From 1da3457e5721bca071f26e02ffa807d5fb074666 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:37:51 +0000 Subject: [PATCH 01/19] add test case --- docs/serialization-attributes.md | 20 --- .../Serialization/ObjectMapperTests.cs | 138 ++++++++++++++++-- 2 files changed, 124 insertions(+), 34 deletions(-) diff --git a/docs/serialization-attributes.md b/docs/serialization-attributes.md index 9b9a544..a6c13bc 100644 --- a/docs/serialization-attributes.md +++ b/docs/serialization-attributes.md @@ -424,26 +424,6 @@ public class Repository } ``` ---- - -## Decision Tree: Which Attribute to Use? - -``` -Is the data a positional value after the node name? -├─ YES → [KdlArgument(index)] -└─ NO - │ - Is the data a key=value pair on the same line? - ├─ YES → [KdlProperty("key")] - └─ NO - │ - Is the data inside a { } children block? - ├─ YES → [KdlNode("childNodeName")] - └─ NO → Not mappable with current attributes -``` - ---- - ## Limitations & Notes 1. **No dictionary support** — `Dictionary` types are not currently supported diff --git a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs index 718408c..750e81a 100644 --- a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs @@ -481,10 +481,48 @@ public async Task DeserializeToScalar_WithMultipleRootNodes_ThrowsException() { // Arrange var kdl = """ - package "my-lib" version="1.0.0" - package "my-dep1" version="2.1.0" - package "my-dep2" version="3.2.1" - """; +layouts { + classicFocus { + rows { + header height=3 + typingArea ratio=3 + footer height=1 + } + } + dashboard { + columns { + gameInfo width=25 + rows ratio=4 { + header height=3 + typingArea ratio=3 + footer height=1 + } + typingInfo width=25 + } + } +} +themes { + default { + // Global fallbacks + default borderColor="gray50" borderStyle="rounded" + typingArea { + borderColor "yellow" + headerText "[yellow]Type here[/]" + padding 1 + alignment vertical="middle" horizontal="center" + } + header { + borderColor "blue" + headerText "[bold blue]Typical[/]" + } + gameInfo { + borderColor "blue" + headerText "Stats" + alignment vertical="middle" + } + } +} +"""; // Act & Assert await Assert @@ -492,21 +530,93 @@ await Assert .Throws(); } + public class AppSettings + { + [KdlNode] + public LayoutPresetDict Layouts { get; set; } = []; + } + + public class Theme : Dictionary { } + + public class LayoutPresetDict : Dictionary; + + public class ThemeDict : Dictionary { } + + public class ElementStyle + { + public string? BorderStyle { get; set; } + + public bool Wrap { get; set; } = true; + } + + [KdlType("layout")] + public class LayoutNode + { + public string Name { get; set; } = default!; + public int? Ratio { get; set; } = 1; + + [KdlArgument(0)] + public string? SplitDirection { get; set; } = "Columns"; + + [KdlNode("panels")] + [KdlProperty("")] + public List Children { get; set; } = []; + } + [Test] - public async Task DeserializeToDictionary_ThrowsException() + public async Task DeserializeToDictionary_MapsCorrectly() { + // Arrange // Arrange var kdl = """ - package "my-lib" version="1.0.0" - reference "my-dep1" version="2.1.0" - node "my-dep2" version="3.2.1" - """; +layouts { + classicFocus { + rows { + header height=3 + typingArea ratio=3 + footer height=1 + } + } + dashboard { + columns { + gameInfo width=25 + rows ratio=4 { + header height=3 + typingArea ratio=3 + footer height=1 + } + typingInfo width=25 + } + } +} +themes { + default { + // Global fallbacks + default borderColor="gray50" borderStyle="rounded" + typingArea { + borderColor "yellow" + headerText "[yellow]Type here[/]" + padding 1 + alignment vertical="middle" horizontal="center" + } + header { + borderColor "blue" + headerText "[bold blue]Typical[/]" + } + gameInfo { + borderColor "blue" + headerText "Stats" + alignment vertical="middle" + } + } +} +"""; - // Act & Assert - await Assert - .That(() => KdlSerializer.Deserialize>(kdl)) - .Throws() - .WithMessageContaining("not supported"); + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Layouts["classicFocus"]).IsNotNull(); } [Test] From 04e559411d35b32ae05d95cb47be84ffc8374278 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Sun, 21 Dec 2025 01:54:40 +0000 Subject: [PATCH 02/19] internal changes --- .../Errors/ErrorHandlingTests.cs | 27 -- src/Kuddle.Net.Tests/Extensions/DxTests.cs | 6 - .../Grammar/NodeParserTests.cs | 5 - .../Grammar/StringParserTests.cs | 70 +-- .../Grammar/WhiteSpaceParsersTests.cs | 20 - .../Serialization/AppSettings.cs | 82 ++++ .../Serialization/KdlTypeInfoTests.cs | 143 ++++++ .../Serialization/ObjectMapperTests.cs | 209 ++------ .../Serialization/TypeAnnotationTests.cs | 454 +++++++++--------- src/Kuddle.Net/Extensions/TypeExtensions.cs | 62 ++- .../Attributes/KdlArgumentAttribute.cs | 3 +- .../Attributes/KdlEntryAttribute.cs | 9 +- .../Attributes/KdlNodeAttribute.cs | 8 +- .../Attributes/KdlPropertyAttribute.cs | 3 +- src/Kuddle.Net/Serialization/KdlMemberInfo.cs | 34 ++ src/Kuddle.Net/Serialization/KdlSerializer.cs | 451 +---------------- .../Serialization/KdlSerializerOptions.cs | 27 ++ src/Kuddle.Net/Serialization/KdlTypeInfo.cs | 145 ++++++ .../Serialization/KdlValueConverter.cs | 11 +- src/Kuddle.Net/Serialization/KdlWriter.cs | 1 - .../Serialization/ObjectDeserializer.cs | 340 +++++++++++++ .../Serialization/ObjectSerializer.cs | 249 ++++++++++ src/Kuddle.Net/Serialization/TypeMetadata.cs | 119 ----- todo.md | 104 ++++ 24 files changed, 1484 insertions(+), 1098 deletions(-) create mode 100644 src/Kuddle.Net.Tests/Serialization/AppSettings.cs create mode 100644 src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs create mode 100644 src/Kuddle.Net/Serialization/KdlMemberInfo.cs create mode 100644 src/Kuddle.Net/Serialization/KdlSerializerOptions.cs create mode 100644 src/Kuddle.Net/Serialization/KdlTypeInfo.cs create mode 100644 src/Kuddle.Net/Serialization/ObjectDeserializer.cs create mode 100644 src/Kuddle.Net/Serialization/ObjectSerializer.cs delete mode 100644 src/Kuddle.Net/Serialization/TypeMetadata.cs create mode 100644 todo.md diff --git a/src/Kuddle.Net.Tests/Errors/ErrorHandlingTests.cs b/src/Kuddle.Net.Tests/Errors/ErrorHandlingTests.cs index 84c6ec0..5157be9 100644 --- a/src/Kuddle.Net.Tests/Errors/ErrorHandlingTests.cs +++ b/src/Kuddle.Net.Tests/Errors/ErrorHandlingTests.cs @@ -119,33 +119,6 @@ public async Task RawString_MismatchHashes_Throws() await AssertParseFails(input, "expected"); } - // [Test] - // public async Task MultilineString_Dedent_Invalid_Throws() - // { - // const string input = "\"\"\"\n content"; - // await AssertParseFails(input, "expected"); - // } - #endregion - - // #region Numbers - - // [Test] - // public async Task Hex_InvalidDigit_Throws() - // { - // const string input = "node 0xG"; - - // await AssertParseFails(input, "expected"); - // } - - // [Test] - // public async Task Number_DoubleDot_Throws() - // { - // const string input = "node 1.2.3"; - - // await AssertParseFails(input, "expected"); - // } - - // #endregion } #pragma warning restore CS8602 // Dereference of a possibly null reference. diff --git a/src/Kuddle.Net.Tests/Extensions/DxTests.cs b/src/Kuddle.Net.Tests/Extensions/DxTests.cs index aa230db..a899f6a 100644 --- a/src/Kuddle.Net.Tests/Extensions/DxTests.cs +++ b/src/Kuddle.Net.Tests/Extensions/DxTests.cs @@ -113,7 +113,6 @@ public async Task TryGetUuid_FromAnnotatedValue_ReturnsTrue() await Assert.That(success).IsTrue(); await Assert.That(result).IsEqualTo(expected); - // Verify annotation didn't interfere await Assert.That(val.TypeAnnotation).IsEqualTo("uuid"); } @@ -123,13 +122,10 @@ public async Task Factory_FromGuid_CreatesAnnotatedString() var guid = Guid.NewGuid(); var val = KdlValue.From(guid)!; - // 1. Check Runtime Type await Assert.That(val).IsOfType(typeof(KdlString)); - // 2. Check Content await Assert.That(val.Value).IsEqualTo(guid.ToString()); - // 3. Check Type Annotation (Crucial for KDL semantic correctness) await Assert.That(val.TypeAnnotation).IsEqualTo("uuid"); } @@ -137,7 +133,6 @@ public async Task Factory_FromGuid_CreatesAnnotatedString() public async Task TryGetDateTime_ValidIso8601_ReturnsTrue() { var now = DateTimeOffset.UtcNow; - // Round-trip format "O" is standard for KDL/JSON var kdl = $"node \"{now:O}\""; var doc = KdlReader.Read(kdl); var val = doc.Nodes[0].Arg(0)!; @@ -145,7 +140,6 @@ public async Task TryGetDateTime_ValidIso8601_ReturnsTrue() bool success = val.TryGetDateTime(out var result); await Assert.That(success).IsTrue(); - // Compare ticks to ensure precision is kept await Assert.That(result).IsEqualTo(now); } diff --git a/src/Kuddle.Net.Tests/Grammar/NodeParserTests.cs b/src/Kuddle.Net.Tests/Grammar/NodeParserTests.cs index a8f59d7..a0396f2 100644 --- a/src/Kuddle.Net.Tests/Grammar/NodeParserTests.cs +++ b/src/Kuddle.Net.Tests/Grammar/NodeParserTests.cs @@ -40,27 +40,22 @@ public async Task Prop_ParsesSimpleProperty() public async Task Node_ParsesComplexLine() { var sut = KdlGrammar.Node; - // Test: Name, Arg, Prop, Type Annotation var input = "(my-type)node 123 key=\"value\";"; bool success = sut.TryParse(input, out var node); await Assert.That(success).IsTrue(); - // Check Name & Type await Assert.That(node.Name.Value).IsEqualTo("node"); await Assert.That(node.TypeAnnotation).IsEqualTo("my-type"); await Assert.That(node.TerminatedBySemicolon).IsTrue(); - // Check Entries await Assert.That(node.Entries).Count().IsEqualTo(2); - // Arg 1: 123 var arg = node.Entries[0] as KdlArgument; await Assert.That(arg).IsNotNull(); await Assert.That(((KdlNumber)arg!.Value).ToInt32()).IsEqualTo(123); - // Prop 2: key="value" var prop = node.Entries[1] as KdlProperty; await Assert.That(prop).IsNotNull(); await Assert.That(prop!.Key.Value).IsEqualTo("key"); diff --git a/src/Kuddle.Net.Tests/Grammar/StringParserTests.cs b/src/Kuddle.Net.Tests/Grammar/StringParserTests.cs index 622221f..61ce170 100644 --- a/src/Kuddle.Net.Tests/Grammar/StringParserTests.cs +++ b/src/Kuddle.Net.Tests/Grammar/StringParserTests.cs @@ -8,13 +8,6 @@ namespace Kuddle.Tests.Grammar; public class StringParserTests { - // Note: These tests are stubs until StringParsers is implemented - // They represent the string parsing rules from the KDL grammar: - // string := identifier-string | quoted-string | raw-string - // identifier-string := unambiguous-ident | signed-ident | dotted-ident - // quoted-string := '"' single-line-string-body '"' | '"""' newline (multi-line-string-body newline)? (unicode-space | ws-escape)* '"""' - // raw-string := '#' raw-string-quotes '#' | '#' raw-string '#' - [Test] [Arguments("+positive")] [Arguments("-negative")] @@ -470,62 +463,17 @@ public async Task String_UnifiedParser_DetectsRaw() await Assert.That(success).IsTrue(); await Assert.That(value.Kind.HasFlag(StringKind.Raw)).IsTrue(); } - // [Test] - // public async Task String_ParsesIdentifierString() - // { - // var sut = KuddleGrammar.String; - - // var input = "hello"; - // bool success = sut.TryParse(input, out var value); - - // await Assert.That(success).IsTrue(); - // await Assert.That(value.ToString()).IsEqualTo(input); - // } - - // [Test] - // public async Task String_ParsesQuotedString() - // { - // var sut = KuddleGrammar.String; - - // var input = "\"hello world\""; - // bool success = sut.TryParse(input, out var value); - - // await Assert.That(success).IsTrue(); - // await Assert.That(value.ToString()).IsEqualTo(input); - // } - - // [Test] - // public async Task String_ParsesRawString() - // { - // var sut = KuddleGrammar.String; - - // var input = "#\"hello world\"#"; - // bool success = sut.TryParse(input, out var value); - - // await Assert.That(success).IsTrue(); - // await Assert.That(value.ToString()).IsEqualTo(input); - // } - - // [Test] - // public async Task QuotedString_RejectsUnterminatedString() - // { - // var sut = KuddleGrammar.QuotedString; - // var input = "\"unterminated string"; - // bool success = sut.TryParse(input, out var value); - - // await Assert.That(success).IsFalse(); - // } - - // [Test] - // public async Task RawString_RejectsMismatchedDelimiters() - // { - // var sut = KuddleGrammar.RawString; + [Test] + public async Task String_ParsesIdentifierString() + { + var sut = KdlGrammar.String; - // var input = "#\"content\"##"; - // bool success = sut.TryParse(input, out var value); + var input = "hello"; + bool success = sut.TryParse(input, out var value); - // await Assert.That(success).IsFalse(); - // } + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(input); + } } #pragma warning restore CS8602 // Dereference of a possibly null reference. diff --git a/src/Kuddle.Net.Tests/Grammar/WhiteSpaceParsersTests.cs b/src/Kuddle.Net.Tests/Grammar/WhiteSpaceParsersTests.cs index 4dd9a40..bc34842 100644 --- a/src/Kuddle.Net.Tests/Grammar/WhiteSpaceParsersTests.cs +++ b/src/Kuddle.Net.Tests/Grammar/WhiteSpaceParsersTests.cs @@ -4,14 +4,6 @@ namespace Kuddle.Tests.Grammar; public class WhiteSpaceParsersTests { - // Note: These tests are stubs until WhiteSpaceParsers is implemented - // They represent the whitespace parsing rules from the KDL grammar: - // ws := unicode-space | multi-line-comment - // escline := '\\' ws* (single-line-comment | newline | eof) - // newline := See Table (All NewLine White_Space) - // line-space := node-space | newline | single-line-comment - // node-space := ws* escline ws* | ws+ - [Test] [Arguments('\u0009')] [Arguments('\u0020')] @@ -125,16 +117,4 @@ public async Task NodeSpace_ParsesSimpleWhitespace() await Assert.That(success).IsTrue(); await Assert.That(value.Span.ToString()).IsEqualTo(input); } - - // [Test] - // public async Task NodeSpace_ParsesEscapedNewLine() - // { - // var sut = KuddleGrammar.NodeSpace; - - // var input = " \\\n "; - // bool success = sut.TryParse(input, out var value); - - // await Assert.That(success).IsTrue(); - // await Assert.That(value.Span.ToString()).IsEqualTo(input); - // } } diff --git a/src/Kuddle.Net.Tests/Serialization/AppSettings.cs b/src/Kuddle.Net.Tests/Serialization/AppSettings.cs new file mode 100644 index 0000000..5f8f97d --- /dev/null +++ b/src/Kuddle.Net.Tests/Serialization/AppSettings.cs @@ -0,0 +1,82 @@ +using Kuddle.Serialization; + +namespace Kuddle.Tests.Serialization; + +public class AppSettings +{ + [KdlNodeDictionary("themes")] + public Dictionary Themes { get; set; } = new(); + + [KdlNodeDictionary("layouts")] + public Dictionary Layouts { get; set; } = new(); +} + +public class Theme : Dictionary { } + +public class LayoutDefinition +{ + [KdlProperty("section")] + public string Section { get; set; } = default!; + + [KdlProperty("size")] + public int Ratio { get; set; } = 1; + + [KdlProperty("split")] + public string SplitDirection { get; set; } = "columns"; + + [KdlNode("child")] + public List Children { get; set; } = []; +} + +public class ElementStyle +{ + [KdlNode("border")] + public BorderStyleSettings? BorderStyle { get; set; } + + [KdlNode("header")] + public PanelHeaderSettings? PanelHeader { get; set; } + + [KdlNode("align")] + public AlignmentSettings? Alignment { get; set; } + + [KdlIgnore] + public bool WrapInPanel { get; internal set; } = true; +} + +public class BorderStyleSettings +{ + [KdlProperty("color")] + public string? ForegroundColor { get; set; } + + [KdlProperty("style")] + public string? Decoration { get; set; } +} + +public class PanelHeaderSettings +{ + [KdlProperty("text")] + public string? Text { get; set; } +} + +public class AlignmentSettings +{ + [KdlProperty("v")] + public VerticalAlignment Vertical { get; set; } + + [KdlProperty("h")] + public HorizontalAlignment Horizontal { get; set; } +} + +public enum VerticalAlignment +{ + Top, + Middle, + Bottom, +} + +public enum HorizontalAlignment +{ + Left, + Center, + Right, +} diff --git a/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs b/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs new file mode 100644 index 0000000..6cf17be --- /dev/null +++ b/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs @@ -0,0 +1,143 @@ +using Kuddle.Serialization; + +namespace Kuddle.Tests.Serialization; + +public class KdlTypeInfoTests +{ + [Test] + public async Task ArgumentContinuity_MissingIndex_Throws() + { + var ex = Assert.Throws(() => + KdlTypeInfo.For() + ); + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task PropertyAndNode_Mapping_Works() + { + var info = KdlTypeInfo.For(); + await Assert.That(info.Properties.Count).IsEqualTo(1); + await Assert.That(info.Children.Count).IsEqualTo(1); + } + + [Test] + public async Task NodeDictionary_Property_IsDetected() + { + var info = KdlTypeInfo.For(); + await Assert.That(info.Dictionaries.Count).IsEqualTo(1); + + var dictInfo = KdlTypeInfo.For>(); + await Assert.That(dictInfo.IsDictionary).IsTrue(); + await Assert.That(dictInfo.DictionaryDef).IsNotNull(); + await Assert.That(dictInfo.DictionaryDef!.ValueType).IsEqualTo(typeof(string)); + } + + [Test] + public async Task CollectionDetection_Works_And_Dictionaries_Are_Not_Collections() + { + var arrInfo = KdlTypeInfo.For(); + await Assert.That(arrInfo.CollectionElementType).IsEqualTo(typeof(int)); + await Assert.That(arrInfo.IsIEnumerable).IsTrue(); + + var dictInfo = KdlTypeInfo.For>(); + await Assert.That(dictInfo.IsIEnumerable).IsFalse(); + } + + [Test] + public async Task KdlTypeAttribute_Overrides_NodeName() + { + var info = KdlTypeInfo.For(); + await Assert.That(info.NodeName).IsEqualTo("my-node"); + } + + [Test] + public async Task IsStrictNode_Behaves_Correctly() + { + var plain = KdlTypeInfo.For(); + await Assert.That(plain.IsStrictNode).IsFalse(); + + var withType = KdlTypeInfo.For(); + await Assert.That(withType.IsStrictNode).IsTrue(); + + var withProp = KdlTypeInfo.For(); + await Assert.That(withProp.IsStrictNode).IsTrue(); + } + + [Test] + public async Task For_Is_Cached() + { + var a = KdlTypeInfo.For(); + var b = KdlTypeInfo.For(); + await Assert.That(object.ReferenceEquals(a, b)).IsTrue(); + } + + [Test] + public async Task KdlTypeInfo_Throws_ForEveryAttributeCombination() + { + var typesToTest = new[] + { + typeof(Conflict_Property_Node), + typeof(Conflict_Property_Argument), + typeof(Conflict_Node_NodeDict), + }; + + foreach (var t in typesToTest) + { + var exception = Assert.Throws(() => KdlTypeInfo.For(t)); + await Assert.That(exception).IsNotNull(); + } + } + + // Test types + + private class Conflict_Property_Node + { + [KdlProperty("k")] + [KdlNode("n")] + public string? Prop { get; set; } + } + + private class Conflict_Property_Argument + { + [KdlProperty("k")] + [KdlArgument(0)] + public string? Prop { get; set; } + } + + private class Conflict_Node_NodeDict + { + [KdlNode("n")] + [KdlNodeDictionary("d")] + public string? Prop { get; set; } + } + + private class ArgContinuityBad + { + [KdlArgument(0)] + public string? A { get; set; } + + [KdlArgument(2)] + public string? B { get; set; } + } + + private class PropertyNodeMap + { + [KdlProperty("k")] + public string? Prop { get; set; } + + [KdlNode("n")] + public string? Child { get; set; } + } + + private class NodeDictHolder + { + [KdlNodeDictionary("d")] + public Dictionary? Dict { get; set; } + } + + [KdlType("my-node")] + private class CustomName { } + + private class PlainDocument { } +} diff --git a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs index 750e81a..dcade7c 100644 --- a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs @@ -354,44 +354,6 @@ public async Task RoundTrip_NestedObject_PreservesData() #endregion - // #region Polymorphism Tests - - // [Test] - // public async Task DeserializeObject_WithTypeDiscriminator_MapsToCorrectSubclass() - // { - // // Arrange - // var kdl = """ - // resource "config" type="file" path="/etc/config.toml" - // """; - - // // Act - // var result = KdlSerializer.Deserialize(kdl); - - // // Assert - // await Assert.That(result).IsOfType(typeof(FileResource)); - // var fileResource = (FileResource)result; - // await Assert.That(fileResource.Path).IsEqualTo("/etc/config.toml"); - // } - - // [Test] - // public async Task DeserializeObject_WithDifferentTypeDiscriminator_MapsToOtherSubclass() - // { - // // Arrange - // var kdl = """ - // resource "api" type="url" url="https://api.example.com" - // """; - - // // Act - // var result = KdlSerializer.Deserialize(kdl); - - // // Assert - // await Assert.That(result).IsOfType(typeof(UrlResource)); - // var urlResource = (UrlResource)result; - // await Assert.That(urlResource.Url).IsEqualTo("https://api.example.com"); - // } - - // #endregion - #region Edge Cases [Test] @@ -477,136 +439,46 @@ await Assert } [Test] - public async Task DeserializeToScalar_WithMultipleRootNodes_ThrowsException() + public async Task DeserializeToDictionary_MapsCorrectly() { // Arrange var kdl = """ -layouts { - classicFocus { - rows { - header height=3 - typingArea ratio=3 - footer height=1 - } - } - dashboard { - columns { - gameInfo width=25 - rows ratio=4 { - header height=3 - typingArea ratio=3 - footer height=1 - } - typingInfo width=25 - } - } -} +// 1. The "themes" dictionary (Key = Theme Name) themes { - default { - // Global fallbacks - default borderColor="gray50" borderStyle="rounded" - typingArea { - borderColor "yellow" - headerText "[yellow]Type here[/]" - padding 1 - alignment vertical="middle" horizontal="center" - } - header { - borderColor "blue" - headerText "[bold blue]Typical[/]" + // Key: "dark-mode" -> Value: Theme (which is also a Dictionary) + dark-mode { + // Key: "window" -> Value: ElementStyle + window { + align v="Top" h="Left" + border color="#FFFFFF" style="solid" } - gameInfo { - borderColor "blue" - headerText "Stats" - alignment vertical="middle" - } - } -} -"""; - - // Act & Assert - await Assert - .That(async () => KdlSerializer.Deserialize(kdl)) - .Throws(); - } - - public class AppSettings - { - [KdlNode] - public LayoutPresetDict Layouts { get; set; } = []; - } - - public class Theme : Dictionary { } - - public class LayoutPresetDict : Dictionary; - - public class ThemeDict : Dictionary { } - public class ElementStyle - { - public string? BorderStyle { get; set; } - - public bool Wrap { get; set; } = true; - } - - [KdlType("layout")] - public class LayoutNode - { - public string Name { get; set; } = default!; - public int? Ratio { get; set; } = 1; - - [KdlArgument(0)] - public string? SplitDirection { get; set; } = "Columns"; - - [KdlNode("panels")] - [KdlProperty("")] - public List Children { get; set; } = []; - } - - [Test] - public async Task DeserializeToDictionary_MapsCorrectly() - { - // Arrange - // Arrange - var kdl = """ -layouts { - classicFocus { - rows { - header height=3 - typingArea ratio=3 - footer height=1 + // Key: "button" -> Value: ElementStyle + button { + header text="Click Me" + align v="Middle" h="Center" } } - dashboard { - columns { - gameInfo width=25 - rows ratio=4 { - header height=3 - typingArea ratio=3 - footer height=1 - } - typingInfo width=25 + + // Key: "high-contrast" + high-contrast { + window { + border color="#FFFF00" } } } -themes { - default { - // Global fallbacks - default borderColor="gray50" borderStyle="rounded" - typingArea { - borderColor "yellow" - headerText "[yellow]Type here[/]" - padding 1 - alignment vertical="middle" horizontal="center" - } - header { - borderColor "blue" - headerText "[bold blue]Typical[/]" - } - gameInfo { - borderColor "blue" - headerText "Stats" - alignment vertical="middle" + +// 2. The "layouts" dictionary (Key = Layout Name) +layouts { + // Key: "dashboard" -> Value: LayoutDefinition + dashboard section="main-view" size=1 split="rows" { + + // Recursive List (Children) + child section="top-bar" size=1 + + child section="content-area" size=4 split="columns" { + child section="sidebar" size=1 + child section="grid" size=3 } } } @@ -616,7 +488,14 @@ padding 1 var result = KdlSerializer.Deserialize(kdl); // Assert - await Assert.That(result.Layouts["classicFocus"]).IsNotNull(); + var dashboard = result.Layouts["dashboard"]; + await Assert.That(dashboard).IsNotNull(); + await Assert.That(dashboard.Section).IsEqualTo("main-view"); + await Assert.That(dashboard.Ratio).IsEqualTo(1); + await Assert.That(dashboard.SplitDirection).IsEqualTo("rows"); + + var contentArea = dashboard.Children.FirstOrDefault(c => c.Section == "content-area"); + await Assert.That(contentArea!.Ratio).IsEqualTo(4); } [Test] @@ -663,20 +542,6 @@ await Assert .Throws(); } - // [Test] - // public async Task DeserializeObject_WhenTargetIsSimpleValue_ThrowsException() - // { - // // Arrange - // var kdl = """ - // package - // """; - - // // Act & Assert - // await Assert - // .That(async () => KdlSerializer.Deserialize(kdl)) - // .Throws() - // .WithMessageContaining("Cannot deserialize type"); - // } #endregion #region Test Models diff --git a/src/Kuddle.Net.Tests/Serialization/TypeAnnotationTests.cs b/src/Kuddle.Net.Tests/Serialization/TypeAnnotationTests.cs index 20a3c6f..f2eb20b 100644 --- a/src/Kuddle.Net.Tests/Serialization/TypeAnnotationTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/TypeAnnotationTests.cs @@ -1,227 +1,227 @@ -using System; -using Kuddle.Serialization; - -namespace Kuddle.Tests.Serialization; - -public class TypeAnnotationTests -{ - #region Test Models - - public class PersonWithAnnotations - { - [KdlArgument(0, "uuid")] - public Guid Id { get; set; } - - [KdlProperty("name")] - public string Name { get; set; } = ""; - - [KdlProperty("created", "date-time")] - public DateTimeOffset CreatedAt { get; set; } - - [KdlProperty("age", "i32")] - public int Age { get; set; } - } - - public class ProductWithTypeAnnotations - { - [KdlArgument(0)] - public string Name { get; set; } = ""; - - [KdlProperty("sku", "uuid")] - public Guid Sku { get; set; } - - [KdlProperty("price", "decimal64")] - public decimal Price { get; set; } - - [KdlProperty("stock", "u32")] - public uint StockCount { get; set; } - } - - public class EventWithChildAnnotations - { - [KdlArgument(0)] - public string Name { get; set; } = ""; - - [KdlNode("timestamp", "date-time")] - public DateTimeOffset Timestamp { get; set; } - - [KdlNode("correlation-id", "uuid")] - public Guid CorrelationId { get; set; } - } - - #endregion - - [Test] - public async Task Serialize_WithUuidTypeAnnotation_WritesAnnotation() - { - // Arrange - var person = new PersonWithAnnotations - { - Id = Guid.Parse("123e4567-e89b-12d3-a456-426614174000"), - Name = "Alice", - CreatedAt = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), - Age = 30, - }; - - // Act - var kdl = KdlSerializer.Serialize(person); - - // Assert - await Assert.That(kdl).Contains("(uuid)\"123e4567-e89b-12d3-a456-426614174000\""); - await Assert.That(kdl).Contains("(date-time)\"2024-01-01T00:00:00.0000000+00:00\""); - await Assert.That(kdl).Contains("(i32)30"); - } - - [Test] - public async Task Deserialize_WithUuidTypeAnnotation_ParsesCorrectly() - { - // Arrange - var kdl = """ - personwithannotations (uuid)"123e4567-e89b-12d3-a456-426614174000" name="Alice" created=(date-time)"2024-01-01T00:00:00.0000000+00:00" age=(i32)30 - """; - - // Act - var person = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(person.Id).IsEqualTo(Guid.Parse("123e4567-e89b-12d3-a456-426614174000")); - await Assert.That(person.Name).IsEqualTo("Alice"); - await Assert - .That(person.CreatedAt) - .IsEqualTo(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); - await Assert.That(person.Age).IsEqualTo(30); - } - - [Test] - public async Task Serialize_WithMixedTypeAnnotations_WritesAllAnnotations() - { - // Arrange - var product = new ProductWithTypeAnnotations - { - Name = "Widget", - Sku = Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), - Price = 99.99m, - StockCount = 42, - }; - - // Act - var kdl = KdlSerializer.Serialize(product); - - // Assert - await Assert.That(kdl).Contains("Widget"); - await Assert.That(kdl).Contains("(uuid)\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\""); - await Assert.That(kdl).Contains("(decimal64)99.99"); - await Assert.That(kdl).Contains("(u32)42"); - } - - [Test] - public async Task Deserialize_WithMixedTypeAnnotations_ParsesAllValues() - { - // Arrange - var kdl = """ - productwithtypeannotations "Widget" sku=(uuid)"a1b2c3d4-e5f6-7890-abcd-ef1234567890" price=(decimal64)99.99 stock=(u32)42 - """; - - // Act - var product = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(product.Name).IsEqualTo("Widget"); - await Assert - .That(product.Sku) - .IsEqualTo(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); - await Assert.That(product.Price).IsEqualTo(99.99m); - await Assert.That(product.StockCount).IsEqualTo(42u); - } - - [Test] - public async Task Serialize_WithChildNodeAnnotations_WritesAnnotationsOnChildren() - { - // Arrange - var evt = new EventWithChildAnnotations - { - Name = "UserLoggedIn", - Timestamp = new DateTimeOffset(2024, 12, 17, 10, 30, 0, TimeSpan.Zero), - CorrelationId = Guid.Parse("11111111-2222-3333-4444-555555555555"), - }; - - // Act - var kdl = KdlSerializer.Serialize(evt); - - // Assert - await Assert - .That(kdl) - .Contains("timestamp (date-time)\"2024-12-17T10:30:00.0000000+00:00\""); - await Assert - .That(kdl) - .Contains("correlation-id (uuid)\"11111111-2222-3333-4444-555555555555\""); - } - - [Test] - public async Task Deserialize_WithChildNodeAnnotations_ParsesChildValues() - { - // Arrange - var kdl = """ - eventwithchildannotations "UserLoggedIn" { - timestamp (date-time)"2024-12-17T10:30:00.0000000+00:00" - correlation-id (uuid)"11111111-2222-3333-4444-555555555555" - } - """; - - // Act - var evt = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(evt.Name).IsEqualTo("UserLoggedIn"); - await Assert - .That(evt.Timestamp) - .IsEqualTo(new DateTimeOffset(2024, 12, 17, 10, 30, 0, TimeSpan.Zero)); - await Assert - .That(evt.CorrelationId) - .IsEqualTo(Guid.Parse("11111111-2222-3333-4444-555555555555")); - } - - [Test] - public async Task RoundTrip_WithTypeAnnotations_PreservesValues() - { - // Arrange - var original = new PersonWithAnnotations - { - Id = Guid.NewGuid(), - Name = "Bob", - CreatedAt = DateTimeOffset.UtcNow, - Age = 25, - }; - - // Act - var kdl = KdlSerializer.Serialize(original); - var deserialized = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(deserialized.Id).IsEqualTo(original.Id); - await Assert.That(deserialized.Name).IsEqualTo(original.Name); - await Assert.That(deserialized.CreatedAt).IsEqualTo(original.CreatedAt); - await Assert.That(deserialized.Age).IsEqualTo(original.Age); - } - - [Test] - public async Task Deserialize_WithoutTypeAnnotationInKdl_StillWorks() - { - // Arrange - KDL without type annotation, but C# model has [KdlTypeAnnotation] - // The deserializer should still work because Guid/DateTimeOffset have their own parsing logic - var kdl = """ - personwithannotations "123e4567-e89b-12d3-a456-426614174000" name="Alice" created="2024-01-01T00:00:00.0000000+00:00" age=30 - """; - - // Act - var person = KdlSerializer.Deserialize(kdl); - - // Assert - should parse without type annotations in the KDL - await Assert.That(person.Id).IsEqualTo(Guid.Parse("123e4567-e89b-12d3-a456-426614174000")); - await Assert.That(person.Name).IsEqualTo("Alice"); - await Assert - .That(person.CreatedAt) - .IsEqualTo(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); - await Assert.That(person.Age).IsEqualTo(30); - } -} +// using System; +// using Kuddle.Serialization; + +// namespace Kuddle.Tests.Serialization; + +// public class TypeAnnotationTests +// { +// #region Test Models + +// public class PersonWithAnnotations +// { +// [KdlArgument(0, "uuid")] +// public Guid Id { get; set; } + +// [KdlProperty("name")] +// public string Name { get; set; } = ""; + +// [KdlProperty("created", "date-time")] +// public DateTimeOffset CreatedAt { get; set; } + +// [KdlProperty("age", "i32")] +// public int Age { get; set; } +// } + +// public class ProductWithTypeAnnotations +// { +// [KdlArgument(0)] +// public string Name { get; set; } = ""; + +// [KdlProperty("sku", "uuid")] +// public Guid Sku { get; set; } + +// [KdlProperty("price", "decimal64")] +// public decimal Price { get; set; } + +// [KdlProperty("stock", "u32")] +// public uint StockCount { get; set; } +// } + +// public class EventWithChildAnnotations +// { +// [KdlArgument(0)] +// public string Name { get; set; } = ""; + +// [KdlNode("timestamp", "date-time")] +// public DateTimeOffset Timestamp { get; set; } + +// [KdlNode("correlation-id", "uuid")] +// public Guid CorrelationId { get; set; } +// } + +// #endregion + +// [Test] +// public async Task Serialize_WithUuidTypeAnnotation_WritesAnnotation() +// { +// // Arrange +// var person = new PersonWithAnnotations +// { +// Id = Guid.Parse("123e4567-e89b-12d3-a456-426614174000"), +// Name = "Alice", +// CreatedAt = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), +// Age = 30, +// }; + +// // Act +// var kdl = KdlSerializer.Serialize(person); + +// // Assert +// await Assert.That(kdl).Contains("(uuid)\"123e4567-e89b-12d3-a456-426614174000\""); +// await Assert.That(kdl).Contains("(date-time)\"2024-01-01T00:00:00.0000000+00:00\""); +// await Assert.That(kdl).Contains("(i32)30"); +// } + +// [Test] +// public async Task Deserialize_WithUuidTypeAnnotation_ParsesCorrectly() +// { +// // Arrange +// var kdl = """ +// personwithannotations (uuid)"123e4567-e89b-12d3-a456-426614174000" name="Alice" created=(date-time)"2024-01-01T00:00:00.0000000+00:00" age=(i32)30 +// """; + +// // Act +// var person = KdlSerializer.Deserialize(kdl); + +// // Assert +// await Assert.That(person.Id).IsEqualTo(Guid.Parse("123e4567-e89b-12d3-a456-426614174000")); +// await Assert.That(person.Name).IsEqualTo("Alice"); +// await Assert +// .That(person.CreatedAt) +// .IsEqualTo(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); +// await Assert.That(person.Age).IsEqualTo(30); +// } + +// [Test] +// public async Task Serialize_WithMixedTypeAnnotations_WritesAllAnnotations() +// { +// // Arrange +// var product = new ProductWithTypeAnnotations +// { +// Name = "Widget", +// Sku = Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), +// Price = 99.99m, +// StockCount = 42, +// }; + +// // Act +// var kdl = KdlSerializer.Serialize(product); + +// // Assert +// await Assert.That(kdl).Contains("Widget"); +// await Assert.That(kdl).Contains("(uuid)\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\""); +// await Assert.That(kdl).Contains("(decimal64)99.99"); +// await Assert.That(kdl).Contains("(u32)42"); +// } + +// [Test] +// public async Task Deserialize_WithMixedTypeAnnotations_ParsesAllValues() +// { +// // Arrange +// var kdl = """ +// productwithtypeannotations "Widget" sku=(uuid)"a1b2c3d4-e5f6-7890-abcd-ef1234567890" price=(decimal64)99.99 stock=(u32)42 +// """; + +// // Act +// var product = KdlSerializer.Deserialize(kdl); + +// // Assert +// await Assert.That(product.Name).IsEqualTo("Widget"); +// await Assert +// .That(product.Sku) +// .IsEqualTo(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); +// await Assert.That(product.Price).IsEqualTo(99.99m); +// await Assert.That(product.StockCount).IsEqualTo(42u); +// } + +// [Test] +// public async Task Serialize_WithChildNodeAnnotations_WritesAnnotationsOnChildren() +// { +// // Arrange +// var evt = new EventWithChildAnnotations +// { +// Name = "UserLoggedIn", +// Timestamp = new DateTimeOffset(2024, 12, 17, 10, 30, 0, TimeSpan.Zero), +// CorrelationId = Guid.Parse("11111111-2222-3333-4444-555555555555"), +// }; + +// // Act +// var kdl = KdlSerializer.Serialize(evt); + +// // Assert +// await Assert +// .That(kdl) +// .Contains("timestamp (date-time)\"2024-12-17T10:30:00.0000000+00:00\""); +// await Assert +// .That(kdl) +// .Contains("correlation-id (uuid)\"11111111-2222-3333-4444-555555555555\""); +// } + +// [Test] +// public async Task Deserialize_WithChildNodeAnnotations_ParsesChildValues() +// { +// // Arrange +// var kdl = """ +// eventwithchildannotations "UserLoggedIn" { +// timestamp (date-time)"2024-12-17T10:30:00.0000000+00:00" +// correlation-id (uuid)"11111111-2222-3333-4444-555555555555" +// } +// """; + +// // Act +// var evt = KdlSerializer.Deserialize(kdl); + +// // Assert +// await Assert.That(evt.Name).IsEqualTo("UserLoggedIn"); +// await Assert +// .That(evt.Timestamp) +// .IsEqualTo(new DateTimeOffset(2024, 12, 17, 10, 30, 0, TimeSpan.Zero)); +// await Assert +// .That(evt.CorrelationId) +// .IsEqualTo(Guid.Parse("11111111-2222-3333-4444-555555555555")); +// } + +// [Test] +// public async Task RoundTrip_WithTypeAnnotations_PreservesValues() +// { +// // Arrange +// var original = new PersonWithAnnotations +// { +// Id = Guid.NewGuid(), +// Name = "Bob", +// CreatedAt = DateTimeOffset.UtcNow, +// Age = 25, +// }; + +// // Act +// var kdl = KdlSerializer.Serialize(original); +// var deserialized = KdlSerializer.Deserialize(kdl); + +// // Assert +// await Assert.That(deserialized.Id).IsEqualTo(original.Id); +// await Assert.That(deserialized.Name).IsEqualTo(original.Name); +// await Assert.That(deserialized.CreatedAt).IsEqualTo(original.CreatedAt); +// await Assert.That(deserialized.Age).IsEqualTo(original.Age); +// } + +// [Test] +// public async Task Deserialize_WithoutTypeAnnotationInKdl_StillWorks() +// { +// // Arrange - KDL without type annotation, but C# model has [KdlTypeAnnotation] +// // The deserializer should still work because Guid/DateTimeOffset have their own parsing logic +// var kdl = """ +// personwithannotations "123e4567-e89b-12d3-a456-426614174000" name="Alice" created="2024-01-01T00:00:00.0000000+00:00" age=30 +// """; + +// // Act +// var person = KdlSerializer.Deserialize(kdl); + +// // Assert - should parse without type annotations in the KDL +// await Assert.That(person.Id).IsEqualTo(Guid.Parse("123e4567-e89b-12d3-a456-426614174000")); +// await Assert.That(person.Name).IsEqualTo("Alice"); +// await Assert +// .That(person.CreatedAt) +// .IsEqualTo(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); +// await Assert.That(person.Age).IsEqualTo(30); +// } +// } diff --git a/src/Kuddle.Net/Extensions/TypeExtensions.cs b/src/Kuddle.Net/Extensions/TypeExtensions.cs index 5aa58a6..4c67b79 100644 --- a/src/Kuddle.Net/Extensions/TypeExtensions.cs +++ b/src/Kuddle.Net/Extensions/TypeExtensions.cs @@ -74,6 +74,66 @@ internal static class TypeExtensions .Select(x => (x.Property, x.ChildAttr!)) .ToArray(); - public bool IsNullable() => Nullable.GetUnderlyingType(type) != null; + internal DictionaryInfo? GetDictionaryInfo() + { + if (type == typeof(string)) + return null; + + var args = type.GetInterfaces() + .Append(type) + .FirstOrDefault(i => + i.IsGenericType + && ( + i.GetGenericTypeDefinition() == typeof(IDictionary<,>) + || i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) + ) + ) + ?.GetGenericArguments(); + + return args is null ? null : new DictionaryInfo(args[0], args[1]); + } + + internal Type? GetCollectionInfo() + { + if (type == typeof(string)) + return null; + + // Exclude dictionaries from being treated as simple collections + if (type.IsDictionary) + return null; + + // Arrays + if (type.IsArray) + return type.GetElementType(); + + // IEnumerable + var enumInterface = type.GetInterfaces() + .Append(type) + .FirstOrDefault(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) + ); + + if (enumInterface != null) + { + return enumInterface.GetGenericArguments()[0]; + } + + return null; + } + + internal bool IsKdlScalar => + type.IsPrimitive + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(Guid); + + public Type GetCollectionElementType() => + type.IsArray ? type.GetElementType()! + : type.IsGenericType ? type.GetGenericArguments()[0] + : throw new KuddleSerializationException( + $"Unsupported collection type '{type.FullName}'." + ); } } diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlArgumentAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlArgumentAttribute.cs index 988719a..dd1c193 100644 --- a/src/Kuddle.Net/Serialization/Attributes/KdlArgumentAttribute.cs +++ b/src/Kuddle.Net/Serialization/Attributes/KdlArgumentAttribute.cs @@ -3,8 +3,7 @@ namespace Kuddle.Serialization; [AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] -public sealed class KdlArgumentAttribute(int index, string? typeAnnotation = null) - : KdlEntryAttribute(typeAnnotation) +public sealed class KdlArgumentAttribute(int index) : KdlEntryAttribute { public int Index { get; } = index; } diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlEntryAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlEntryAttribute.cs index 27f0dba..705c6f9 100644 --- a/src/Kuddle.Net/Serialization/Attributes/KdlEntryAttribute.cs +++ b/src/Kuddle.Net/Serialization/Attributes/KdlEntryAttribute.cs @@ -6,15 +6,10 @@ namespace Kuddle.Serialization; /// Base class for KDL entry attributes. Only one entry attribute can be applied per property. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public abstract class KdlEntryAttribute : Attribute +public abstract class KdlEntryAttribute(string? typeAnnotation = null) : Attribute { /// /// Optional KDL type annotation (e.g., "uuid", "date-time", "i32"). /// - public string? TypeAnnotation { get; } - - protected KdlEntryAttribute(string? typeAnnotation = null) - { - TypeAnnotation = typeAnnotation; - } + public string? TypeAnnotation { get; } = typeAnnotation; } diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs index 23a03cc..ea868d7 100644 --- a/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs +++ b/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs @@ -3,8 +3,12 @@ namespace Kuddle.Serialization; [AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] -public sealed class KdlNodeAttribute(string? name = null, string? typeAnnotation = null) - : KdlEntryAttribute(typeAnnotation) +public sealed class KdlNodeAttribute(string? name = null) : KdlEntryAttribute +{ + public string? Name { get; } = name; +} + +public class KdlNodeDictionaryAttribute(string? name = null) : KdlEntryAttribute { public string? Name { get; } = name; } diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlPropertyAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlPropertyAttribute.cs index 916208e..6cd78ab 100644 --- a/src/Kuddle.Net/Serialization/Attributes/KdlPropertyAttribute.cs +++ b/src/Kuddle.Net/Serialization/Attributes/KdlPropertyAttribute.cs @@ -3,8 +3,7 @@ namespace Kuddle.Serialization; [AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] -public sealed class KdlPropertyAttribute(string? key = null, string? typeAnnotation = null) - : KdlEntryAttribute(typeAnnotation) +public sealed class KdlPropertyAttribute(string? key = null) : KdlEntryAttribute { public string? Key { get; } = key; } diff --git a/src/Kuddle.Net/Serialization/KdlMemberInfo.cs b/src/Kuddle.Net/Serialization/KdlMemberInfo.cs new file mode 100644 index 0000000..2c16fcc --- /dev/null +++ b/src/Kuddle.Net/Serialization/KdlMemberInfo.cs @@ -0,0 +1,34 @@ +using System; +using System.Reflection; + +namespace Kuddle.Serialization; + +/// +/// Represents a mapping from a C# property to a KDL entry (argument, property, or child node). +/// +internal sealed record KdlMemberInfo(PropertyInfo Property, Attribute? Attribute) +{ + public bool IsArgument => Attribute is KdlArgumentAttribute; + public bool IsProperty => Attribute is KdlPropertyAttribute; + public bool IsNode => Attribute is KdlNodeAttribute; + public bool IsNodeDictionary => Attribute is KdlNodeDictionaryAttribute; + + //TODO: Add support for property dictionaries + public bool IsPropertyDictionary => false; + + //TODO: Add support for keyed node collections + public bool IsKeyedNodeCollection => false; + + public int ArgumentIndex => Attribute is KdlArgumentAttribute arg ? arg.Index : -1; + + public string Name => + Attribute switch + { + KdlPropertyAttribute p => p.Key ?? Property.Name.ToLowerInvariant(), + KdlNodeAttribute n => n.Name ?? Property.Name.ToLowerInvariant(), + KdlNodeDictionaryAttribute nd => nd.Name ?? Property.Name.ToLowerInvariant(), + _ => Property.Name.ToLowerInvariant(), + }; + + public string? TypeAnnotation => null; +} diff --git a/src/Kuddle.Net/Serialization/KdlSerializer.cs b/src/Kuddle.Net/Serialization/KdlSerializer.cs index 388eab8..a3eb5de 100644 --- a/src/Kuddle.Net/Serialization/KdlSerializer.cs +++ b/src/Kuddle.Net/Serialization/KdlSerializer.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Reflection; using System.Threading; @@ -14,8 +15,6 @@ namespace Kuddle.Serialization; /// public static class KdlSerializer { - private const StringComparison NodeNameComparison = StringComparison.OrdinalIgnoreCase; - #region Deserialization /// @@ -29,22 +28,12 @@ public static IEnumerable DeserializeMany( where T : new() { var doc = KdlReader.Read(text); - var metadata = TypeMetadata.For(); + var metadata = KdlTypeInfo.For(); foreach (var node in doc.Nodes) { cancellationToken.ThrowIfCancellationRequested(); - - if (!node.Name.Value.Equals(metadata.NodeName, NodeNameComparison)) - { - throw new KuddleSerializationException( - $"Expected node '{metadata.NodeName}', found '{node.Name.Value}'." - ); - } - - var item = new T(); - MapNodeToObject(node, item, metadata); - yield return item; + yield return ObjectDeserializer.DeserializeNode(node, options); } } @@ -54,233 +43,9 @@ public static IEnumerable DeserializeMany( public static T Deserialize(string text, KdlSerializerOptions? options = null) where T : new() { - var document = KdlReader.Read(text); - var metadata = TypeMetadata.For(); - - // Reject dictionary types - if (metadata.IsDictionary) - { - throw new KuddleSerializationException( - $"Dictionary deserialization is not supported. Type: {metadata.Type.Name}" - ); - } - - // Reject IEnumerable types (use DeserializeMany instead) - if (metadata.IsIEnumerable) - { - throw new KuddleSerializationException( - $"Cannot deserialize to collection type '{metadata.Type.Name}'. Use DeserializeMany() instead." - ); - } - - if (metadata.IsNodeDefinition) - { - // Type maps to a single KDL node - if (document.Nodes.Count != 1) - { - throw new KuddleSerializationException( - $"Expected exactly 1 root node for type '{typeof(T).Name}', found {document.Nodes.Count}." - ); - } - - var rootNode = document.Nodes[0]; - if (!rootNode.Name.Value.Equals(metadata.NodeName, NodeNameComparison)) - { - throw new KuddleSerializationException( - $"Expected node '{metadata.NodeName}', found '{rootNode.Name.Value}'." - ); - } - - var instance = new T(); - MapNodeToObject(rootNode, instance, metadata); - return instance; - } - else - { - // Type maps to a document with child nodes as properties - var instance = new T(); - MapChildNodes(document.Nodes, instance, metadata); - return instance; - } - } - - /// - /// Maps a KDL node's entries and children to an object instance. - /// - private static void MapNodeToObject(KdlNode node, object instance, TypeMetadata metadata) - { - // Map arguments - foreach (var mapping in metadata.ArgumentAttributes) - { - var argValue = node.Arg(mapping.ArgumentIndex); - if (argValue is null) - { - throw new KuddleSerializationException( - $"Missing required argument at index {mapping.ArgumentIndex}'." - ); - } - - SetPropertyValue(mapping.Property, instance, argValue, mapping.TypeAnnotation); - } - - // Map properties - foreach (var mapping in metadata.Properties) - { - var propKey = mapping.GetPropertyKey(); - var kdlValue = node.Prop(propKey); - - if (kdlValue is null) - { - continue; // Optional property, use default - } - - SetPropertyValue(mapping.Property, instance, kdlValue, mapping.TypeAnnotation); - } - - // Map child nodes - MapChildNodes(node.Children?.Nodes, instance, metadata); - } - - /// - /// Maps child KDL nodes to properties marked with [KdlNode]. - /// - private static void MapChildNodes( - IReadOnlyList? nodes, - object instance, - TypeMetadata metadata - ) - { - if (nodes is null || nodes.Count == 0) - { - return; - } - - foreach (var mapping in metadata.Children) - { - var nodeName = mapping.GetChildNodeName(); - var matchingNodes = GetMatchingNodes(nodes, nodeName); - - if (matchingNodes.Count == 0) - { - continue; - } - - var propType = mapping.Property.PropertyType; - var propMeta = TypeMetadata.For(propType); - - if (propMeta.IsIEnumerable) - { - SetCollectionProperty(mapping.Property, instance, matchingNodes); - } - else if (propMeta.IsComplexType) - { - if (matchingNodes.Count > 1) - { - throw new KuddleSerializationException( - $"Expected single node '{nodeName}' for property '{mapping.Property.Name}', found {matchingNodes.Count}." - ); - } - - SetComplexProperty(mapping.Property, instance, matchingNodes[0]); - } - else - { - // Scalar property - extract from first argument - if (matchingNodes.Count > 1) - { - throw new KuddleSerializationException( - $"Expected single node '{nodeName}' for scalar property '{mapping.Property.Name}', found {matchingNodes.Count}." - ); - } - - var argValue = matchingNodes[0].Arg(0); - if (argValue is not null) - { - SetPropertyValue(mapping.Property, instance, argValue, mapping.TypeAnnotation); - } - } - } - } - - private static List GetMatchingNodes(IReadOnlyList nodes, string nodeName) - { - var result = new List(); - foreach (var node in nodes) - { - if (node.Name.Value.Equals(nodeName, NodeNameComparison)) - { - result.Add(node); - } - } - return result; - } - - private static void SetPropertyValue( - PropertyInfo property, - object instance, - KdlValue kdlValue, - string? expectedTypeAnnotation = null - ) - { - var result = KdlValueConverter.FromKdlOrThrow( - kdlValue, - property.PropertyType, - $"Property: {property.DeclaringType?.Name}.{property.Name}", - expectedTypeAnnotation - ); - - property.SetValue(instance, result); - } - - private static void SetComplexProperty(PropertyInfo property, object instance, KdlNode node) - { - var childInstance = - Activator.CreateInstance(property.PropertyType) - ?? throw new KuddleSerializationException( - $"Failed to create instance of '{property.PropertyType.Name}' for property '{property.Name}'." - ); - - var childMetadata = TypeMetadata.For(property.PropertyType); - MapNodeToObject(node, childInstance, childMetadata); - - property.SetValue(instance, childInstance); - } - - private static void SetCollectionProperty( - PropertyInfo property, - object instance, - List nodes - ) - { - var elementType = GetCollectionElementType(property.PropertyType); - var metadata = TypeMetadata.For(property.PropertyType); - - if (!metadata.IsComplexType) - { - throw new KuddleSerializationException( - $"Collection element type '{elementType.Name}' must be a complex type for property '{property.Name}'." - ); - } - - var listType = typeof(List<>).MakeGenericType(elementType); - var list = (IList)Activator.CreateInstance(listType)!; - - var elementMetadata = TypeMetadata.For(elementType); - - foreach (var node in nodes) - { - var element = - Activator.CreateInstance(elementType) - ?? throw new KuddleSerializationException( - $"Failed to create instance of '{elementType.Name}'." - ); - - MapNodeToObject(node, element, elementMetadata); - list.Add(element); - } + var doc = KdlReader.Read(text); - var finalValue = ConvertToTargetCollectionType(property.PropertyType, elementType, list); - property.SetValue(instance, finalValue); + return ObjectDeserializer.DeserializeDocument(doc, options); } #endregion @@ -292,36 +57,7 @@ List nodes /// public static string Serialize(T instance, KdlSerializerOptions? options = null) { - ArgumentNullException.ThrowIfNull(instance); - - var type = typeof(T); - var metadata = TypeMetadata.For(); - - if (!metadata.IsComplexType) - { - throw new KuddleSerializationException( - $"Cannot serialize primitive type '{type.Name}'. Only complex types are supported." - ); - } - - var doc = new KdlDocument(); - - if (metadata.IsDictionary) - { - throw new NotSupportedException("Dictionary serialization is not yet supported."); - } - - if (metadata.IsIEnumerable) - { - var nodes = SerializeCollection((IEnumerable)instance); - doc.Nodes.AddRange(nodes); - } - else - { - var node = SerializeToNode(instance); - doc.Nodes.Add(node); - } - + var doc = ObjectSerializer.SerializeDocument(instance, options); return KdlWriter.Write(doc); } @@ -336,198 +72,25 @@ public static string SerializeMany( { ArgumentNullException.ThrowIfNull(items); - var metadata = TypeMetadata.For(); var doc = new KdlDocument(); foreach (var item in items) { cancellationToken.ThrowIfCancellationRequested(); - if (item is null) - { continue; - } - var node = SerializeToNode(item); + var node = ObjectSerializer.SerializeNode(item, options); doc.Nodes.Add(node); } return KdlWriter.Write(doc); } - private static KdlNode SerializeToNode(object instance, string? overrideNodeName = null) - { - var type = instance.GetType(); - var metadata = TypeMetadata.For(type); - var nodeName = overrideNodeName ?? metadata.NodeName; - - var entries = new List(); - - // Serialize arguments (in order) - foreach (var mapping in metadata.ArgumentAttributes) - { - var value = mapping.Property.GetValue(instance); - var typeAnnotation = mapping.TypeAnnotation; - var kdlValue = KdlValueConverter.ToKdlOrThrow( - value, - $"Argument property: {mapping.Property.Name}", - typeAnnotation - ); - - entries.Add(new KdlArgument(kdlValue)); - } - - // Serialize properties - foreach (var mapping in metadata.Properties) - { - var value = mapping.Property.GetValue(instance); - var typeAnnotation = mapping.TypeAnnotation; - - if (!KdlValueConverter.TryToKdl(value, out var kdlValue, typeAnnotation)) - { - continue; // Skip properties that can't be converted - } - - var key = mapping.GetPropertyKey(); - entries.Add(new KdlProperty(KdlValue.From(key), kdlValue)); - } - - // Serialize children - KdlBlock? childBlock = null; - - foreach (var mapping in metadata.Children) - { - var propValue = mapping.Property.GetValue(instance); - - if (propValue is null) - { - continue; - } - - childBlock ??= new KdlBlock(); - var childNodeName = mapping.GetChildNodeName(); - - var propType = mapping.Property.PropertyType; - var childMeta = TypeMetadata.For(propType); - - if (childMeta.IsIEnumerable) - { - var childNodes = SerializeCollection((IEnumerable)propValue, childNodeName); - childBlock.Nodes.AddRange(childNodes); - } - else if (childMeta.IsComplexType) - { - var childNode = SerializeToNode(propValue, childNodeName); - childBlock.Nodes.Add(childNode); - } - else - { - // Scalar value as a child node with single argument - var typeAnnotation = mapping.TypeAnnotation; - var kdlValue = KdlValueConverter.ToKdlOrThrow( - propValue, - $"Child scalar property: {mapping.Property.Name}", - typeAnnotation - ); - - var scalarNode = new KdlNode(KdlValue.From(childNodeName)) - { - Entries = [new KdlArgument(kdlValue)], - }; - childBlock.Nodes.Add(scalarNode); - } - } - - return new KdlNode(KdlValue.From(nodeName)) - { - Entries = entries, - Children = childBlock?.Nodes.Count > 0 ? childBlock : null, - }; - } - - private static IEnumerable SerializeCollection( - IEnumerable collection, - string? overrideNodeName = null - ) - { - foreach (var item in collection) - { - if (item is null) - { - continue; - } - - yield return SerializeToNode(item, overrideNodeName); - } - } - #endregion #region Helpers - private static Type GetCollectionElementType(Type collectionType) - { - if (collectionType.IsArray) - { - return collectionType.GetElementType()!; - } - - if (collectionType.IsGenericType) - { - return collectionType.GetGenericArguments()[0]; - } - - throw new KuddleSerializationException( - $"Unsupported collection type '{collectionType.FullName}'." - ); - } - - private static object ConvertToTargetCollectionType( - Type targetType, - Type elementType, - IList list - ) - { - if (targetType.IsArray) - { - var array = Array.CreateInstance(elementType, list.Count); - list.CopyTo(array, 0); - return array; - } - - if (targetType.IsAssignableFrom(list.GetType())) - { - return list; - } - - return list; - } #endregion } - -/// -/// Options for KDL serialization and deserialization. -/// -public record KdlSerializerOptions -{ - /// - /// Whether to ignore null values when serializing. Default is true. - /// - public bool IgnoreNullValues { get; init; } = true; - - /// - /// Whether property/node name comparison is case-insensitive. Default is true. - /// - public bool CaseInsensitiveNames { get; init; } = true; - - /// - /// Whether to include type annotations in output. Default is true. - /// - public bool WriteTypeAnnotations { get; init; } = true; - - /// - /// Default options instance. - /// - public static KdlSerializerOptions Default { get; } = new(); -} diff --git a/src/Kuddle.Net/Serialization/KdlSerializerOptions.cs b/src/Kuddle.Net/Serialization/KdlSerializerOptions.cs new file mode 100644 index 0000000..c7d8466 --- /dev/null +++ b/src/Kuddle.Net/Serialization/KdlSerializerOptions.cs @@ -0,0 +1,27 @@ +namespace Kuddle.Serialization; + +/// +/// Options for KDL serialization and deserialization. +/// +public record KdlSerializerOptions +{ + /// + /// Whether to ignore null values when serializing. Default is true. + /// + public bool IgnoreNullValues { get; init; } = true; + + /// + /// Whether property/node name comparison is case-insensitive. Default is true. + /// + public bool CaseInsensitiveNames { get; init; } = true; + + /// + /// Whether to include type annotations in output. Default is true. + /// + public bool WriteTypeAnnotations { get; init; } = true; + + /// + /// Default options instance. + /// + public static KdlSerializerOptions Default { get; } = new(); +} diff --git a/src/Kuddle.Net/Serialization/KdlTypeInfo.cs b/src/Kuddle.Net/Serialization/KdlTypeInfo.cs new file mode 100644 index 0000000..eff2181 --- /dev/null +++ b/src/Kuddle.Net/Serialization/KdlTypeInfo.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; + +namespace Kuddle.Serialization; + +/// +/// Cached metadata about how a CLR type maps to/from KDL nodes. +/// +/// +[DebuggerDisplay("Type = {Type.Name,nq}")] +internal sealed class KdlTypeInfo +{ + private static readonly ConcurrentDictionary s_cache = new(); + + public Type Type { get; } + public string NodeName { get; } + + public DictionaryInfo? DictionaryDef { get; } + public Type? CollectionElementType { get; } + + /// + /// A strict node cannot be a document node. + /// Document nodes cannot have args or props + /// + public bool IsStrictNode => + ArgumentAttributes.Count > 0 + || Properties.Count > 0 + || Type.GetCustomAttribute() != null; + + /// Properties mapped to KDL arguments, sorted by index. + public IReadOnlyList ArgumentAttributes { get; } + + /// Properties mapped to KDL properties. + public IReadOnlyList Properties { get; } + + /// Properties mapped to child nodes. + public IReadOnlyList Children { get; } + public IReadOnlyList Dictionaries { get; } + + private KdlTypeInfo(Type type) + { + Type = type; + + var kdlTypeAttr = type.GetCustomAttribute(); + NodeName = kdlTypeAttr?.Name ?? type.Name.ToLowerInvariant(); + + DictionaryDef = type.GetDictionaryInfo(); + CollectionElementType = type.GetCollectionInfo(); + var allMappings = new List(); + + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!prop.CanWrite || prop.GetCustomAttribute() != null) + continue; + + var attrs = prop.GetCustomAttributes() + .Where(a => a is KdlEntryAttribute || a is KdlNodeDictionaryAttribute) + .ToList(); + + if (attrs.Count > 1) + throw new KdlConfigurationException( + $"Property '{type.Name}.{prop.Name}' has multiple KDL attributes. Only one mapping is allowed per property." + ); + + if (attrs.Count == 1) + { + allMappings.Add(new KdlMemberInfo(prop, attrs[0])); + } + } + + var args = allMappings.Where(m => m.IsArgument).OrderBy(m => m.ArgumentIndex).ToList(); + for (int i = 0; i < args.Count; i++) + { + if (args[i].ArgumentIndex != i) + throw new KdlConfigurationException( + $"Property '{type.Name}.{args[i].Property.Name}' declares index {args[i].ArgumentIndex}, but index {i} is missing. Arguments must be contiguous starting at 0." + ); + } + + ArgumentAttributes = args; + Properties = allMappings.Where(m => m.IsProperty).ToList(); + Children = allMappings.Where(m => m.IsNode).ToList(); + + Dictionaries = allMappings.Where(m => m.IsNodeDictionary).ToList(); + } + + /// + /// Gets or creates cached metadata for a type. + /// + public static KdlTypeInfo For(Type type) => s_cache.GetOrAdd(type, t => new KdlTypeInfo(t)); + + /// + /// Gets or creates cached metadata for a type. + /// + public static KdlTypeInfo For() => For(typeof(T)); + + /// + /// Checks if a type is a complex type (not primitive, not string, not interface/abstract). + /// + public bool IsComplexType => + !Type.IsValueType + && !Type.IsPrimitive + && Type != typeof(string) + && Type != typeof(object) + && !Type.IsInterface + && !Type.IsAbstract; + + /// + /// Checks if a type is enumerable (but not string or dictionary). + /// + public bool IsIEnumerable => + Type != typeof(string) && !IsDictionary && typeof(IEnumerable).IsAssignableFrom(Type); + + /// + /// Checks if a type is a dictionary. + /// + public bool IsDictionary => + Type.GetInterfaces() + .Any(i => + i.IsGenericType + && ( + i.GetGenericTypeDefinition() == typeof(IDictionary<,>) + || i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) + ) + ); +} + +[Serializable] +internal class KdlConfigurationException : Exception +{ + public KdlConfigurationException() { } + + public KdlConfigurationException(string? message) + : base(message) { } + + public KdlConfigurationException(string? message, Exception? innerException) + : base(message, innerException) { } +} + +internal sealed record DictionaryInfo(Type KeyType, Type ValueType); diff --git a/src/Kuddle.Net/Serialization/KdlValueConverter.cs b/src/Kuddle.Net/Serialization/KdlValueConverter.cs index 389bbab..66753b5 100644 --- a/src/Kuddle.Net/Serialization/KdlValueConverter.cs +++ b/src/Kuddle.Net/Serialization/KdlValueConverter.cs @@ -29,6 +29,13 @@ public static bool TryFromKdl(KdlValue kdlValue, Type targetType, out object? va var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (underlying.IsEnum) + { + kdlValue.TryGetString(out var enumString); + bool success = Enum.TryParse(underlying, enumString, true, out var result); + value = result; + return success; + } // String if (underlying == typeof(string) && kdlValue.TryGetString(out var stringVal)) { @@ -177,7 +184,7 @@ public static bool TryToKdl(object? input, out KdlValue kdlValue, string? typeAn /// /// Converts a KDL value to a CLR type, throwing on failure. /// - public static object? FromKdlOrThrow( + public static object FromKdlOrThrow( KdlValue kdlValue, Type targetType, string context, @@ -194,7 +201,7 @@ public static bool TryToKdl(object? input, out KdlValue kdlValue, string? typeAn $"Cannot convert KDL value '{kdlValue}' to {targetType.Name}. {context}" ); } - return result; + return result ?? throw new Exception(); } /// diff --git a/src/Kuddle.Net/Serialization/KdlWriter.cs b/src/Kuddle.Net/Serialization/KdlWriter.cs index b757e52..20adb85 100644 --- a/src/Kuddle.Net/Serialization/KdlWriter.cs +++ b/src/Kuddle.Net/Serialization/KdlWriter.cs @@ -230,7 +230,6 @@ private static string EscapeString(string input) sb.Append("\\f"); break; default: - // KDL allows most unicode, but you might want to escape control codes if (char.IsControl(c)) { sb.Append($"\\u{(int)c:X4}"); diff --git a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs new file mode 100644 index 0000000..63d5ed5 --- /dev/null +++ b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Kuddle.AST; +using Kuddle.Extensions; + +namespace Kuddle.Serialization; + +internal class ObjectDeserializer +{ + private const StringComparison NodeNameComparison = StringComparison.OrdinalIgnoreCase; + + private readonly KdlSerializerOptions _options; + + public ObjectDeserializer(KdlSerializerOptions? options = null) + { + _options = options ?? KdlSerializerOptions.Default; + } + + internal static T DeserializeDocument(KdlDocument doc, KdlSerializerOptions? options) + where T : new() + { + var worker = new ObjectDeserializer(options); + return worker.DeserializeRoot(doc); + } + + internal T DeserializeRoot(KdlDocument doc) + where T : new() + { + var metadata = KdlTypeInfo.For(); + + if (metadata.IsIEnumerable) + { + throw new KuddleSerializationException( + $"Cannot deserialize to collection type '{metadata.Type.Name}'. Use DeserializeMany() instead." + ); + } + + if (metadata.IsStrictNode) + { + if (doc.Nodes.Count > 1) + throw new KuddleSerializationException( + $"Expected exactly 1 root node, found {doc.Nodes.Count}." + ); + + var rootNode = doc.Nodes[0]; + ValidateNodeName(rootNode, metadata.NodeName); + + var instance = new T(); + MapNodeToObject(rootNode, instance, metadata); + return instance; + } + else + { + // Document Mode: Root properties and children + var instance = new T(); + MapChildNodes(doc.Nodes, instance, metadata); + MapDictionaries(doc.Nodes, null, instance, metadata); + return instance; + } + } + + internal static T DeserializeNode(KdlNode node, KdlSerializerOptions? options) + where T : new() + { + var worker = new ObjectDeserializer(options); + var metadata = KdlTypeInfo.For(); + ValidateNodeName(node, metadata.NodeName); + + var instance = new T(); + worker.MapNodeToObject(node, instance, metadata); + return instance; + } + + private object DeserializeObject(KdlNode node, Type type) + { + var instance = + Activator.CreateInstance(type) + ?? throw new KuddleSerializationException( + $"Failed to create instance of '{type.Name}'." + ); + + var metadata = KdlTypeInfo.For(type); + MapNodeToObject(node, instance, metadata); + return instance; + } + + /// + /// Maps a KDL node's entries and children to an object instance. + /// + private void MapNodeToObject(KdlNode node, object instance, KdlTypeInfo metadata) + { + foreach (var mapping in metadata.ArgumentAttributes) + { + var argValue = + node.Arg(mapping.ArgumentIndex) + ?? throw new KuddleSerializationException( + $"Missing required argument at index {mapping.ArgumentIndex}." + ); + SetPropertyValue(mapping.Property, instance, argValue, mapping.TypeAnnotation); + } + + foreach (var mapping in metadata.Properties) + { + var kdlValue = node.Prop(mapping.Name); + if (kdlValue != null) + { + SetPropertyValue(mapping.Property, instance, kdlValue, mapping.TypeAnnotation); + } + } + + MapChildNodes(node.Children?.Nodes, instance, metadata); + + MapDictionaries(node.Children?.Nodes, node, instance, metadata); + + if (metadata.IsDictionary && metadata.DictionaryDef != null) + { + var (keyType, valueType) = metadata.DictionaryDef; + PopulateNodeDictionary(node.Children?.Nodes, (IDictionary)instance, keyType, valueType); + } + } + + /// + /// Maps child KDL nodes to properties marked with [KdlNode]. + /// + private void MapChildNodes(IReadOnlyList? nodes, object instance, KdlTypeInfo metadata) + { + if (nodes is null || nodes.Count == 0) + return; + + foreach (var mapping in metadata.Children) + { + var matches = nodes + .Where(n => n.Name.Value.Equals(mapping.Name, NodeNameComparison)) + .ToList(); + + if (matches.Count == 0) + continue; + + var propType = mapping.Property.PropertyType; + var propMeta = KdlTypeInfo.For(propType); + + if (propMeta.IsIEnumerable) + { + SetCollectionProperty(mapping.Property, instance, matches); + } + else + { + if (matches.Count > 1) + throw new KuddleSerializationException( + $"Expected single node '{mapping.Name}', found {matches.Count}." + ); + + var node = matches[0]; + object value; + + if (propMeta.IsComplexType) + { + value = DeserializeObject(node, propType); + } + else + { + var arg = node.Arg(0); + if (arg is null) + continue; + value = KdlValueConverter.FromKdlOrThrow( + arg, + propType, + mapping.Name, + mapping.TypeAnnotation + ); + } + + mapping.Property.SetValue(instance, value); + } + } + } + + private void MapDictionaries( + List? nodes, + KdlNode? parentNode, + object instance, + KdlTypeInfo metadata + ) + { + if (metadata.Dictionaries.Count == 0) + return; + + foreach (var member in metadata.Dictionaries) + { + var propMeta = KdlTypeInfo.For(member.Property.PropertyType); + if (!propMeta.IsDictionary || propMeta.DictionaryDef is null) + continue; + + var (keyType, valueType) = propMeta.DictionaryDef; + var dict = GetOrCreateDictionary(instance, member); + if (dict is null) + continue; + + if (member.IsNodeDictionary && nodes != null) + { + var container = nodes.LastOrDefault(n => + n.Name.Value.Equals(member.Name, NodeNameComparison) + ); + if (container?.Children?.Nodes != null) + { + PopulateNodeDictionary(container.Children.Nodes, dict, keyType, valueType); + } + } + } + static IDictionary? GetOrCreateDictionary(object instance, KdlMemberInfo member) + { + var dict = (IDictionary?)member.Property.GetValue(instance); + if (dict is null) + { + dict = (IDictionary?)Activator.CreateInstance(member.Property.PropertyType); + member.Property.SetValue(instance, dict); + } + + return dict; + } + } + + private void PopulateNodeDictionary( + IEnumerable? children, + IDictionary dict, + Type keyType, + Type valueType + ) + { + if (children is null) + return; + + foreach (var child in children) + { + object key = Convert.ChangeType(child.Name.Value, keyType); + object value; + + if (!valueType.IsComplexType) + { + // Scalar: Arg 0 + var arg = child.Arg(0); + if (arg is null) + continue; + value = KdlValueConverter.FromKdlOrThrow(arg, valueType, $"Dictionary Value {key}"); + } + else + { + value = DeserializeObject(child, valueType); + } + + if (dict.Contains(key)) + throw new KuddleSerializationException( + $"Duplicate dictionary key detected: '{key}'" + ); + + dict.Add(key, value); + } + } + + private void SetPropertyValue( + PropertyInfo property, + object instance, + KdlValue kdlValue, + string? expectedTypeAnnotation = null + ) + { + var result = KdlValueConverter.FromKdlOrThrow( + kdlValue, + property.PropertyType, + $"Property: {property.DeclaringType?.Name}.{property.Name}", + expectedTypeAnnotation + ); + + property.SetValue(instance, result); + } + + private void SetCollectionProperty(PropertyInfo property, object instance, List nodes) + { + var elementType = property.PropertyType.GetCollectionElementType(); + + var listType = typeof(List<>).MakeGenericType(elementType); + var list = (IList)Activator.CreateInstance(listType)!; + + var elementMetadata = KdlTypeInfo.For(elementType); + + foreach (var node in nodes) + { + object element; + if (elementMetadata.IsComplexType) + { + element = DeserializeObject(node, elementType); + } + else + { + var arg = node.Arg(0); + if (arg is null) + continue; + element = KdlValueConverter.FromKdlOrThrow(arg, elementType, "List Element"); + } + list.Add(element); + } + + var finalValue = ConvertToTargetCollectionType(property.PropertyType, elementType, list); + property.SetValue(instance, finalValue); + } + + private static void ValidateNodeName(KdlNode node, string nodeName) + { + if (!node.Name.Value.Equals(nodeName, NodeNameComparison)) + { + throw new KuddleSerializationException( + $"Expected node '{nodeName}', found '{node.Name.Value}'." + ); + } + } + + private static object ConvertToTargetCollectionType( + Type targetType, + Type elementType, + IList list + ) + { + if (targetType.IsArray) + { + var array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + return array; + } + + if (targetType.IsAssignableFrom(list.GetType())) + { + return list; + } + + return list; + } +} diff --git a/src/Kuddle.Net/Serialization/ObjectSerializer.cs b/src/Kuddle.Net/Serialization/ObjectSerializer.cs new file mode 100644 index 0000000..8a258e6 --- /dev/null +++ b/src/Kuddle.Net/Serialization/ObjectSerializer.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Kuddle.AST; + +namespace Kuddle.Serialization; + +internal class ObjectSerializer +{ + private readonly KdlSerializerOptions _options; + + public ObjectSerializer(KdlSerializerOptions? options = null) + { + _options = options ?? KdlSerializerOptions.Default; + } + + internal static KdlDocument SerializeDocument(T? instance, KdlSerializerOptions? options) + { + ArgumentNullException.ThrowIfNull(instance); + + var type = typeof(T); + var worker = new ObjectSerializer(options); + var metadata = KdlTypeInfo.For(); + + if (!metadata.IsComplexType && !metadata.IsDictionary) + { + throw new KuddleSerializationException( + $"Cannot serialize primitive type '{type.Name}'. Only complex types are supported." + ); + } + var doc = new KdlDocument(); + + if (metadata.IsIEnumerable) + { + var nodes = worker.SerializeCollection((IEnumerable)instance); + doc.Nodes.AddRange(nodes); + } + else + { + var node = worker.SerializeToNode(instance); + doc.Nodes.Add(node); + } + + return doc; + } + + public static KdlNode SerializeNode(object instance, KdlSerializerOptions? options) + { + var worker = new ObjectSerializer(options); + return worker.SerializeToNode(instance); + } + + private IEnumerable SerializeCollection( + IEnumerable collection, + string? overrideNodeName = null + ) + { + foreach (var item in collection) + { + if (item is null) + continue; + + yield return SerializeToNode(item, overrideNodeName); + } + } + + private KdlNode SerializeToNode(object instance, string? overrideNodeName = null) + { + var type = instance.GetType(); + var metadata = KdlTypeInfo.For(type); + var nodeName = overrideNodeName ?? metadata.NodeName; + + var entries = new List(); + + // Serialize arguments (in order) + foreach (var mapping in metadata.ArgumentAttributes) + { + var value = mapping.Property.GetValue(instance); + var kdlValue = KdlValueConverter.ToKdlOrThrow( + value, + $"Argument property: {mapping.Property.Name}", + mapping.TypeAnnotation + ); + + entries.Add(new KdlArgument(kdlValue)); + } + + // Serialize properties + foreach (var mapping in metadata.Properties) + { + var value = mapping.Property.GetValue(instance); + var typeAnnotation = mapping.TypeAnnotation; + + if (!KdlValueConverter.TryToKdl(value, out var kdlValue, typeAnnotation)) + { + continue; // Skip properties that can't be converted + } + + entries.Add(new KdlProperty(KdlValue.From(mapping.Name), kdlValue)); + } + + foreach (var dictMap in metadata.Dictionaries.Where(m => m.IsPropertyDictionary)) + { + var dict = (IDictionary?)dictMap.Property.GetValue(instance); + if (dict is null) + continue; + + foreach (DictionaryEntry entry in dict) + { + var keyStr = entry.Key.ToString()!; + if (entry.Value != null && KdlValueConverter.TryToKdl(entry.Value, out var val)) + { + entries.Add(new KdlProperty(KdlValue.From(keyStr), val)); + } + } + } + // Serialize children + KdlBlock? childBlock = null; + + void AddChild(KdlNode child) + { + childBlock ??= new KdlBlock(); + childBlock.Nodes.Add(child); + } + + foreach (var mapping in metadata.Children) + { + var propValue = mapping.Property.GetValue(instance); + + if (propValue is null) + continue; + + var propType = mapping.Property.PropertyType; + var childMeta = KdlTypeInfo.For(propType); + + if (childMeta.IsIEnumerable) + { + var childNodes = SerializeCollection((IEnumerable)propValue, mapping.Name); + foreach (var child in childNodes) + { + AddChild(child); + } + } + else if (childMeta.IsComplexType) + { + AddChild(SerializeToNode(propValue, mapping.Name)); + } + else + { + var kdlValue = KdlValueConverter.ToKdlOrThrow( + propValue, + $"Child scalar property: {mapping.Property.Name}", + mapping.TypeAnnotation + ); + + var scalarNode = new KdlNode(KdlValue.From(mapping.Name)) + { + Entries = [new KdlArgument(kdlValue)], + }; + AddChild(scalarNode); + } + } + foreach (var dictMap in metadata.Dictionaries) + { + if (dictMap.IsPropertyDictionary) + continue; // Handled above + + var dict = (IDictionary?)dictMap.Property.GetValue(instance); + if (dict is null || dict.Count == 0) + continue; + + var (_, valueType) = KdlTypeInfo.For(dictMap.Property.PropertyType).DictionaryDef!; + + if (dictMap.IsNodeDictionary) + { + // Strategy: Create a container node, put entries inside + // themes { dark { ... } } + var container = new KdlNode(KdlValue.From(dictMap.Name)); + var nodes = new List(); + SerializeDictionaryEntriesToBlock(nodes, dict, valueType); + + if (nodes.Count > 0) + { + container = container with { Children = new KdlBlock() { Nodes = nodes } }; + AddChild(container); + } + } + else if (dictMap.IsKeyedNodeCollection) + { + // Strategy: Flatten entries as children + // db "A" { ... } + // db "B" { ... } + foreach (var value in dict.Values) + { + if (value is null) + continue; + // For KeyedNodes, the Object contains the Key, so we just serialize the Object + // with the forced Node Name from the attribute. + AddChild(SerializeToNode(value, dictMap.Name)); + } + } + } + return new KdlNode(KdlValue.From(nodeName)) + { + Entries = entries, + Children = childBlock?.Nodes.Count > 0 ? childBlock : null, + }; + } + + /// + /// Helper to convert Dictionary entries into KDL Nodes. + /// Used by [KdlNodeDictionary] and Implicit Dictionary logic. + /// + private void SerializeDictionaryEntriesToBlock( + List targetList, + IDictionary dict, + Type valueType + ) + { + var isScalar = valueType.IsKdlScalar; + + foreach (DictionaryEntry entry in dict) + { + var keyStr = entry.Key.ToString(); + if (string.IsNullOrEmpty(keyStr) || entry.Value is null) + continue; + + if (isScalar) + { + // Scalar: NodeName=Key, Arg0=Value + // e.g. timeout 5000 + if (KdlValueConverter.TryToKdl(entry.Value, out var kdlVal, null)) + { + targetList.Add( + new KdlNode(KdlValue.From(keyStr)) { Entries = [new KdlArgument(kdlVal)] } + ); + } + } + else + { + // Complex: NodeName=Key, Body=Object + // e.g. dark-mode { ... } + // We recurse SerializeToNode, but we override the name to be the Key + targetList.Add(SerializeToNode(entry.Value, overrideNodeName: keyStr)); + } + } + } +} diff --git a/src/Kuddle.Net/Serialization/TypeMetadata.cs b/src/Kuddle.Net/Serialization/TypeMetadata.cs deleted file mode 100644 index 7f2548a..0000000 --- a/src/Kuddle.Net/Serialization/TypeMetadata.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; - -namespace Kuddle.Serialization; - -/// -/// Represents a mapping from a C# property to a KDL entry (argument, property, or child node). -/// -internal sealed record KdlEntryMapping(PropertyInfo Property, KdlEntryAttribute? Entry) -{ - public bool IsArgument => Entry is KdlArgumentAttribute; - public bool IsProperty => Entry is KdlPropertyAttribute; - public bool IsChildNode => Entry is KdlNodeAttribute; - - public int ArgumentIndex => Entry is KdlArgumentAttribute arg ? arg.Index : -1; - - public string GetPropertyKey() => - Entry is KdlPropertyAttribute prop - ? prop.Key ?? Property.Name.ToLowerInvariant() - : Property.Name.ToLowerInvariant(); - - public string GetChildNodeName() => - Entry is KdlNodeAttribute node - ? node.Name ?? Property.Name.ToLowerInvariant() - : Property.Name.ToLowerInvariant(); - - public string? TypeAnnotation => Entry?.TypeAnnotation; -} - -/// -/// Cached metadata about how a CLR type maps to/from KDL nodes. -/// -/// -[DebuggerDisplay("Type = {Type.Name,nq}")] -internal sealed class TypeMetadata -{ - private static readonly ConcurrentDictionary s_cache = new(); - - public Type Type { get; } - public string NodeName { get; } - public bool IsNodeDefinition => ArgumentAttributes.Count > 0 || Properties.Count > 0; - - /// Properties mapped to KDL arguments, sorted by index. - public IReadOnlyList ArgumentAttributes { get; } - - /// Properties mapped to KDL properties. - public IReadOnlyList Properties { get; } - - /// Properties mapped to child nodes. - public IReadOnlyList Children { get; } - - /// All writable, non-ignored properties. - public IReadOnlyList AllMappings { get; } - - private TypeMetadata(Type type) - { - Type = type; - - var kdlTypeAttr = type.GetCustomAttribute(); - NodeName = kdlTypeAttr?.Name ?? type.Name.ToLowerInvariant(); - - var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.CanWrite && p.GetCustomAttribute() == null) - .Select(p => new KdlEntryMapping(p, p.GetCustomAttribute())) - .ToList(); - - AllMappings = props; - - ArgumentAttributes = [.. props.Where(m => m.IsArgument).OrderBy(m => m.ArgumentIndex)]; - Properties = [.. props.Where(m => m.IsProperty)]; - Children = [.. props.Where(m => m.IsChildNode)]; - } - - /// - /// Gets or creates cached metadata for a type. - /// - public static TypeMetadata For(Type type) => s_cache.GetOrAdd(type, t => new TypeMetadata(t)); - - /// - /// Gets or creates cached metadata for a type. - /// - public static TypeMetadata For() => For(typeof(T)); - - /// - /// Checks if a type is a complex type (not primitive, not string, not interface/abstract). - /// - public bool IsComplexType => - !Type.IsValueType - && !Type.IsPrimitive - && Type != typeof(string) - && Type != typeof(object) - && !Type.IsInterface - && !Type.IsAbstract; - - /// - /// Checks if a type is enumerable (but not string or dictionary). - /// - public bool IsIEnumerable => - Type != typeof(string) && !IsDictionary && typeof(IEnumerable).IsAssignableFrom(Type); - - /// - /// Checks if a type is a dictionary. - /// - public bool IsDictionary => - Type.IsGenericType - && Type.GetInterfaces() - .Any(i => - i.IsGenericType - && ( - i.GetGenericTypeDefinition() == typeof(IDictionary<,>) - || i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) - ) - ); -} diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..491a5eb --- /dev/null +++ b/todo.md @@ -0,0 +1,104 @@ +# TODO + +## Phase 1: Foundation & Metadata (The "Brain") + +*Before parsing a single byte, your system must understand the shape of your C# types.* + +* **1.1. Define the Attribute Suite** + * [ ] Create `KdlPropertyDictionaryAttribute`. + * [ ] Create `KdlNodeDictionaryAttribute` (with `string NodeName` property). + * [ ] Create `KdlKeyedNodesAttribute` (with `string NodeName` and `string KeyProperty`). + * [ ] Add `Enforce` bool to existing attributes (for future Strict Mode). + +* **1.2. Upgrade `KdlEntryMapping`** + * [ ] Update the record to detect the 3 new Dictionary attributes. + * [ ] Add logic to resolve the effective `Name` (handling the fallback to property names). + * [ ] Add a field to store `KeyPropertyName` (specifically for `KdlKeyedNodes`). + +* **1.3. Implement Type Inspection Utilities** + * [ ] Implement `TypeHelpers.GetDictionaryInfo(Type t)`: Returns `(bool IsDict, Type KeyType, Type ValueType)`. Must handle `class MyDict : Dictionary`. + * [ ] Implement `TypeHelpers.GetCollectionInfo(Type t)`: Returns `(bool IsCol, Type ElemType)`. Must handle Arrays and Lists. + +* **1.4. Build the `TypeMetadata` Validator** + * [ ] Implement **Attribute Exclusion Check**: Throw if a property has both `[KdlProperty]` and `[KdlNode]`. + * [ ] Implement **Contiguity Check**: Throw if `[KdlArgument]` indices have gaps (e.g., 0, 2). + * [ ] Implement **Bucketing**: Pre-sort mappings into `Arguments`, `Properties`, `Children`, and `Dictionaries` lists so the parser doesn't scan attributes at runtime. + +--- + +## Phase 2: Core Logic Refactoring (The "Muscle") + +*Update the main loop to use your new Metadata instead of raw reflection.* + +* **2.1. Refactor `Deserialize`** + * [ ] Change the entry point to look up `TypeMetadata.For()`. + * [ ] Replace current attribute lookups with loops over the pre-calculated Metadata buckets. + +* **2.2. Implement Strict Argument Mapping** + * [ ] Iterate `meta.ArgumentAttributes`. + * [ ] Map KDL Argument `i` to Property `i`. + * [ ] **Validation**: If KDL has fewer arguments than required (non-nullable) properties, decide if you throw or use default. + +* **2.3. Implement Child Node Mapping (`[KdlNode]`)** + * [ ] **Collection Mode**: If `meta.IsCollection` or property is `List`, find *all* matching children, deserialize, and Add. + * [ ] **Single Object Mode**: If property is a class, find *exactly one* matching child. Throw if multiple exist (ambiguous match). + * [ ] **Scalar Flattening**: If property is `int`/`string`, find child, read `Arg[0]`, assign. + +--- + +## Phase 3: The Dictionary Engine (The "Complex Part") + +*Implement the three strategies for IDictionary.* + +* **3.1. Implement `[KdlPropertyDictionary]`** + * [ ] Iterate over **Properties** of the *current* KDL node. + * [ ] Filter out properties already mapped to explicit C# properties. + * [ ] Cast/Convert remaining values to `TValue` (usually string) and add to the dictionary. + +* **3.2. Implement `[KdlNodeDictionary]`** + * [ ] Iterate over **Child Nodes** of the current KDL node. + * [ ] **Key Extraction**: Use the Child Node's Name. + * [ ] **Value Extraction (Scalar)**: If `TValue` is primitive, read `Arg[0]`. + * [ ] **Value Extraction (Object)**: If `TValue` is complex, recursively call `DeserializeNode`. + +* **3.3. Implement `[KdlKeyedNodes]`** + * [ ] Find all child nodes matching the attribute's `NodeName`. + * [ ] Loop: + 1. Deserialize child node into `TObject`. + 2. Use Reflection to read `KeyProperty` from `TObject`. + 3. Add `(Key, TObject)` to the dictionary. + +--- + +## Phase 4: Collection & Instantiation + +*Handle the "plumbing" of creating objects and lists.* + +* **4.1. Factory Logic** + * [ ] Ensure every target type has a parameterless constructor. + * [ ] For collections: Handle `List`, `T[]` (needs buffering), and `Dictionary`. + * [ ] Handle **Read-Only Properties**: If a collection property is `get` only but not null, `Clear()` it and reuse the instance rather than trying to set it. + +* **4.2. Nullability Safety** + * [ ] Check for `KdlNull` tokens. + * [ ] Throw `KdlInvalidCastException` if trying to assign `#null` to `int` or `bool`. + +--- + +## Phase 5: Verification + +*Prove it works.* + +* **5.1. Test The "Theme/Layout" Scenario** + * [ ] Create the complex nested Dictionary structure from our previous discussion. + * [ ] Verify deeply nested recursion works. +* **5.2. Test Failure Modes** + * [ ] Test "Duplicate Attributes" -> Expect Startup Crash. + * [ ] Test "Missing Argument Index" -> Expect Startup Crash. + * [ ] Test "Duplicate Key in Dictionary" -> Expect Deserialization Crash. + +## What is deferred (Post-v1) + +* Type Annotations logic (`(uuid)`, `(date-time)`). +* Serialization (Writing C# -> KDL). +* Polymorphism (Selecting different derived classes based on KDL annotations). From c84a46027adc2c40c3e88fd55d6030f207257e37 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Sun, 21 Dec 2025 02:14:47 +0000 Subject: [PATCH 03/19] test: move models --- .../Serialization/{ => Models}/AppSettings.cs | 2 +- .../Serialization/Models/TelemetrySnapshot.cs | 161 ++++++++++++++++++ .../Serialization/ObjectMapperTests.cs | 1 + 3 files changed, 163 insertions(+), 1 deletion(-) rename src/Kuddle.Net.Tests/Serialization/{ => Models}/AppSettings.cs (97%) create mode 100644 src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs diff --git a/src/Kuddle.Net.Tests/Serialization/AppSettings.cs b/src/Kuddle.Net.Tests/Serialization/Models/AppSettings.cs similarity index 97% rename from src/Kuddle.Net.Tests/Serialization/AppSettings.cs rename to src/Kuddle.Net.Tests/Serialization/Models/AppSettings.cs index 5f8f97d..3de7d62 100644 --- a/src/Kuddle.Net.Tests/Serialization/AppSettings.cs +++ b/src/Kuddle.Net.Tests/Serialization/Models/AppSettings.cs @@ -1,6 +1,6 @@ using Kuddle.Serialization; -namespace Kuddle.Tests.Serialization; +namespace Kuddle.Tests.Serialization.Models; public class AppSettings { diff --git a/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs new file mode 100644 index 0000000..53d0344 --- /dev/null +++ b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs @@ -0,0 +1,161 @@ +namespace Kuddle.Tests.Serialization.Models; + +public class TelemetrySnapshot +{ + public Guid SnapshotId { get; set; } + public DateTimeOffset CapturedAt { get; set; } + + // Dictionary with complex values + public Dictionary Services { get; set; } = new(); + + // Dictionary of dictionaries + public Dictionary> GlobalTags { get; set; } = new(); + + public EnvironmentInfo Environment { get; set; } = new(); + + public List Events { get; set; } = new(); + + // Arbitrary metadata bucket + public Dictionary Metadata { get; set; } = new(); +} + +public class ServiceInfo +{ + public string Name { get; set; } = string.Empty; + public ServiceStatus Status { get; set; } + + public VersionInfo Version { get; set; } = new(); + + // Dictionary with primitive values + public Dictionary Metrics { get; set; } = new(); + + // Dictionary with list values + public Dictionary> Dependencies { get; set; } = new(); + + public List Endpoints { get; set; } = new(); +} + +public class VersionInfo +{ + public string VersionString { get; set; } = string.Empty; + public int Major { get; set; } + public int Minor { get; set; } + public int Patch { get; set; } + + public DateTime? BuildDate { get; set; } +} + +public class DependencyInfo +{ + public string DependencyName { get; set; } = string.Empty; + public DependencyType Type { get; set; } + + // Nullable to test optional fields + public TimeSpan? Latency { get; set; } + + public Dictionary Properties { get; set; } = new(); +} + +public class EndpointInfo +{ + public string Route { get; set; } = string.Empty; + public HttpMethod Method { get; set; } + + public bool RequiresAuth { get; set; } + + // Dictionary keyed by status code + public Dictionary ResponseProfiles { get; set; } = new(); +} + +public class ResponseProfile +{ + public int StatusCode { get; set; } + public string Description { get; set; } = string.Empty; + + public Dictionary Headers { get; set; } = new(); + + // Nested complex object + public PayloadSchema? Payload { get; set; } +} + +public class PayloadSchema +{ + public string ContentType { get; set; } = string.Empty; + + // Dictionary representing schema-like data + public Dictionary Fields { get; set; } = new(); +} + +public class FieldDefinition +{ + public string Type { get; set; } = string.Empty; + public bool Required { get; set; } + + // Recursive-ish structure + public Dictionary? SubFields { get; set; } +} + +public class EventRecord +{ + public Guid EventId { get; set; } + public DateTimeOffset Timestamp { get; set; } + + public EventSeverity Severity { get; set; } + + public string Message { get; set; } = string.Empty; + + // Heterogeneous dictionary + public Dictionary Context { get; set; } = new(); +} + +public class EnvironmentInfo +{ + public string Name { get; set; } = string.Empty; + public string Region { get; set; } = string.Empty; + + // Dictionary keyed by machine name + public Dictionary Machines { get; set; } = new(); +} + +public class MachineInfo +{ + public string Os { get; set; } = string.Empty; + public int CpuCores { get; set; } + public long MemoryBytes { get; set; } + + public Dictionary Labels { get; set; } = new(); +} + +public enum ServiceStatus +{ + Unknown, + Healthy, + Degraded, + Unavailable, +} + +public enum DependencyType +{ + Database, + HttpService, + MessageQueue, + Cache, +} + +public enum EventSeverity +{ + Trace, + Info, + Warning, + Error, + Critical, +} + +public enum HttpMethod +{ + Get, + Post, + Put, + Delete, + Patch, +} diff --git a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs index dcade7c..dea33db 100644 --- a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs @@ -1,5 +1,6 @@ using Kuddle.AST; using Kuddle.Serialization; +using Kuddle.Tests.Serialization.Models; namespace Kuddle.Tests.Serialization; From 52db75d14e1b1dc9e383181e0e3cd1a6070fc2cf Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Mon, 22 Dec 2025 00:08:33 +0000 Subject: [PATCH 04/19] feat: enable implicit serialization/deserialization when attributes are not used --- .../Serialization/KdlTypeInfoTests.cs | 1 + .../Serialization/Models/TelemetrySnapshot.cs | 23 ++++ .../Serialization/TelemetrySnapshotTests.cs | 108 ++++++++++++++++++ src/Kuddle.Net/AST/KdlValue.cs | 5 + .../Exceptions/KdlConfigurationException.cs | 15 +++ src/Kuddle.Net/Extensions/SpanExtensions.cs | 57 +++++++++ src/Kuddle.Net/Extensions/TypeExtensions.cs | 1 + src/Kuddle.Net/Parser/KdlGrammar.cs | 1 + src/Kuddle.Net/Serialization/KdlTypeInfo.cs | 47 +++++--- .../Serialization/KdlValueConverter.cs | 1 + .../Serialization/ObjectSerializer.cs | 22 +++- 11 files changed, 260 insertions(+), 21 deletions(-) create mode 100644 src/Kuddle.Net.Tests/Serialization/TelemetrySnapshotTests.cs create mode 100644 src/Kuddle.Net/Exceptions/KdlConfigurationException.cs diff --git a/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs b/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs index 6cf17be..aae16e1 100644 --- a/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs @@ -1,3 +1,4 @@ +using Kuddle.Exceptions; using Kuddle.Serialization; namespace Kuddle.Tests.Serialization; diff --git a/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs index 53d0344..d346494 100644 --- a/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs +++ b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs @@ -1,29 +1,41 @@ +using Kuddle.Serialization; + namespace Kuddle.Tests.Serialization.Models; public class TelemetrySnapshot { + // [KdlNode("id")] public Guid SnapshotId { get; set; } public DateTimeOffset CapturedAt { get; set; } // Dictionary with complex values + // [KdlNodeDictionary("services")] public Dictionary Services { get; set; } = new(); // Dictionary of dictionaries + // [KdlNodeDictionary("tags")] public Dictionary> GlobalTags { get; set; } = new(); + // [KdlNode("env")] public EnvironmentInfo Environment { get; set; } = new(); + // [KdlNode("event")] public List Events { get; set; } = new(); // Arbitrary metadata bucket public Dictionary Metadata { get; set; } = new(); } +// [KdlType("info")] public class ServiceInfo { + // [KdlNode] public string Name { get; set; } = string.Empty; + + // [KdlNode] public ServiceStatus Status { get; set; } + // [KdlNode] public VersionInfo Version { get; set; } = new(); // Dictionary with primitive values @@ -97,23 +109,34 @@ public class FieldDefinition public class EventRecord { + // [KdlProperty] public Guid EventId { get; set; } + + // [KdlProperty] public DateTimeOffset Timestamp { get; set; } + // [KdlProperty] public EventSeverity Severity { get; set; } + // [KdlProperty] public string Message { get; set; } = string.Empty; // Heterogeneous dictionary + // [KdlNode] public Dictionary Context { get; set; } = new(); } +[KdlType("env-info")] public class EnvironmentInfo { + // [KdlProperty] public string Name { get; set; } = string.Empty; + + // [KdlProperty] public string Region { get; set; } = string.Empty; // Dictionary keyed by machine name + // [KdlNode] public Dictionary Machines { get; set; } = new(); } diff --git a/src/Kuddle.Net.Tests/Serialization/TelemetrySnapshotTests.cs b/src/Kuddle.Net.Tests/Serialization/TelemetrySnapshotTests.cs new file mode 100644 index 0000000..f5d103a --- /dev/null +++ b/src/Kuddle.Net.Tests/Serialization/TelemetrySnapshotTests.cs @@ -0,0 +1,108 @@ +using System.Diagnostics; +using Kuddle.Serialization; +using Kuddle.Tests.Serialization.Models; + +namespace Kuddle.Tests.Serialization; + +public class TelemetrySnapshotTests +{ + [Test] + public async Task RoundTrip_TelemetrySnapshot_SerializesAndDeserializes() + { + var original = new TelemetrySnapshot + { + SnapshotId = Guid.NewGuid(), + CapturedAt = DateTimeOffset.UtcNow, + Services = + { + ["svc1"] = new ServiceInfo + { + Name = "Inventory", + Status = ServiceStatus.Healthy, + Version = new VersionInfo + { + VersionString = "1.2.3", + Major = 1, + Minor = 2, + Patch = 3, + }, + Metrics = { ["cpu"] = 0.75 }, + Dependencies = + { + ["dep-type"] = new List + { + new() { DependencyName = "db", Type = DependencyType.Database }, + }, + }, + Endpoints = new List + { + new() + { + Route = "/items", + Method = Models.HttpMethod.Get, + RequiresAuth = true, + }, + }, + }, + }, + GlobalTags = + { + ["global"] = new Dictionary + { + ["region"] = "uk-south", + ["timezone"] = "gmt", + }, + }, + Environment = new EnvironmentInfo + { + Name = "prod", + Region = "uk-west", + Machines = + { + ["host1"] = new MachineInfo + { + Os = "linux", + CpuCores = 4, + MemoryBytes = 8L * 1024 * 1024 * 1024, + }, + }, + }, + Events = new List + { + new EventRecord + { + EventId = Guid.NewGuid(), + Timestamp = DateTimeOffset.UtcNow, + Severity = EventSeverity.Info, + Message = "Started", + }, + new EventRecord + { + EventId = Guid.NewGuid(), + Timestamp = DateTimeOffset.UtcNow.AddSeconds(10), + Severity = EventSeverity.Warning, + Message = "Slow start", + }, + }, + }; + + var kdl = KdlSerializer.Serialize(original); + + Debug.WriteLine(kdl); + + var deserialized = KdlSerializer.Deserialize(kdl); + + await Assert.That(deserialized).IsNotNull(); + await Assert.That(deserialized.SnapshotId).IsEqualTo(original.SnapshotId); + await Assert.That(deserialized.CapturedAt).IsEqualTo(original.CapturedAt); + await Assert.That(deserialized.Services).ContainsKey("svc1"); + await Assert.That(deserialized.Services["svc1"].Name).IsEqualTo("Inventory"); + await Assert.That(deserialized.Services["svc1"].Metrics["cpu"]).IsEqualTo(0.75); + await Assert.That(deserialized.GlobalTags["global"]["region"]).IsEqualTo("uk-south"); + await Assert.That(deserialized.GlobalTags["global"]["timezone"]).IsEqualTo("gmt"); + await Assert.That(deserialized.Environment.Name).IsEqualTo("prod"); + await Assert.That(deserialized.Environment.Region).IsEqualTo("uk-west"); + await Assert.That(deserialized.Environment.Machines).ContainsKey("host1"); + await Assert.That(deserialized.Events).Count().IsEqualTo(2); + } +} diff --git a/src/Kuddle.Net/AST/KdlValue.cs b/src/Kuddle.Net/AST/KdlValue.cs index 6f465af..8f4c872 100644 --- a/src/Kuddle.Net/AST/KdlValue.cs +++ b/src/Kuddle.Net/AST/KdlValue.cs @@ -63,4 +63,9 @@ public static KdlNumber From(decimal value) { return new KdlNumber(value.ToString(CultureInfo.InvariantCulture)); } + + internal static KdlString From(Enum e) + { + return new KdlString(e.ToString(), StringKind.Bare); + } } diff --git a/src/Kuddle.Net/Exceptions/KdlConfigurationException.cs b/src/Kuddle.Net/Exceptions/KdlConfigurationException.cs new file mode 100644 index 0000000..18635c0 --- /dev/null +++ b/src/Kuddle.Net/Exceptions/KdlConfigurationException.cs @@ -0,0 +1,15 @@ +using System; + +namespace Kuddle.Exceptions; + +[Serializable] +internal class KdlConfigurationException : Exception +{ + public KdlConfigurationException() { } + + public KdlConfigurationException(string? message) + : base(message) { } + + public KdlConfigurationException(string? message, Exception? innerException) + : base(message, innerException) { } +} diff --git a/src/Kuddle.Net/Extensions/SpanExtensions.cs b/src/Kuddle.Net/Extensions/SpanExtensions.cs index 367fe66..83b4d16 100644 --- a/src/Kuddle.Net/Extensions/SpanExtensions.cs +++ b/src/Kuddle.Net/Extensions/SpanExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Text; namespace Kuddle.Extensions; @@ -31,4 +32,60 @@ public int MaxConsecutive(T target) return max; } } + + extension(string input) + { + public string ToKebabCase() + { + if (string.IsNullOrEmpty(input)) + return input; + + StringBuilder result = new(); + + bool previousCharacterIsSeparator = true; + + for (int i = 0; i < input.Length; i++) + { + char currentChar = input[i]; + + if (char.IsUpper(currentChar) || char.IsDigit(currentChar)) + { + if ( + !previousCharacterIsSeparator + && ( + i > 0 + && ( + char.IsLower(input[i - 1]) + || (i < input.Length - 1 && char.IsLower(input[i + 1])) + ) + ) + ) + { + result.Append('-'); + } + + result.Append(char.ToLowerInvariant(currentChar)); + + previousCharacterIsSeparator = false; + } + else if (char.IsLower(currentChar)) + { + result.Append(currentChar); + + previousCharacterIsSeparator = false; + } + else if (currentChar == ' ' || currentChar == '_' || currentChar == '-') + { + if (!previousCharacterIsSeparator) + { + result.Append('-'); + } + + previousCharacterIsSeparator = true; + } + } + + return result.ToString(); + } + } } diff --git a/src/Kuddle.Net/Extensions/TypeExtensions.cs b/src/Kuddle.Net/Extensions/TypeExtensions.cs index 4c67b79..c14de32 100644 --- a/src/Kuddle.Net/Extensions/TypeExtensions.cs +++ b/src/Kuddle.Net/Extensions/TypeExtensions.cs @@ -123,6 +123,7 @@ internal static class TypeExtensions internal bool IsKdlScalar => type.IsPrimitive + || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) diff --git a/src/Kuddle.Net/Parser/KdlGrammar.cs b/src/Kuddle.Net/Parser/KdlGrammar.cs index cc260f7..4f83137 100644 --- a/src/Kuddle.Net/Parser/KdlGrammar.cs +++ b/src/Kuddle.Net/Parser/KdlGrammar.cs @@ -84,6 +84,7 @@ static KdlGrammar() var openingHashes = Capture(OneOrMany(hash)); + // TODO: Investigate using Runes instead of chars: https://learn.microsoft.com/en-us/dotnet/api/system.text.rune var identifierChar = Literals.Pattern( c => !CharacterSets.IsNewline(c) diff --git a/src/Kuddle.Net/Serialization/KdlTypeInfo.cs b/src/Kuddle.Net/Serialization/KdlTypeInfo.cs index eff2181..4739870 100644 --- a/src/Kuddle.Net/Serialization/KdlTypeInfo.cs +++ b/src/Kuddle.Net/Serialization/KdlTypeInfo.cs @@ -5,6 +5,9 @@ using System.Diagnostics; using System.Linq; using System.Reflection; +using System.Security.Cryptography.X509Certificates; +using Kuddle.Exceptions; +using Kuddle.Extensions; namespace Kuddle.Serialization; @@ -55,7 +58,11 @@ private KdlTypeInfo(Type type) foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { - if (!prop.CanWrite || prop.GetCustomAttribute() != null) + if ( + !prop.CanWrite + || prop.GetCustomAttribute() != null + || prop.GetIndexParameters().Length > 0 // Ignore indexers + ) continue; var attrs = prop.GetCustomAttributes() @@ -67,10 +74,10 @@ private KdlTypeInfo(Type type) $"Property '{type.Name}.{prop.Name}' has multiple KDL attributes. Only one mapping is allowed per property." ); - if (attrs.Count == 1) - { - allMappings.Add(new KdlMemberInfo(prop, attrs[0])); - } + if (attrs.Count == 0 && IsSystemCollectionProperty(prop)) + continue; + Attribute mappingAttr = attrs.Count == 1 ? attrs[0] : InferAttribute(prop); + allMappings.Add(new KdlMemberInfo(prop, mappingAttr)); } var args = allMappings.Where(m => m.IsArgument).OrderBy(m => m.ArgumentIndex).ToList(); @@ -86,9 +93,18 @@ private KdlTypeInfo(Type type) Properties = allMappings.Where(m => m.IsProperty).ToList(); Children = allMappings.Where(m => m.IsNode).ToList(); - Dictionaries = allMappings.Where(m => m.IsNodeDictionary).ToList(); + Dictionaries = allMappings + .Where(m => + m.IsNodeDictionary /* TODO: Add support for IsPropertyDictionary and IsKeyedNodeCollection */ + ) + .ToList(); } + private static bool IsSystemCollectionProperty(PropertyInfo prop) => + prop.DeclaringType != null + && prop.DeclaringType.Namespace != null + && prop.DeclaringType.Namespace.StartsWith("system"); + /// /// Gets or creates cached metadata for a type. /// @@ -128,18 +144,15 @@ private KdlTypeInfo(Type type) || i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) ) ); -} -[Serializable] -internal class KdlConfigurationException : Exception -{ - public KdlConfigurationException() { } - - public KdlConfigurationException(string? message) - : base(message) { } - - public KdlConfigurationException(string? message, Exception? innerException) - : base(message, innerException) { } + private static Attribute InferAttribute(PropertyInfo prop) => + prop.PropertyType switch + { + { IsDictionary: true } => new KdlNodeDictionaryAttribute(prop.Name.ToKebabCase()), + { IsIEnumerable: true } => new KdlNodeAttribute(prop.Name.ToKebabCase()), + { IsKdlScalar: true } => new KdlPropertyAttribute(prop.Name.ToKebabCase()), + _ => new KdlNodeAttribute(prop.Name.ToKebabCase()), + }; } internal sealed record DictionaryInfo(Type KeyType, Type ValueType); diff --git a/src/Kuddle.Net/Serialization/KdlValueConverter.cs b/src/Kuddle.Net/Serialization/KdlValueConverter.cs index 66753b5..e005d64 100644 --- a/src/Kuddle.Net/Serialization/KdlValueConverter.cs +++ b/src/Kuddle.Net/Serialization/KdlValueConverter.cs @@ -170,6 +170,7 @@ public static bool TryToKdl(object? input, out KdlValue kdlValue, string? typeAn Guid uuid => KdlValue.From(uuid), DateTimeOffset dto => KdlValue.From(dto), DateTime dt => KdlValue.From(new DateTimeOffset(dt)), + Enum e => KdlValue.From(e), _ => null!, }; diff --git a/src/Kuddle.Net/Serialization/ObjectSerializer.cs b/src/Kuddle.Net/Serialization/ObjectSerializer.cs index 8a258e6..54ca008 100644 --- a/src/Kuddle.Net/Serialization/ObjectSerializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectSerializer.cs @@ -67,8 +67,7 @@ private IEnumerable SerializeCollection( private KdlNode SerializeToNode(object instance, string? overrideNodeName = null) { - var type = instance.GetType(); - var metadata = KdlTypeInfo.For(type); + var metadata = KdlTypeInfo.For(instance.GetType()); var nodeName = overrideNodeName ?? metadata.NodeName; var entries = new List(); @@ -178,7 +177,7 @@ void AddChild(KdlNode child) // themes { dark { ... } } var container = new KdlNode(KdlValue.From(dictMap.Name)); var nodes = new List(); - SerializeDictionaryEntriesToBlock(nodes, dict, valueType); + SerializeDictionaryEntries(nodes, dict, valueType); if (nodes.Count > 0) { @@ -201,6 +200,21 @@ void AddChild(KdlNode child) } } } + + if ( + metadata.IsDictionary + && metadata.DictionaryDef != null + && instance is IDictionary selfDict + ) + { + childBlock ??= new KdlBlock(); + + SerializeDictionaryEntries( + childBlock.Nodes, + selfDict, + metadata.DictionaryDef.ValueType + ); + } return new KdlNode(KdlValue.From(nodeName)) { Entries = entries, @@ -212,7 +226,7 @@ void AddChild(KdlNode child) /// Helper to convert Dictionary entries into KDL Nodes. /// Used by [KdlNodeDictionary] and Implicit Dictionary logic. /// - private void SerializeDictionaryEntriesToBlock( + private void SerializeDictionaryEntries( List targetList, IDictionary dict, Type valueType From f802d9c0459c638ca952b78d43473bac62869964 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Mon, 22 Dec 2025 00:36:16 +0000 Subject: [PATCH 05/19] feat: KdlNodeCollectionAttribute --- .../Serialization/Models/TelemetrySnapshot.cs | 2 +- .../Attributes/KdlNodeAttribute.cs | 5 --- .../Attributes/KdlNodeCollectionAttribute.cs | 16 +++++++++ .../Attributes/KdlNodeDictionaryAttribute.cs | 9 +++++ .../Attributes/KdlTypeAttribute.cs | 2 +- src/Kuddle.Net/Serialization/KdlMemberInfo.cs | 3 ++ src/Kuddle.Net/Serialization/KdlTypeInfo.cs | 3 +- .../Serialization/ObjectDeserializer.cs | 29 ++++++++++++++++ .../Serialization/ObjectSerializer.cs | 33 +++++++++++++++++++ 9 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 src/Kuddle.Net/Serialization/Attributes/KdlNodeCollectionAttribute.cs create mode 100644 src/Kuddle.Net/Serialization/Attributes/KdlNodeDictionaryAttribute.cs diff --git a/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs index d346494..d5ab871 100644 --- a/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs +++ b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs @@ -19,7 +19,7 @@ public class TelemetrySnapshot // [KdlNode("env")] public EnvironmentInfo Environment { get; set; } = new(); - // [KdlNode("event")] + [KdlNodeCollection("events", "event")] public List Events { get; set; } = new(); // Arbitrary metadata bucket diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs index ea868d7..2efdf15 100644 --- a/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs +++ b/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs @@ -7,8 +7,3 @@ public sealed class KdlNodeAttribute(string? name = null) : KdlEntryAttribute { public string? Name { get; } = name; } - -public class KdlNodeDictionaryAttribute(string? name = null) : KdlEntryAttribute -{ - public string? Name { get; } = name; -} diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlNodeCollectionAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlNodeCollectionAttribute.cs new file mode 100644 index 0000000..0d5ceca --- /dev/null +++ b/src/Kuddle.Net/Serialization/Attributes/KdlNodeCollectionAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace Kuddle.Serialization; + +/// +/// Maps a collection to a child node (container) that holds the items. +/// +/// The name of the wrapper/container node. +/// The node name for items inside the container. If null, uses the type's default. +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public class KdlNodeCollectionAttribute(string nodeName, string? elementName = null) + : KdlEntryAttribute +{ + public string NodeName { get; } = nodeName; + public string? ElementName { get; } = elementName; +} diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlNodeDictionaryAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlNodeDictionaryAttribute.cs new file mode 100644 index 0000000..321f977 --- /dev/null +++ b/src/Kuddle.Net/Serialization/Attributes/KdlNodeDictionaryAttribute.cs @@ -0,0 +1,9 @@ +using System; + +namespace Kuddle.Serialization; + +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public class KdlNodeDictionaryAttribute(string? name = null) : KdlEntryAttribute +{ + public string? Name { get; } = name; +} diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlTypeAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlTypeAttribute.cs index 535030c..83bd225 100644 --- a/src/Kuddle.Net/Serialization/Attributes/KdlTypeAttribute.cs +++ b/src/Kuddle.Net/Serialization/Attributes/KdlTypeAttribute.cs @@ -7,7 +7,7 @@ namespace Kuddle.Serialization; AllowMultiple = false, Inherited = false )] -public sealed class KdlTypeAttribute(string name) : Attribute +public sealed class KdlTypeAttribute(string name) : KdlEntryAttribute { public string Name { get; set; } = name; } diff --git a/src/Kuddle.Net/Serialization/KdlMemberInfo.cs b/src/Kuddle.Net/Serialization/KdlMemberInfo.cs index 2c16fcc..39183e8 100644 --- a/src/Kuddle.Net/Serialization/KdlMemberInfo.cs +++ b/src/Kuddle.Net/Serialization/KdlMemberInfo.cs @@ -11,6 +11,8 @@ internal sealed record KdlMemberInfo(PropertyInfo Property, Attribute? Attribute public bool IsArgument => Attribute is KdlArgumentAttribute; public bool IsProperty => Attribute is KdlPropertyAttribute; public bool IsNode => Attribute is KdlNodeAttribute; + public bool IsWrappedCollection => Attribute is KdlNodeCollectionAttribute; + public string? CollectionElementName => (Attribute as KdlNodeCollectionAttribute)?.ElementName; public bool IsNodeDictionary => Attribute is KdlNodeDictionaryAttribute; //TODO: Add support for property dictionaries @@ -27,6 +29,7 @@ internal sealed record KdlMemberInfo(PropertyInfo Property, Attribute? Attribute KdlPropertyAttribute p => p.Key ?? Property.Name.ToLowerInvariant(), KdlNodeAttribute n => n.Name ?? Property.Name.ToLowerInvariant(), KdlNodeDictionaryAttribute nd => nd.Name ?? Property.Name.ToLowerInvariant(), + KdlNodeCollectionAttribute nc => nc.NodeName ?? Property.Name.ToLowerInvariant(), _ => Property.Name.ToLowerInvariant(), }; diff --git a/src/Kuddle.Net/Serialization/KdlTypeInfo.cs b/src/Kuddle.Net/Serialization/KdlTypeInfo.cs index 4739870..445997b 100644 --- a/src/Kuddle.Net/Serialization/KdlTypeInfo.cs +++ b/src/Kuddle.Net/Serialization/KdlTypeInfo.cs @@ -43,6 +43,7 @@ internal sealed class KdlTypeInfo /// Properties mapped to child nodes. public IReadOnlyList Children { get; } + public IReadOnlyList Collections { get; } public IReadOnlyList Dictionaries { get; } private KdlTypeInfo(Type type) @@ -92,7 +93,7 @@ private KdlTypeInfo(Type type) ArgumentAttributes = args; Properties = allMappings.Where(m => m.IsProperty).ToList(); Children = allMappings.Where(m => m.IsNode).ToList(); - + Collections = allMappings.Where(m => m.IsWrappedCollection).ToList(); Dictionaries = allMappings .Where(m => m.IsNodeDictionary /* TODO: Add support for IsPropertyDictionary and IsKeyedNodeCollection */ diff --git a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs index 63d5ed5..57e5b32 100644 --- a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs @@ -115,6 +115,8 @@ private void MapNodeToObject(KdlNode node, object instance, KdlTypeInfo metadata MapDictionaries(node.Children?.Nodes, node, instance, metadata); + MapWrappedCollections(node.Children?.Nodes, instance, metadata); + if (metadata.IsDictionary && metadata.DictionaryDef != null) { var (keyType, valueType) = metadata.DictionaryDef; @@ -122,6 +124,33 @@ private void MapNodeToObject(KdlNode node, object instance, KdlTypeInfo metadata } } + private void MapWrappedCollections(List? nodes, object instance, KdlTypeInfo metadata) + { + if (nodes is null || nodes.Count == 0) + return; + + foreach (var mapping in metadata.Collections) + { + var container = nodes.LastOrDefault(n => + n.Name.Value.Equals(mapping.Name, NodeNameComparison) + ); + + if (container?.Children?.Nodes is null) + continue; + + var itemType = mapping.Property.PropertyType.GetCollectionElementType(); + var itemInfo = KdlTypeInfo.For(itemType); + + var targetName = mapping.CollectionElementName ?? itemInfo.NodeName; + + var items = container + .Children.Nodes.Where(n => n.Name.Value.Equals(targetName, NodeNameComparison)) + .ToList(); + + SetCollectionProperty(mapping.Property, instance, items); + } + } + /// /// Maps child KDL nodes to properties marked with [KdlNode]. /// diff --git a/src/Kuddle.Net/Serialization/ObjectSerializer.cs b/src/Kuddle.Net/Serialization/ObjectSerializer.cs index 54ca008..1ff21af 100644 --- a/src/Kuddle.Net/Serialization/ObjectSerializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectSerializer.cs @@ -201,6 +201,39 @@ void AddChild(KdlNode child) } } + foreach (var mapping in metadata.Collections) + { + var val = mapping.Property.GetValue(instance); + if (val is null) + continue; + + var collection = (IEnumerable)val; + + // 1. Create the Container Node + var container = new KdlNode(KdlValue.From(mapping.Name)); + var nodes = new List(); + // 2. Determine Item Name + var itemType = mapping.Property.PropertyType.GetCollectionElementType(); + var itemMeta = KdlTypeInfo.For(itemType); + var targetName = mapping.CollectionElementName ?? itemMeta.NodeName; + + // 3. Serialize Items as children of the Container + foreach (var item in collection) + { + if (item is null) + continue; + // Recursively serialize, forcing the name to "event" + nodes.Add(SerializeToNode(item, targetName)); + } + + // 4. Attach + if (nodes.Count > 0) + { + container = container with { Children = new KdlBlock() { Nodes = nodes } }; + AddChild(container); + } + } + if ( metadata.IsDictionary && metadata.DictionaryDef != null From b4e4d13e32aa78004e264ac589e97118a3295e98 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:35:17 +0000 Subject: [PATCH 06/19] add minver default prerelease ID prop --- src/Kuddle.Net/Kuddle.Net.csproj | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Kuddle.Net/Kuddle.Net.csproj b/src/Kuddle.Net/Kuddle.Net.csproj index 883fd4c..10135c1 100644 --- a/src/Kuddle.Net/Kuddle.Net.csproj +++ b/src/Kuddle.Net/Kuddle.Net.csproj @@ -8,7 +8,7 @@ net10.0 preview true - + preview.0 jamesshenry jamesshenry @@ -33,7 +33,6 @@ - From 81fd2ce747c25d8692380c25986e9ce74c52df4d Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:50:14 +0000 Subject: [PATCH 07/19] allow nuget prereleases --- .github/workflows/prerelease-nuget.yml | 30 ++++++++++++++++++++++++++ .github/workflows/release-nuget.yml | 2 ++ 2 files changed, 32 insertions(+) create mode 100644 .github/workflows/prerelease-nuget.yml diff --git a/.github/workflows/prerelease-nuget.yml b/.github/workflows/prerelease-nuget.yml new file mode 100644 index 0000000..bef1e18 --- /dev/null +++ b/.github/workflows/prerelease-nuget.yml @@ -0,0 +1,30 @@ +name: Publish Preview Nuget + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+-*" # Matches semver tags with a suffix, e.g., 1.2.3-preview, 1.2.3-beta1 + branches-ignore: + - main + workflow_dispatch: + +permissions: + contents: write + +jobs: + release-nuget-preview: + name: Publish Preview NuGet Package + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: Run Tests + run: dotnet run .build/targets.cs test + - name: Pack NuGet Package + run: dotnet run .build/targets.cs pack + - name: Push NuGet Package + run: dotnet nuget push "dist/nuget/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" --skip-duplicate diff --git a/.github/workflows/release-nuget.yml b/.github/workflows/release-nuget.yml index 8e70ca8..e674d3e 100644 --- a/.github/workflows/release-nuget.yml +++ b/.github/workflows/release-nuget.yml @@ -4,6 +4,8 @@ on: push: tags: - "[0-9]+.[0-9]+.[0-9]+" + branches: + - main permissions: contents: write From a5faa3891d388b923e575217939b6a1337f7ceca Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:52:34 +0000 Subject: [PATCH 08/19] fix indentation --- .github/workflows/prerelease-nuget.yml | 46 +++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/prerelease-nuget.yml b/.github/workflows/prerelease-nuget.yml index bef1e18..ddca187 100644 --- a/.github/workflows/prerelease-nuget.yml +++ b/.github/workflows/prerelease-nuget.yml @@ -1,30 +1,30 @@ name: Publish Preview Nuget on: - push: - tags: - - "[0-9]+.[0-9]+.[0-9]+-*" # Matches semver tags with a suffix, e.g., 1.2.3-preview, 1.2.3-beta1 - branches-ignore: - - main - workflow_dispatch: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+-*" # Matches semver tags with a suffix, e.g., 1.2.3-preview, 1.2.3-beta1 + branches-ignore: + - main + workflow_dispatch: permissions: - contents: write + contents: write jobs: - release-nuget-preview: - name: Publish Preview NuGet Package - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json - - name: Run Tests - run: dotnet run .build/targets.cs test - - name: Pack NuGet Package - run: dotnet run .build/targets.cs pack - - name: Push NuGet Package - run: dotnet nuget push "dist/nuget/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" --skip-duplicate + release-nuget-preview: + name: Publish Preview NuGet Package + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: Run Tests + run: dotnet run .build/targets.cs test + - name: Pack NuGet Package + run: dotnet run .build/targets.cs pack + - name: Push NuGet Package + run: dotnet nuget push "dist/nuget/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" --skip-duplicate From e8b6fd5f5117ceae6b4ceda4a3a8da3a560483f5 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:57:27 +0000 Subject: [PATCH 09/19] fix prerelease --- .github/workflows/prerelease-nuget.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/prerelease-nuget.yml b/.github/workflows/prerelease-nuget.yml index ddca187..9510c27 100644 --- a/.github/workflows/prerelease-nuget.yml +++ b/.github/workflows/prerelease-nuget.yml @@ -3,9 +3,7 @@ name: Publish Preview Nuget on: push: tags: - - "[0-9]+.[0-9]+.[0-9]+-*" # Matches semver tags with a suffix, e.g., 1.2.3-preview, 1.2.3-beta1 - branches-ignore: - - main + - "[0-9]*.[0-9]*.[0-9]*-*" # Matches semver tags with a suffix, e.g., 1.2.3-preview, 1.2.3-beta1 workflow_dispatch: permissions: From 9ea72d95f46fe3bd26c28c126e344e6cd99a1691 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:20:13 +0000 Subject: [PATCH 10/19] wip --- .ai/outputs/codebase.txt | 9722 +++++++++++++++++ .github/codebase.ps1 | 67 + .../Serialization/KdlTypeInfoTests.cs | 288 +- src/Kuddle.Net/AST/KdlNode.cs | 2 + src/Kuddle.Net/Extensions/ParserExtensions.cs | 7 +- src/Kuddle.Net/Extensions/TypeExtensions.cs | 109 +- src/Kuddle.Net/Serialization/KdlMemberInfo.cs | 9 + src/Kuddle.Net/Serialization/KdlMemberMap.cs | 46 + src/Kuddle.Net/Serialization/KdlSerializer.cs | 7 +- src/Kuddle.Net/Serialization/KdlTypeInfo.cs | 159 - .../Serialization/KdlTypeMapping.cs | 174 + .../Serialization/KdlValueConverter.cs | 16 + .../Serialization/ObjectDeserializer.cs | 395 +- .../Serialization/ObjectSerializer.cs | 313 +- 14 files changed, 10466 insertions(+), 848 deletions(-) create mode 100644 .ai/outputs/codebase.txt create mode 100644 .github/codebase.ps1 create mode 100644 src/Kuddle.Net/Serialization/KdlMemberMap.cs create mode 100644 src/Kuddle.Net/Serialization/KdlTypeMapping.cs diff --git a/.ai/outputs/codebase.txt b/.ai/outputs/codebase.txt new file mode 100644 index 0000000..bd974b5 --- /dev/null +++ b/.ai/outputs/codebase.txt @@ -0,0 +1,9722 @@ + - Kuddle.Net + - AST + - Exceptions + - Extensions + - Parser + - Serialization + - Attributes + - Validation + - Kuddle.Net.Benchmarks + - Kuddle.Net.Tests + - AST + - Errors + - Extensions + - Grammar + - Serialization + - Models + - test_cases + - expected_kdl + - input + - Validation + +# --- Start of Code Files --- + + +// File: src\Kuddle.Net\AST\KdlArgument.cs`$langnamespace Kuddle.AST; + +public sealed record KdlArgument(KdlValue Value) : KdlEntry; + +``` +// File: src\Kuddle.Net\AST\KdlBlock.cs`$langusing System.Collections.Generic; + +namespace Kuddle.AST; + +public sealed record KdlBlock : KdlObject +{ + public List Nodes { get; init; } = []; +} + +``` +// File: src\Kuddle.Net\AST\KdlBool.cs`$langnamespace Kuddle.AST; + +public sealed record KdlBool(bool Value) : KdlValue; + +``` +// File: src\Kuddle.Net\AST\KdlDocument.cs`$langusing System.Collections.Generic; +using Kuddle.Serialization; + +namespace Kuddle.AST; + +public sealed record KdlDocument : KdlObject +{ + public List Nodes { get; init; } = []; + + public string ToString(KdlWriterOptions? options = null) + { + return KdlWriter.Write(this, options); + } + + public override string ToString() + { + return KdlWriter.Write(this); + } +} + +``` +// File: src\Kuddle.Net\AST\KdlEntry.cs`$langnamespace Kuddle.AST; + +public abstract record KdlEntry : KdlObject; + +``` +// File: src\Kuddle.Net\AST\KdlNode.cs`$langusing System.Collections.Generic; + +namespace Kuddle.AST; + +public sealed record KdlNode(KdlString Name) : KdlObject +{ + public List Entries { get; init; } = []; + + public KdlBlock? Children { get; init; } + + public bool TerminatedBySemicolon { get; init; } + public string? TypeAnnotation { get; init; } + + /// + /// Gets the value of the last property with the specified name (per KDL spec, last wins). + /// Returns null if no property with that name exists. + /// + public KdlValue? this[string key] + { + get + { + for (var i = Entries.Count - 1; i >= 0; i--) + { + if ( + Entries[i] is KdlProperty { Key.Value: var propKey, Value: var value } + && propKey == key + ) + { + return value; + } + } + return null; + } + } + + /// + /// Gets all arguments (positional values) for this node. + /// + public IEnumerable Arguments + { + get + { + foreach (var entry in Entries) + { + if (entry is KdlArgument arg) + { + yield return arg.Value; + } + } + } + } + + /// + /// Gets all properties (key-value pairs) for this node. + /// + public IEnumerable Properties + { + get + { + foreach (var entry in Entries) + { + if (entry is KdlProperty prop) + { + yield return prop; + } + } + } + } +} + +``` +// File: src\Kuddle.Net\AST\KdlNull.cs`$langnamespace Kuddle.AST; + +public sealed record KdlNull : KdlValue; + +``` +// File: src\Kuddle.Net\AST\KdlNumber.cs`$langusing System; +using System.Globalization; + +namespace Kuddle.AST; + +public sealed record KdlNumber(string RawValue) : KdlValue +{ + public NumberBase GetBase() + { + ReadOnlySpan span = RawValue.AsSpan(); + if (span.IsEmpty) + return NumberBase.Decimal; + + // Skip sign + if (span[0] == '+' || span[0] == '-') + { + if (span.Length == 1) + return NumberBase.Decimal; + span = span[1..]; + } + + // Check prefix + if (span.Length >= 2 && span[0] == '0') + { + char c = span[1]; + if (c == 'x' || c == 'X') + return NumberBase.Hex; + if (c == 'o' || c == 'O') + return NumberBase.Octal; + if (c == 'b' || c == 'B') + return NumberBase.Binary; + } + + return NumberBase.Decimal; + } + + public long ToInt64() + { + if (RawValue.ContainsAny(['.', 'e', 'E']) || RawValue.StartsWith('#')) + throw new FormatException($"Value '{RawValue}' is not a valid Integer."); + var (sanitised, radix, isNegative) = Sanitise(RawValue, GetBase()); + + try + { + ulong magnitude = Convert.ToUInt64(sanitised, radix); + + if (isNegative) + { + if (magnitude == (ulong)long.MaxValue + 1) + { + return long.MinValue; + } + + if (magnitude > long.MaxValue) + { + throw new OverflowException(); + } + + return -(long)magnitude; + } + else + { + if (magnitude > (ulong)long.MaxValue) + { + throw new OverflowException(); + } + return (long)magnitude; + } + } + catch (FormatException) + { + throw new FormatException($"Value '{RawValue}' is not a valid {GetBase()} integer."); + } + } + + public int ToInt32() => checked((int)ToInt64()); + + public short ToInt16() => checked((short)ToInt64()); + + public sbyte ToSByte() => checked((sbyte)ToInt64()); + + public ulong ToUInt64() + { + if (RawValue.ContainsAny(['.', 'e', 'E']) || RawValue.StartsWith('#')) + throw new FormatException($"Value '{RawValue}' is not a valid Integer."); + + var (magnitudeString, radix, isNegative) = Sanitise(RawValue, GetBase()); + + if (isNegative) + throw new OverflowException("Cannot convert negative value to UInt64."); + + return Convert.ToUInt64(magnitudeString, radix); + } + + public uint ToUInt32() => checked((uint)ToUInt64()); + + public ushort ToUInt16() => checked((ushort)ToUInt64()); + + public byte ToByte() => checked((byte)ToUInt64()); + + public double ToDouble() + { + var numberBase = GetBase(); + + if (RawValue.StartsWith('#')) + { + return (double)( + RawValue switch + { + "#inf" => double.PositiveInfinity, + "#-inf" => double.NegativeInfinity, + "#nan" => double.NaN, + _ => throw new NotSupportedException(), + } + ); + } + var (sanitised, radix, isNegative) = Sanitise(RawValue, numberBase); + + double result; + if (radix != 10) + { + result = Convert.ToUInt64(sanitised, radix); + } + else + { + result = Convert.ToDouble(sanitised); + } + return isNegative ? result * -1 : result; + } + + public float ToFloat() => checked((float)ToDouble()); + + public decimal ToDecimal() + { + var numberBase = GetBase(); + if (RawValue.StartsWith('#')) + { + throw new NotSupportedException(); + } + var (sanitised, radix, isNegative) = Sanitise(RawValue, numberBase); + + decimal result; + if (radix != 10) + { + result = Convert.ToUInt64(sanitised, radix); + } + else + { + result = decimal.Parse(sanitised, NumberStyles.Float, CultureInfo.InvariantCulture); + } + return isNegative ? result * -1 : result; + } + + private static (string cleaned, int radix, bool isNegative) Sanitise( + string raw, + NumberBase baseKind + ) + { + string s = raw.Replace("_", ""); + if (string.IsNullOrEmpty(s)) + return ("0", 10, false); + + bool isNegative = s.StartsWith('-'); + bool isPositive = s.StartsWith('+'); + + string sanitised = (isNegative || isPositive) ? s.Substring(1) : s; + + int radix = 10; + if (baseKind == NumberBase.Hex) + { + radix = 16; + if (sanitised.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + sanitised = sanitised.Substring(2); + } + else if (baseKind == NumberBase.Octal) + { + radix = 8; + if (sanitised.StartsWith("0o", StringComparison.OrdinalIgnoreCase)) + sanitised = sanitised.Substring(2); + } + else if (baseKind == NumberBase.Binary) + { + radix = 2; + if (sanitised.StartsWith("0b", StringComparison.OrdinalIgnoreCase)) + sanitised = sanitised.Substring(2); + } + + return (sanitised, radix, isNegative); + } + + internal string ToCanonicalString() + { + if (RawValue.StartsWith('#')) + return RawValue; + + var numberBase = GetBase(); + var (clean, radix, isNegative) = Sanitise(RawValue, numberBase); + + if (numberBase == NumberBase.Decimal) + { + return isNegative ? '-' + clean : clean; + } + else + { + return TryDecimal() ?? TryBigInteger() ?? RawValue; + } + + string? TryDecimal() + { + try + { + var dec = ToDecimal(); + return dec.ToString(CultureInfo.InvariantCulture); + } + catch (OverflowException) + { + return null; + } + } + string? TryBigInteger() + { + try + { + var bi = System.Numerics.BigInteger.Parse( + "0" + clean, + radix switch + { + 2 => NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite, + 8 => NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite, + 16 => NumberStyles.AllowHexSpecifier, + _ => NumberStyles.Integer, + } + ); + + if (isNegative) + bi = -bi; + + return bi.ToString(CultureInfo.InvariantCulture); + } + catch + { + return null; + } + } + } +} + +``` +// File: src\Kuddle.Net\AST\KdlObject.cs`$langnamespace Kuddle.AST; + +public record KdlObject +{ + public string LeadingTrivia { get; init; } = string.Empty; + public string TrailingTrivia { get; init; } = string.Empty; +} + +``` +// File: src\Kuddle.Net\AST\KdlProperty.cs`$langnamespace Kuddle.AST; + +public sealed record KdlProperty(KdlString Key, KdlValue Value) : KdlEntry +{ + public string EqualsTrivia { get; init; } = "="; +} + +``` +// File: src\Kuddle.Net\AST\KdlSkippedEntry.cs`$langnamespace Kuddle.AST; + +public sealed record KdlSkippedEntry(string RawText) : KdlEntry; + +``` +// File: src\Kuddle.Net\AST\KdlString.cs`$langnamespace Kuddle.AST; + +public sealed record KdlString(string Value, StringKind Kind) : KdlValue +{ + public override string ToString() => Value; +} + +``` +// File: src\Kuddle.Net\AST\KdlTrivia.cs`$langusing System.Collections.Immutable; + +namespace Kuddle.AST; + +public record KdlTrivia(string Value, TriviaKind Kind) : KdlValue { } + +public enum TriviaKind +{ + Unknown, + WhiteSpace, + NewLine, + Comment, +} + +``` +// File: src\Kuddle.Net\AST\KdlValue.cs`$langusing System; +using System.Globalization; +using System.Linq; + +namespace Kuddle.AST; + +public abstract record KdlValue : KdlObject +{ + public string? TypeAnnotation { get; init; } + + /// Gets a KdlNull value. + public static KdlValue Null => new KdlNull(); + + /// Creates a KdlString from a Guid with "uuid" type annotation. + public static KdlString From(Guid guid, StringKind stringKind = StringKind.Quoted) + { + return new KdlString(guid.ToString(), stringKind) { TypeAnnotation = "uuid" }; + } + + /// Creates a KdlString from a DateTimeOffset with "date-time" type annotation (ISO 8601). + public static KdlString From(DateTimeOffset date, StringKind stringKind = StringKind.Quoted) + { + return new KdlString(date.ToString("O"), stringKind) { TypeAnnotation = "date-time" }; + } + + /// Creates a KdlString from a string value. + public static KdlString From(string value, StringKind stringKind = StringKind.Bare) + { + foreach (char c in value) + { + if (char.IsWhiteSpace(c)) + stringKind = StringKind.Quoted; + } + return new KdlString(value, stringKind); + } + + /// Creates a KdlBool from a boolean value. + public static KdlBool From(bool value) + { + return new KdlBool(value); + } + + /// Creates a KdlNumber from an integer value. + public static KdlNumber From(int value) + { + return new KdlNumber(value.ToString(CultureInfo.InvariantCulture)); + } + + /// Creates a KdlNumber from a long value. + public static KdlNumber From(long value) + { + return new KdlNumber(value.ToString(CultureInfo.InvariantCulture)); + } + + /// Creates a KdlNumber from a double value. + public static KdlNumber From(double value) + { + return new KdlNumber(value.ToString("G17", CultureInfo.InvariantCulture)); + } + + /// Creates a KdlNumber from a decimal value. + public static KdlNumber From(decimal value) + { + return new KdlNumber(value.ToString(CultureInfo.InvariantCulture)); + } + + internal static KdlString From(Enum e) + { + return new KdlString(e.ToString(), StringKind.Bare); + } +} + +``` +// File: src\Kuddle.Net\AST\NumberBase.cs`$langnamespace Kuddle.AST; + +public enum NumberBase +{ + Decimal, + Hex, + Octal, + Binary, +} + +``` +// File: src\Kuddle.Net\AST\StringKind.cs`$langusing System; + +namespace Kuddle.AST; + +[Flags] +public enum StringKind +{ + Bare = 1, + Quoted = 2, + Raw = 4, + MultiLine = 8, + + MultiLineRaw = MultiLine | Raw, + QuotedRaw = Quoted | Raw, +} + +``` +// File: src\Kuddle.Net\Exceptions\KdlConfigurationException.cs`$langusing System; + +namespace Kuddle.Exceptions; + +[Serializable] +internal class KdlConfigurationException : Exception +{ + public KdlConfigurationException() { } + + public KdlConfigurationException(string? message) + : base(message) { } + + public KdlConfigurationException(string? message, Exception? innerException) + : base(message, innerException) { } +} + +``` +// File: src\Kuddle.Net\Exceptions\KuddleParseException.cs`$langusing System; + +namespace Kuddle.Exceptions; + +[Serializable] +public class KuddleParseException : Exception +{ + private readonly Exception? _ex = default; + + public KuddleParseException() { } + + public KuddleParseException(Exception ex) + { + _ex = ex; + } + + public KuddleParseException(string? message) + : base(message) { } + + public KuddleParseException(string? message, Exception? innerException) + : base(message, innerException) { } + + public KuddleParseException(string? message, int? column, int? line, int? offset) + : this(message) + { + Column = column; + Line = line; + Offset = offset; + } + + public int? Line { get; } + public int? Column { get; } + public int? Offset { get; } +} + +``` +// File: src\Kuddle.Net\Exceptions\KuddleSerializationException.cs`$langusing System; + +namespace Kuddle.Serialization; + +[Serializable] +internal class KuddleSerializationException : Exception +{ + private readonly Exception? _ex; + + public KuddleSerializationException() { } + + public KuddleSerializationException(Exception ex) + { + _ex = ex; + } + + public KuddleSerializationException(string? message) + : base(message) { } + + public KuddleSerializationException(string? message, Exception? innerException) + : base(message, innerException) { } +} + +``` +// File: src\Kuddle.Net\Exceptions\KuddleValidationException.cs`$langusing System; +using System.Collections.Generic; +using Kuddle.AST; + +namespace Kuddle.Exceptions; + +public class KuddleValidationException : Exception +{ + public IEnumerable Errors { get; } = []; + + public KuddleValidationException(List errors) + : base($"Found {errors.Count} validation errors in the KDL document.") + { + Errors = errors; + } + + public KuddleValidationException() { } + + public KuddleValidationException(string? message) + : base(message) { } + + public KuddleValidationException(string? message, Exception? innerException) + : base(message, innerException) { } +} + +public record KuddleValidationError(string Message, KdlObject Source); + +``` +// File: src\Kuddle.Net\Extensions\KdlNodeExtensions.cs`$langusing Kuddle.AST; + +namespace Kuddle.Extensions; + +public static class KdlNodeExtensions +{ + extension(KdlNode node) + { + public KdlValue? Prop(string key) + { + for (int i = node.Entries.Count - 1; i >= 0; i--) + { + if (node.Entries[i] is KdlProperty prop && prop.Key.Value == key) + return prop.Value; + } + return null; + } + + public KdlValue? Arg(int index) + { + int count = 0; + foreach (var entry in node.Entries) + { + if (entry is KdlArgument arg) + { + if (count == index) + return arg.Value; + count++; + } + } + return null; + } + + public bool TryGetProp(string key, out T result) + { + result = default!; + var val = node.Prop(key); + if (val is null) + { + return false; + } + + if (typeof(T) == typeof(int) && val.TryGetInt(out int i)) + { + result = (T)(object)i; + return true; + } + if (typeof(T) == typeof(bool) && val.TryGetBool(out bool b)) + { + result = (T)(object)b; + return true; + } + if (typeof(T) == typeof(string) && val.TryGetString(out string? s)) + { + result = (T)(object)s; + return true; + } + // ... add other types + + return false; + } + } +} + +``` +// File: src\Kuddle.Net\Extensions\KdlValueExtensions.cs`$langusing System; +using System.Diagnostics.CodeAnalysis; +using Kuddle.AST; + +namespace Kuddle.Extensions; + +public static class KdlValueExtensions +{ + extension(KdlValue value) + { + public bool IsNull => value is KdlNull; + public bool IsNumber => value is KdlNumber; + public bool IsString => value is KdlString; + public bool IsBool => value is KdlBool; + + public bool TryGetInt(out int result) + { + result = 0; + if (value is KdlNumber num) + { + try + { + result = num.ToInt32(); + return true; + } + catch + { + return false; + } + } + return false; + } + + public bool TryGetLong(out long result) + { + result = 0; + if (value is KdlNumber num) + { + try + { + result = num.ToInt64(); + return true; + } + catch + { + return false; + } + } + return false; + } + + // --- Floats --- + + public bool TryGetDouble(out double result) + { + result = 0; + if (value is KdlNumber num) + { + try + { + result = num.ToDouble(); + return true; + } + catch + { + return false; + } + } + return false; + } + + public bool TryGetDecimal(out decimal result) + { + result = 0; + if (value is KdlNumber num) + { + try + { + result = num.ToDecimal(); + return true; + } + catch + { + return false; + } + } + return false; + } + + // --- Booleans --- + + public bool TryGetBool(out bool result) + { + if (value is KdlBool b) + { + result = b.Value; + return true; + } + result = false; + return false; + } + + // --- Strings --- + + public bool TryGetString([NotNullWhen(true)] out string? result) + { + if (value is KdlString s) + { + result = s.Value; + return true; + } + result = null; + return false; + } + + // --- Complex Types (UUID, Date, IP) --- + + public bool TryGetUuid(out Guid result) + { + result = Guid.Empty; + return value is KdlString s && Guid.TryParse(s.Value, out result); + } + + public bool TryGetDateTime(out DateTimeOffset result) + { + result = default; + return value is KdlString s && DateTimeOffset.TryParse(s.Value, out result); + } + } +} + +``` +// File: src\Kuddle.Net\Extensions\ParserExtensions.cs`$langusing System.Diagnostics; +using Kuddle.Parser; +using Parlot.Fluent; + +namespace Kuddle.Extensions; + +internal static class ParserExtensions +{ + /// + /// Wraps a parser with debug tracing. Only active in DEBUG builds. + /// In Release builds, this is a no-op to enable Parlot compilation. + /// + [DebuggerStepThrough] + public static Parser Debug(this Parser parser, string name) + { +#if DEBUG + return new DebugParser(parser, name); +#else + return parser; +#endif + } +} + +``` +// File: src\Kuddle.Net\Extensions\SpanExtensions.cs`$langusing System; +using System.Text; + +namespace Kuddle.Extensions; + +public static class SpanExtensions +{ + extension(ReadOnlySpan span) + { + public int MaxConsecutive(T target) + { + int max = 0; + while (true) + { + int start = span.IndexOf(target); + if (start < 0) + break; + + span = span.Slice(start); + + int length = span.IndexOfAnyExcept(target); + if (length < 0) + length = span.Length; + + if (length > max) + max = length; + + if (length == span.Length) + break; + span = span.Slice(length); + } + return max; + } + } + + extension(string input) + { + public string ToKebabCase() + { + if (string.IsNullOrEmpty(input)) + return input; + + StringBuilder result = new(); + + bool previousCharacterIsSeparator = true; + + for (int i = 0; i < input.Length; i++) + { + char currentChar = input[i]; + + if (char.IsUpper(currentChar) || char.IsDigit(currentChar)) + { + if ( + !previousCharacterIsSeparator + && ( + i > 0 + && ( + char.IsLower(input[i - 1]) + || (i < input.Length - 1 && char.IsLower(input[i + 1])) + ) + ) + ) + { + result.Append('-'); + } + + result.Append(char.ToLowerInvariant(currentChar)); + + previousCharacterIsSeparator = false; + } + else if (char.IsLower(currentChar)) + { + result.Append(currentChar); + + previousCharacterIsSeparator = false; + } + else if (currentChar == ' ' || currentChar == '_' || currentChar == '-') + { + if (!previousCharacterIsSeparator) + { + result.Append('-'); + } + + previousCharacterIsSeparator = true; + } + } + + return result.ToString(); + } + } +} + +``` +// File: src\Kuddle.Net\Extensions\TypeExtensions.cs`$langusing System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Kuddle.Serialization; + +internal static class TypeExtensions +{ + extension(Type type) + { + internal bool IsNodeDefinition => + type.GetProperties() + .Any(p => + p.GetCustomAttribute() != null + || p.GetCustomAttribute() != null + ); + internal bool IsComplexType => + !type.IsValueType + && !type.IsPrimitive + && type != typeof(string) + && type != typeof(object) + && !type.IsInterface + && !type.IsAbstract; + + internal bool IsDictionary => + type.IsGenericType + && type.GetInterfaces() + .Any(i => + i.IsGenericType + && ( + i.GetGenericTypeDefinition() == typeof(IDictionary<,>) + || i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) + ) + ); + + internal bool IsIEnumerable => + type != typeof(string) + && !type.IsDictionary + && type.IsAssignableTo(typeof(IEnumerable)); + + internal (PropertyInfo, KdlArgumentAttribute)[] GetKdlArgProps() => + type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => new + { + Property = p, + ArgAttr = p.GetCustomAttribute(), + }) + .Where(x => x.ArgAttr is not null) + .OrderBy(x => x.ArgAttr!.Index) + .Select(x => (x.Property, x.ArgAttr!)) + .ToArray(); + + internal (PropertyInfo, KdlPropertyAttribute)[] GetKdlPropProps() => + type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => new + { + Property = p, + PropAttr = p.GetCustomAttribute(), + }) + .Where(x => x.PropAttr is not null) + .Select(x => (x.Property, x.PropAttr!)) + .ToArray(); + + internal (PropertyInfo, KdlNodeAttribute)[] GetKdlChildProps() => + type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => new + { + Property = p, + ChildAttr = p.GetCustomAttribute(), + }) + .Where(x => x.ChildAttr is not null) + .Select(x => (x.Property, x.ChildAttr!)) + .ToArray(); + + internal DictionaryInfo? GetDictionaryInfo() + { + if (type == typeof(string)) + return null; + + var args = type.GetInterfaces() + .Append(type) + .FirstOrDefault(i => + i.IsGenericType + && ( + i.GetGenericTypeDefinition() == typeof(IDictionary<,>) + || i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) + ) + ) + ?.GetGenericArguments(); + + return args is null ? null : new DictionaryInfo(args[0], args[1]); + } + + internal Type? GetCollectionInfo() + { + if (type == typeof(string)) + return null; + + // Exclude dictionaries from being treated as simple collections + if (type.IsDictionary) + return null; + + // Arrays + if (type.IsArray) + return type.GetElementType(); + + // IEnumerable + var enumInterface = type.GetInterfaces() + .Append(type) + .FirstOrDefault(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) + ); + + if (enumInterface != null) + { + return enumInterface.GetGenericArguments()[0]; + } + + return null; + } + + internal bool IsKdlScalar => + type.IsPrimitive + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(Guid); + + public Type GetCollectionElementType() => + type.IsArray ? type.GetElementType()! + : type.IsGenericType ? type.GetGenericArguments()[0] + : throw new KuddleSerializationException( + $"Unsupported collection type '{type.FullName}'." + ); + } +} + +``` +// File: src\Kuddle.Net\Parser\CharacterSets.cs`$langusing System; +using System.Collections.Generic; + +namespace Kuddle.Parser; + +public static class CharacterSets +{ + public static ReadOnlySpan Digits => "0123456789"; + public static ReadOnlySpan DigitsAndUnderscore => "0123456789_"; + public static ReadOnlySpan StringExcludedChars => + ['[', '"', '\\', 'b', 'f', 'n', 't', 'r', 's']; + public static ReadOnlySpan WhiteSpaceChars => + [ + '\u0009', + '\u0020', + '\u00A0', + '\u1680', + '\u2000', + '\u2001', + '\u2002', + '\u2003', + '\u2004', + '\u2005', + '\u2006', + '\u2007', + '\u2008', + '\u2009', + '\u200A', + '\u202F', + '\u205F', + '\u3000', + ]; + + internal static bool IsWhiteSpace(char c) + { + return c switch + { + '\u0009' => true, // Character Tabulation + '\u0020' => true, // Space + '\u00A0' => true, // No-Break Space + '\u1680' => true, // Ogham Space Mark + '\u2000' => true, // En Quad + '\u2001' => true, // Em Quad + '\u2002' => true, // En Space + '\u2003' => true, // Em Space + '\u2004' => true, // Three-Per-Em Space + '\u2005' => true, // Four-Per-Em Space + '\u2006' => true, // Six-Per-Em Space + '\u2007' => true, // Figure Space + '\u2008' => true, // Punctuation Space + '\u2009' => true, // Thin Space + '\u200A' => true, // Hair Space + '\u202F' => true, // Narrow No-Break Space + '\u205F' => true, // Medium Mathematical Space + '\u3000' => true, // Ideographic Space + _ => false, + }; + } + + internal static bool IsNewline(char c) + { + return c switch + { + '\u000D' => true, + '\u000A' => true, + '\u0085' => true, + '\u000B' => true, + '\u000C' => true, + '\u2028' => true, + '\u2029' => true, + _ => false, + }; + } + + public static readonly HashSet ReservedTypes = + [ + "i8", + "i16", + "i32", + "i64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "decimal64", + "decimal128", + "date-time", + "time", + "date", + "duration", + "decimal", + "currency", + "country-2", + "country-3", + "ipv4", + "ipv6", + "url", + "uuid", + "regex", + "base64", + ]; + + /// + /// Maps KDL type annotations to their corresponding CLR types. + /// Not all reserved types have CLR equivalents (e.g., country codes, IP addresses as validated strings). + /// + public static readonly Dictionary TypeAnnotationToClrType = new() + { + ["i8"] = typeof(sbyte), + ["i16"] = typeof(short), + ["i32"] = typeof(int), + ["i64"] = typeof(long), + ["u8"] = typeof(byte), + ["u16"] = typeof(ushort), + ["u32"] = typeof(uint), + ["u64"] = typeof(ulong), + ["f32"] = typeof(float), + ["f64"] = typeof(double), + ["decimal64"] = typeof(decimal), + ["decimal128"] = typeof(decimal), + ["decimal"] = typeof(decimal), + ["date-time"] = typeof(DateTimeOffset), + ["time"] = typeof(TimeOnly), + ["date"] = typeof(DateOnly), + ["duration"] = typeof(TimeSpan), + ["uuid"] = typeof(Guid), + ["url"] = typeof(Uri), + ["base64"] = typeof(byte[]), + // These remain as strings with semantic meaning: + // "currency", "country-2", "country-3", "ipv4", "ipv6", "regex" + }; + + /// + /// Gets the CLR type for a KDL type annotation, or null if no mapping exists. + /// + public static Type? GetClrType(string? typeAnnotation) => + typeAnnotation is not null + && TypeAnnotationToClrType.TryGetValue(typeAnnotation, out var type) + ? type + : null; +} + +``` +// File: src\Kuddle.Net\Parser\DebugParser.cs`$langusing System; +using Parlot; +using Parlot.Fluent; + +namespace Kuddle.Parser; + +public class DebugParser : Parser +{ + private readonly Parser _inner; + private readonly string _name; + + public DebugParser(Parser inner, string name) + { + _inner = inner; + _name = name; + } + + public override bool Parse(ParseContext context, ref ParseResult result) + { + var startCursor = context.Scanner.Cursor.Position; + + // Peek at the next few chars to see what we are looking at + var peekPreview = context + .Scanner.Buffer.Substring( + startCursor.Offset, + Math.Min(20, context.Scanner.Buffer.Length - startCursor.Offset) + ) + .Replace("\n", "\\n") + .Replace("\r", "\\r"); + + System.Diagnostics.Debug.WriteLine( + $"[START] {_name} at {startCursor.Line}:{startCursor.Column} (Input: '{peekPreview}')" + ); + + if (_inner.Parse(context, ref result)) + { + System.Diagnostics.Debug.WriteLine($"[MATCH] {_name} -> {result.Value}"); + return true; + } + + System.Diagnostics.Debug.WriteLine($"[FAIL ] {_name}"); + return false; + } +} + +``` +// File: src\Kuddle.Net\Parser\KdlGrammar.cs`$langusing System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Kuddle.AST; +using Kuddle.Extensions; +using Parlot; +using Parlot.Fluent; +using static Parlot.Fluent.Parsers; + +namespace Kuddle.Parser; + +public static class KdlGrammar +{ + internal static readonly Parser Document; + + #region Numbers + internal static readonly Parser Decimal; + internal static readonly Parser Integer; + internal static readonly Parser Sign; + internal static readonly Parser Hex; + internal static readonly Parser Octal; + internal static readonly Parser Binary; + internal static readonly Parser Number; + #endregion + + #region Keywords and booleans + internal static readonly Parser Boolean; + internal static readonly Parser KeywordNumber; + internal static readonly Parser Keyword; + #endregion + + #region Specific code points + internal static readonly Parser Bom = Literals.Char('\uFEFF'); + #endregion + + #region Comments + internal static readonly Parser SingleLineComment; + internal static readonly Parser MultiLineComment; + internal static readonly Parser SlashDash; + #endregion + + #region WhiteSpace + internal static readonly Parser Ws; + internal static readonly Parser EscLine; + internal static readonly Parser NodeSpace; + internal static readonly Parser LineSpace = Deferred(); + internal static readonly Parser Type; + private static readonly Parser Value; + internal static readonly Parser UnambiguousIdent; + internal static readonly Parser SignedIdent; + internal static readonly Parser DottedIdent; + internal static readonly Parser HexUnicode; + internal static readonly Parser WsEscape; + internal static readonly Parser StringCharacter; + internal static readonly Parser MultiLineQuoted; + internal static readonly Parser SingleLineQuoted; + internal static readonly Parser RawString; + internal static readonly Parser IdentifierString; + internal static readonly Parser QuotedString; + internal static readonly Parser String; + #endregion + + internal static readonly Parser FinalNode = Deferred(); + internal static readonly Parser Node; + internal static readonly Parser> Nodes = Deferred< + IReadOnlyList + >(); + + static KdlGrammar() + { + var nodeSpace = Deferred(); + + var singleNewLine = Capture(Literals.Text("\r\n").Or(Literals.Text("\n"))) + .Debug("SingleNewLine"); + var eof = Capture(Always().Eof()); + Sign = Literals.AnyOf(['+', '-'], 1, 1); + + //Strings + + var singleQuote = Literals.Char('"'); + var tripleQuote = Literals.Text("\"\"\"").Debug("tripleQuote"); + var hash = Literals.Char('#'); + + var openingHashes = Capture(OneOrMany(hash)); + + // TODO: Investigate using Runes instead of chars: https://learn.microsoft.com/en-us/dotnet/api/system.text.rune + var identifierChar = Literals.Pattern( + c => + !CharacterSets.IsNewline(c) + && !CharacterSets.IsWhiteSpace(c) + && !"\\/(){};[]\"#=".Contains(c), + 1, + 1 + ); + var unambiguousStartChar = identifierChar.When( + (_, c) => c.Span[^1] >= 'a' && c.Span[^1] <= 'z' + ); + var literalCodePoint = Literals + .NoneOf("", minSize: 1, maxSize: 1) + .When((context, c) => !IsDisallowedLiteralCodePoint(c.Span[0])); + + var hexSequence = Literals.Pattern(IsHexChar, 1, 6); + HexUnicode = hexSequence + .When((a, b) => !IsLoneSurrogate(b.Span[0])) + .Then(ts => new TextSpan(Regex.Unescape(ts.Span.ToString()))); + + WsEscape = Literals + .Char('\\') + .And( + Literals.Pattern( + c => CharacterSets.IsNewline(c) || char.IsWhiteSpace(c), + minSize: 1, + maxSize: 0 + ) + ) + .Then(x => new TextSpan()) + .Debug("WsEscape"); + + var escapeSequence = Literals + .Char('\\') + .SkipAnd( + OneOf( + Literals.Char('n').Then(_ => "\n"), + Literals.Char('r').Then(_ => "\r"), + Literals.Char('t').Then(_ => "\t"), + Literals.Char('\\').Then(_ => "\\"), + Literals.Char('"').Then(_ => "\""), + Literals.Char('b').Then(_ => "\b"), + Literals.Char('f').Then(_ => "\f"), + Literals.Char('s').Then(_ => " "), + Literals + .Text("u{") + .SkipAnd(HexUnicode) + .AndSkip(Literals.Char('}')) + .Then(ts => char.ConvertFromUtf32(Convert.ToInt32(ts.Buffer, 16))) + ) + .Then(s => new TextSpan(s)) + ); + + var surrogatePair = Capture( + Literals + .Pattern(char.IsHighSurrogate, 1, 1) + .And(Literals.Pattern(char.IsLowSurrogate, 1, 1)) + ); + + var singleChar = Literals.Pattern( + c => c != '\\' && c != '"' && !IsDisallowedLiteralCodePoint(c), + 1, + 1 + ); + var plainCharacter = OneOf(surrogatePair, singleChar) + .Then((_, x) => x.Span[0] == '\r' ? new TextSpan() : x) + .Debug("plainCharacter"); + StringCharacter = OneOf(escapeSequence, WsEscape, plainCharacter); + + var singleLineStringBody = ZeroOrMany(StringCharacter) + .Then(x => + { + if (x.Count == 0) + return new TextSpan(string.Empty); + + if (x.Count == 1) + return new TextSpan(x[0].Span.ToString()); + + int totalLength = 0; + for (int i = 0; i < x.Count; i++) + totalLength += x[i].Length; + + return new TextSpan( + string.Create( + totalLength, + x, + static (span, items) => + { + int pos = 0; + for (int i = 0; i < items.Count; i++) + { + var itemSpan = items[i].Span; + itemSpan.CopyTo(span.Slice(pos)); + pos += itemSpan.Length; + } + } + ) + ); + }); + MultiLineQuoted = new MultiLineStringParser().Debug("MultiLineQuoted"); + SingleLineQuoted = Between( + Literals.Char('"'), + singleLineStringBody.Debug("singleLineStringBody"), + Literals.Char('"').ElseError("Expected a closing quote on string") + ) + .Debug("SingleLineQuoted") + .Then(ts => new KdlString(ts.Span.ToString(), StringKind.Quoted)); + QuotedString = OneOf(MultiLineQuoted, SingleLineQuoted); + + DottedIdent = Capture( + Sign.Optional() + .And( + Literals + .Char('.') + .Debug("First identifierChar") + .And( + Literals + .Pattern( + c => + !CharacterSets.IsNewline(c) + && !CharacterSets.IsWhiteSpace(c) + && !"\\/(){};[]\"#=".Contains(c), + 0 + ) + .Optional() + .Debug("Remaining identifierChars") + ) + ) + ) + .When( + (ctx, b) => + { + var span = b.Span; + if (span.IsEmpty) + return false; + + char c0 = span[0]; + bool isSign = c0 == '+' || c0 == '-'; + + if (isSign && span.Length == 1) + return true; + + ReadOnlySpan slice = isSign ? span[1..] : span; + + if (char.IsDigit(slice[0])) + return false; + + if (slice[0] == '.' && slice.Length > 1 && char.IsDigit(slice[1])) + return false; + + return true; + } + ) + .Debug("DottedIdent"); + + SignedIdent = Capture( + Sign.And( + ZeroOrOne( + identifierChar + .When((a, b) => !IsDigitChar(b.Span[0]) && b.Span[0] != '.') + .And(ZeroOrMany(identifierChar)) + ) + ) + ); + + UnambiguousIdent = Capture( + identifierChar + .When( + (a, b) => + !IsDigitChar(b.Span[0]) && !IsSigned(b.Span[0]) && b.Span[0] != '.' + ) + .And(ZeroOrMany(identifierChar)) + ) + .Then( + (context, span) => + { + return IsReservedKeyword(span.Span) + ? throw new ParseException( + $"The keyword '{span}' cannot be used as an unquoted identifier. Wrap it in quotes: \"{span}\".", + context.Scanner.Cursor.Position + ) + : span; + } + ); + + IdentifierString = OneOf(DottedIdent, SignedIdent, UnambiguousIdent) + .Then(ts => new KdlString(ts.Span.ToString(), StringKind.Bare)); + RawString = new RawStringParser(); + String = OneOf(IdentifierString, RawString, QuotedString).Then((context, ks) => ks); + + Integer = Literals + .Pattern(c => char.IsDigit(c) || c == '_') + .When((a, b) => b.Span[0] != '_'); + var exponent = Literals.Char('e').Or(Literals.Char('E')).And(Sign.Optional()).And(Integer); + Decimal = Capture( + Sign.Optional() + .And(Integer) + .And(ZeroOrOne(Literals.Char('.').And(Integer))) + .And(exponent.Optional()) + ); + Hex = Capture( + Sign.Optional() + .And(Literals.Text("0x")) + .And(Literals.Pattern(IsHexChar, 1, 1)) + .And(ZeroOrMany(Literals.Pattern(c => c == '_' || IsHexChar(c)))) + ) + .When((context, x) => x.Span[^1] != '_'); + Octal = Capture( + Sign.Optional() + .AndSkip(Literals.Text("0o")) + .And( + Literals + .Pattern(IsOctalChar) + .And(ZeroOrMany(Literals.Pattern(c => c == '_' || IsOctalChar(c)))) + ) + ) + .When((context, x) => x.Span[^1] != '_'); + + Binary = Capture( + Sign.Optional() + .AndSkip(Literals.Text("0b")) + .And(Literals.Char('0').Or(Literals.Char('1'))) + .And(ZeroOrMany(Literals.Pattern(c => c == '_' || IsBinaryChar(c)))) + ); + Boolean = Literals + .Text("#true") + .Or(Literals.Text("#false")) + .Then(value => + value switch + { + "#true" => new KdlBool(true), + "#false" => new KdlBool(false), + _ => throw new NotSupportedException(), + } + ); + Keyword = Boolean.Or( + Literals.Text("#null").Then(_ => new KdlNull()) + ); + KeywordNumber = Capture( + OneOf(Literals.Text("#inf"), Literals.Text("#-inf"), Literals.Text("#nan")) + ); + + Number = OneOf(KeywordNumber, Hex, Octal, Binary, Decimal) + .Then((context, value) => new KdlNumber(value.Span.ToString())); + + var multiLineComment = Deferred(); + + var openComment = Literals.Text("/*"); + var closeComment = Literals.Text("*/"); + + SingleLineComment = Literals.Comments("//").Debug("SingleLineComment"); + MultiLineComment = Recursive(commentParser => + { + var nestedComment = commentParser; + + var otherContent = AnyCharBefore(openComment.Or(closeComment)); + var fullContent = ZeroOrMany(nestedComment.Or(otherContent)); + var fullCommentParser = openComment.And(fullContent).And(closeComment); + + return Capture(fullCommentParser); + }); + + var lineSpace = Deferred(); + SlashDash = Capture(Literals.Text("/-").And(ZeroOrMany(lineSpace))).Debug("SlashDash"); + + Ws = Literals + .Pattern(c => CharacterSets.IsWhiteSpace(c), minSize: 1, maxSize: 1) + .Or(MultiLineComment) + .Debug("Ws"); + EscLine = Capture( + Literals + .Text(@"\") + .And(ZeroOrMany(Ws)) + .And( + OneOf(SingleLineComment.AndSkip(OneOf(singleNewLine, eof)), singleNewLine, eof) + ) + .Debug("EscLine") + ); + + nodeSpace.Parser = Capture(Ws.ZeroOrMany().And(EscLine).And(Ws.ZeroOrMany())) + .Or(Capture(Ws.OneOrMany())); + NodeSpace = nodeSpace.Debug("NodeSpace"); + lineSpace.Parser = NodeSpace.Or(singleNewLine).Or(SingleLineComment); + LineSpace = lineSpace; + + Type = Between( + Literals.Char('('), + ZeroOrMany(NodeSpace).SkipAnd(String).AndSkip(ZeroOrMany(NodeSpace)), + Literals.Char(')').ElseError("Expected closing brace on type annotation") + ); + + Value = Type.Optional() + .AndSkip(ZeroOrMany(NodeSpace)) + .And(OneOf(Keyword, Number, String)) + .Then(x => + x.Item1.HasValue ? (x.Item2 with { TypeAnnotation = x.Item1.Value.Value }) : x.Item2 + ); + + var prop = String + .AndSkip(ZeroOrMany(NodeSpace)) + .AndSkip(Literals.Char('=')) + .AndSkip(ZeroOrMany(NodeSpace)) + .And(Value.ElseError("Expected a value at end of input")) + .Then(x => new KdlProperty(x.Item1, x.Item2) as KdlEntry); + var arg = Value.Then(v => new KdlArgument(v) as KdlEntry); + var nodePropOrArg = OneOf(prop, arg); + + var nodeTerminator = OneOf( + SingleLineComment, + singleNewLine, + Capture(Literals.AnyOf(";")), + eof + ) + .Debug("NodeTerminator"); + + var skippedEntry = SlashDash + .And(Capture(nodePropOrArg)) + .Then(x => new KdlSkippedEntry(x.Item2.ToString()) as KdlEntry) + .Debug("skippedEntry"); + + var entryParser = OneOrMany(NodeSpace) + .SkipAnd(OneOf(skippedEntry, nodePropOrArg)) + .Debug("EntryParser"); + + var nodeChildren = Literals + .Char('{') + .SkipAnd(ZeroOrMany(LineSpace)) + .SkipAnd(Nodes.And(FinalNode.Optional())) + .AndSkip( + Literals.Char('}').ElseError("Expected closing brace '}' for node children block.") // <--- COMMITMENT + ) + .Then(x => + { + var block = new KdlBlock { Nodes = [.. x.Item1] }; + if (x.Item2.HasValue) + block.Nodes.Add(x.Item2.Value!); + return block; + }) + .Debug("nodeChildren"); + + var childrenValueParser = OneOf( + SlashDash.And(nodeChildren).Then(_ => (KdlBlock?)null), + nodeChildren.Then(b => (KdlBlock?)b) + ) + .Debug("ChildrenValueParser"); + + var baseNode = SlashDash + .Optional() + .And(Type.Optional()) + .AndSkip(ZeroOrMany(NodeSpace)) + .And(String.Debug("NodeName")) + .And(ZeroOrMany(entryParser)) + .And( + ZeroOrMany( + OneOf( + OneOrMany(NodeSpace).SkipAnd(childrenValueParser), + OneOf(Capture(Literals.Char('{')), Capture(Literals.Text("/-"))) + .Then( + (context, _) => + throw new ParseException( + "Nodes must be separated from their children block by whitespace.", + context.Scanner.Cursor.Position + ) + ) + ) + ) + ) + .AndSkip(ZeroOrMany(NodeSpace)) + .Then(result => + { + if (result.Item1.HasValue) + return null; + + var entries = result.Item4; + var hasSkipped = false; + for (int i = 0; i < entries.Count; i++) + { + if (entries[i] is KdlSkippedEntry) + { + hasSkipped = true; + break; + } + } + List filteredEntries; + if (hasSkipped) + { + filteredEntries = new List(entries.Count); + for (int i = 0; i < entries.Count; i++) + { + if (entries[i] is not KdlSkippedEntry) + filteredEntries.Add(entries[i]); + } + } + else + { + filteredEntries = new List(entries.Count); + for (int i = 0; i < entries.Count; i++) + filteredEntries.Add(entries[i]); + } + return new KdlNode(result.Item3) + { + Entries = filteredEntries, + Children = result.Item5.FirstOrDefault(b => b != null), + TerminatedBySemicolon = false, + TypeAnnotation = result.Item2.HasValue ? result.Item2.Value.Value : null, + }; + }) + .Debug("BaseNode"); + + Node = baseNode + .And(nodeTerminator) + .Then(x => + x.Item1 is null + ? null + : x.Item1 with + { + TerminatedBySemicolon = x.Item2.Span.Contains(';'), + } + ) + .Debug("Node"); + + (FinalNode as Deferred)!.Parser = baseNode.AndSkip(nodeTerminator.Optional()); + + (Nodes as Deferred>)!.Parser = ZeroOrMany( + ZeroOrMany(LineSpace).SkipAnd(Node) + ) + .AndSkip(ZeroOrMany(LineSpace)) + .Then(list => + { + List? filtered = null; + for (int i = 0; i < list.Count; i++) + { + var node = list[i]; + if (node is null) + { + if (filtered is null) + { + filtered = new List(list.Count); + for (int j = 0; j < i; j++) + { + if (list[j] is not null) + filtered.Add(list[j]!); + } + } + } + else + { + filtered?.Add(node); + } + } + return (IReadOnlyList)(filtered ?? list!); + }); + + Document = Literals + .Char('\uFEFF') + .Optional() + .SkipAnd(Nodes) + .AndSkip(Always().Eof()) + .ElseError( + "Unconsumed content at end of file. Syntax error likely occurred before this point." + ) + .Then(nodes => new KdlDocument + { + Nodes = nodes is List list ? list : [.. nodes], + }); + } + + private static bool IsBinaryChar(char c) => c == '0' || c == '1'; + + private static bool IsOctalChar(char c) => c >= '0' && c <= '7'; + + private static bool IsLoneSurrogate(char codePoint) => + codePoint >= 0xD800 && codePoint <= 0xDFFF; + + private static bool IsHexChar(char c) => + (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + + private static bool IsDigitChar(char c) => c >= '0' && c <= '9'; + + private static bool IsSigned(char c) => c == '+' || c == '-'; + + private static bool IsDisallowedLiteralCodePoint(char c) + { + return (c >= '\u0000' && c <= '\u0008') + || IsLoneSurrogate(c) + || (c >= '\u200E' && c <= '\u200F') + || (c >= '\u202A' && c <= '\u202E') + || (c >= '\u2066' && c <= '\u2069') + || c == '\uFEFF'; + } + + /// + /// Checks if a span matches a reserved keyword without allocating a string. + /// + private static bool IsReservedKeyword(ReadOnlySpan span) + { + return span switch + { + "inf" => true, + "-inf" => true, + "nan" => true, + "true" => true, + "false" => true, + "null" => true, + _ => false, + }; + } +} + +``` +// File: src\Kuddle.Net\Parser\MultiLineStringParser.cs`$langusing System; +using Kuddle.AST; +using Parlot; +using Parlot.Fluent; + +namespace Kuddle.Parser; + +public class MultiLineStringParser : Parser +{ + public override bool Parse(ParseContext context, ref ParseResult result) + { + var cursor = context.Scanner.Cursor; + + if (!cursor.Match("\"\"\"")) + return false; + + var startPos = cursor.Position; + + cursor.Advance(); + cursor.Advance(); + cursor.Advance(); + + bool hasNewline = false; + + if (cursor.Match("\r\n")) + { + cursor.Advance(); + cursor.Advance(); + hasNewline = true; + } + else if (cursor.Current == '\n') + { + cursor.Advance(); + hasNewline = true; + } + + if (!hasNewline) + { + throw new ParseException( + "Multi-line strings must start with a newline immediately after the opening \"\"\".", + cursor.Position + ); + } + + var searchSpan = context.Scanner.Buffer.AsSpan(cursor.Position.Offset); + + int searchOffset = 0; + + while (true) + { + int relativeIndex = searchSpan.Slice(searchOffset).IndexOf("\"\"\""); + + if (relativeIndex < 0) + { + throw new ParseException("Unterminated multi-line string.", startPos); + } + + int matchIndex = searchOffset + relativeIndex; + + int backslashCount = 0; + int backScan = matchIndex - 1; + + while (backScan >= 0 && searchSpan[backScan] == '\\') + { + backslashCount++; + backScan--; + } + + if (backslashCount % 2 == 0) + { + var contentSpan = searchSpan.Slice(0, matchIndex); + + int charsToAdvance = matchIndex + 3; + for (int i = 0; i < charsToAdvance; i++) + cursor.Advance(); + + KdlString kdlString = ProcessMultiLineString(contentSpan, context); + + result.Set(startPos.Offset, cursor.Position.Offset - startPos.Offset, kdlString); + return true; + } + + searchOffset = matchIndex + 1; + } + } + + private static KdlString ProcessMultiLineString( + ReadOnlySpan rawInput, + ParseContext context + ) + { + if (rawInput.IsEmpty) + return new KdlString(string.Empty, StringKind.MultiLine); + + bool hasCR = rawInput.Contains('\r'); + bool hasBackslash = rawInput.Contains('\\'); + + ReadOnlySpan normalized; + string? workingString = null; + + if (hasCR) + { + workingString = NormalizeNewlines(rawInput); + normalized = workingString.AsSpan(); + } + else + { + normalized = rawInput; + } + + if (hasBackslash) + { + workingString = ResolveWsEscapes(workingString ?? normalized.ToString()); + normalized = workingString.AsSpan(); + } + + int lastNewLine = normalized.LastIndexOf('\n'); + + ReadOnlySpan prefix; + ReadOnlySpan contentBody; + + if (lastNewLine >= 0) + { + prefix = normalized.Slice(lastNewLine + 1); + contentBody = normalized.Slice(0, lastNewLine + 1); + } + else + { + prefix = normalized; + contentBody = ReadOnlySpan.Empty; + } + + foreach (char c in prefix) + { + if (!CharacterSets.IsWhiteSpace(c)) + { + throw new ParseException( + "Multi-line string closing delimiter must be on its own line.", + TextPosition.Start + ); + } + } + + string dedented = BuildDedentedString(contentBody, prefix, context); + + string finalValue = hasBackslash ? UnescapeStandardKdl(dedented) : dedented; + + return new KdlString(finalValue, StringKind.MultiLine); + } + + private static string NormalizeNewlines(ReadOnlySpan input) + { + int outputLength = 0; + for (int i = 0; i < input.Length; i++) + { + if (input[i] == '\r') + { + outputLength++; + if (i + 1 < input.Length && input[i + 1] == '\n') + i++; + } + else + { + outputLength++; + } + } + + return string.Create( + outputLength, + input.ToString(), + static (span, inputStr) => + { + int writePos = 0; + for (int i = 0; i < inputStr.Length; i++) + { + if (inputStr[i] == '\r') + { + span[writePos++] = '\n'; + if (i + 1 < inputStr.Length && inputStr[i + 1] == '\n') + i++; + } + else + { + span[writePos++] = inputStr[i]; + } + } + } + ); + } + + private static string BuildDedentedString( + ReadOnlySpan contentBody, + ReadOnlySpan prefix, + ParseContext context + ) + { + if (contentBody.IsEmpty) + return string.Empty; + + int startPos = 0; + if (contentBody[0] == '\n') + startPos = 1; + + if (prefix.IsEmpty) + { + var body = contentBody.Slice(startPos); + if (body.Length > 0 && body[^1] == '\n') + body = body.Slice(0, body.Length - 1); + return body.ToString(); + } + + int outputLength = 0; + int pos = startPos; + + while (pos < contentBody.Length) + { + int nextNewLine = contentBody.Slice(pos).IndexOf('\n'); + if (nextNewLine < 0) + break; + + nextNewLine += pos; + int lineLength = nextNewLine + 1 - pos; + var line = contentBody.Slice(pos, lineLength); + + bool isWhitespaceOnly = IsWhitespaceOnlyLine(line); + + if (isWhitespaceOnly) + { + outputLength++; + } + else + { + if (!line.StartsWith(prefix)) + { + throw new ParseException( + "Multi-line string indentation mismatch.", + context.Scanner.Cursor.Position + ); + } + outputLength += line.Length - prefix.Length; + } + + pos = nextNewLine + 1; + } + + if (outputLength > 0) + outputLength--; + + if (outputLength <= 0) + return string.Empty; + + string prefixStr = prefix.ToString(); + string contentStr = contentBody.ToString(); + + return string.Create( + outputLength, + (contentStr, startPos, prefixStr), + static (span, state) => + { + var (content, startIdx, prefixS) = state; + int writePos = 0; + int pos = startIdx; + + while (pos < content.Length && writePos < span.Length) + { + int nextNewLine = content.IndexOf('\n', pos); + if (nextNewLine < 0) + break; + + int lineLength = nextNewLine + 1 - pos; + var line = content.AsSpan(pos, lineLength); + + bool isWhitespaceOnly = true; + for (int i = 0; i < line.Length - 1; i++) + { + if (!CharacterSets.IsWhiteSpace(line[i])) + { + isWhitespaceOnly = false; + break; + } + } + + if (isWhitespaceOnly) + { + if (writePos < span.Length) + span[writePos++] = '\n'; + } + else + { + var dedentedLine = line.Slice(prefixS.Length); + int copyLen = Math.Min(dedentedLine.Length, span.Length - writePos); + dedentedLine.Slice(0, copyLen).CopyTo(span.Slice(writePos)); + writePos += copyLen; + } + + pos = nextNewLine + 1; + } + } + ); + } + + private static bool IsWhitespaceOnlyLine(ReadOnlySpan line) + { + for (int i = 0; i < line.Length - 1; i++) + { + if (!CharacterSets.IsWhiteSpace(line[i])) + return false; + } + return true; + } + + private static string ResolveWsEscapes(string input) + { + int backslashIdx = input.IndexOf('\\'); + if (backslashIdx == -1) + return input; + + bool hasWsEscape = false; + for (int i = backslashIdx; i < input.Length - 1; i++) + { + if (input[i] == '\\') + { + char next = input[i + 1]; + if (next == ' ' || next == '\t' || next == '\n') + { + hasWsEscape = true; + break; + } + } + } + + if (!hasWsEscape) + return input; + + int outputLength = 0; + for (int i = 0; i < input.Length; i++) + { + if (input[i] == '\\') + { + int scanIdx = i + 1; + + while (scanIdx < input.Length && (input[scanIdx] == ' ' || input[scanIdx] == '\t')) + scanIdx++; + + if (scanIdx < input.Length && input[scanIdx] == '\n') + { + scanIdx++; + + while ( + scanIdx < input.Length && (input[scanIdx] == ' ' || input[scanIdx] == '\t') + ) + scanIdx++; + + i = scanIdx - 1; + continue; + } + else if (scanIdx >= input.Length && scanIdx > i + 1) + { + i = scanIdx - 1; + continue; + } + } + outputLength++; + } + + return string.Create( + outputLength, + input, + static (span, inp) => + { + int writePos = 0; + for (int i = 0; i < inp.Length; i++) + { + if (inp[i] == '\\') + { + int scanIdx = i + 1; + + while ( + scanIdx < inp.Length && (inp[scanIdx] == ' ' || inp[scanIdx] == '\t') + ) + scanIdx++; + + if (scanIdx < inp.Length && inp[scanIdx] == '\n') + { + scanIdx++; + while ( + scanIdx < inp.Length + && (inp[scanIdx] == ' ' || inp[scanIdx] == '\t') + ) + scanIdx++; + + i = scanIdx - 1; + continue; + } + else if (scanIdx >= inp.Length && scanIdx > i + 1) + { + i = scanIdx - 1; + continue; + } + } + span[writePos++] = inp[i]; + } + } + ); + } + + private static string UnescapeStandardKdl(string input) + { + int backslashIdx = input.IndexOf('\\'); + if (backslashIdx == -1) + return input; + + int outputLength = 0; + for (int i = 0; i < input.Length; i++) + { + if (input[i] == '\\' && i + 1 < input.Length) + { + char next = input[i + 1]; + switch (next) + { + case 'n': + case 'r': + case 't': + case '\\': + case '"': + case 'b': + case 'f': + case '/': + case 's': + outputLength++; + i++; + break; + case ' ': + i++; + break; + case 'u': + if (i + 2 < input.Length && input[i + 2] == '{') + { + int endBrace = input.IndexOf('}', i + 3); + if (endBrace > i + 2) + { + string hex = input.Substring(i + 3, endBrace - (i + 3)); + try + { + int codePoint = Convert.ToInt32(hex, 16); + outputLength += char.ConvertFromUtf32(codePoint).Length; + i = endBrace; + } + catch + { + outputLength++; + } + } + else + { + outputLength++; + } + } + else + { + outputLength++; + } + break; + default: + outputLength++; + break; + } + } + else + { + outputLength++; + } + } + + return string.Create( + outputLength, + input, + static (span, inp) => + { + int writePos = 0; + for (int i = 0; i < inp.Length; i++) + { + if (inp[i] == '\\' && i + 1 < inp.Length) + { + char next = inp[i + 1]; + switch (next) + { + case 'n': + span[writePos++] = '\n'; + i++; + break; + case 'r': + span[writePos++] = '\r'; + i++; + break; + case 't': + span[writePos++] = '\t'; + i++; + break; + case '\\': + span[writePos++] = '\\'; + i++; + break; + case '"': + span[writePos++] = '"'; + i++; + break; + case 'b': + span[writePos++] = '\b'; + i++; + break; + case 'f': + span[writePos++] = '\f'; + i++; + break; + case '/': + span[writePos++] = '/'; + i++; + break; + case 's': + span[writePos++] = ' '; + i++; + break; + case ' ': + i++; + break; + case 'u': + if (i + 2 < inp.Length && inp[i + 2] == '{') + { + int endBrace = inp.IndexOf('}', i + 3); + if (endBrace > i + 2) + { + string hex = inp.Substring(i + 3, endBrace - (i + 3)); + try + { + int codePoint = Convert.ToInt32(hex, 16); + string utf32 = char.ConvertFromUtf32(codePoint); + utf32.AsSpan().CopyTo(span.Slice(writePos)); + writePos += utf32.Length; + i = endBrace; + } + catch + { + span[writePos++] = inp[i]; + } + } + else + { + span[writePos++] = inp[i]; + } + } + else + { + span[writePos++] = inp[i]; + } + break; + default: + span[writePos++] = inp[i]; + break; + } + } + else + { + span[writePos++] = inp[i]; + } + } + } + ); + } +} + +``` +// File: src\Kuddle.Net\Parser\RawStringParser.cs`$langusing System; +using Kuddle.AST; +using Parlot; +using Parlot.Fluent; + +namespace Kuddle.Parser; + +sealed class RawStringParser : Parser +{ + public override bool Parse(ParseContext context, ref ParseResult result) + { + var cursor = context.Scanner.Cursor; + var bufferSpan = context.Scanner.Buffer.AsSpan(); + + if (cursor.Current != '#') + return false; + + var startPos = cursor.Position; + int currentOffset = startPos.Offset; + + int hashCount = 0; + while (currentOffset < bufferSpan.Length && bufferSpan[currentOffset] == '#') + { + hashCount++; + currentOffset++; + } + + int quoteCount = 0; + while (currentOffset < bufferSpan.Length && bufferSpan[currentOffset] == '"') + { + quoteCount++; + currentOffset++; + } + + if (quoteCount == 2) + { + quoteCount = 1; + currentOffset--; + } + + if (quoteCount != 1 && quoteCount != 3) + { + cursor.ResetPosition(startPos); + return false; + } + + bool isMultiline = quoteCount == 3; + + int needleLength = quoteCount + hashCount; + Span needle = + needleLength <= 256 ? stackalloc char[needleLength] : new char[needleLength]; + + needle.Slice(0, quoteCount).Fill('"'); + needle.Slice(quoteCount, hashCount).Fill('#'); + + var remainingBuffer = bufferSpan.Slice(currentOffset); + int matchIndex = remainingBuffer.IndexOf(needle); + + if (matchIndex < 0) + { + throw new ParseException( + $"Expected raw string to be terminated with sequence '{needle.ToString()}'", + startPos + ); + } + + var contentSpan = remainingBuffer.Slice(0, matchIndex); + int totalLengthParsed = (currentOffset - startPos.Offset) + matchIndex + needleLength; + + for (int i = 0; i < totalLengthParsed; i++) + cursor.Advance(); + + string content; + StringKind style; + + if (isMultiline) + { + content = ProcessMultiLineRawString(contentSpan); + style = StringKind.MultiLine | StringKind.Raw; + } + else + { + content = contentSpan.ToString(); + style = StringKind.Quoted | StringKind.Raw; + } + + result.Set(startPos.Offset, totalLengthParsed, new KdlString(content, style)); + return true; + } + + /// + /// Processes a multi-line raw string, handling newline normalization and dedentation. + /// Works directly with spans to minimize allocations. + /// + public static string ProcessMultiLineRawString(ReadOnlySpan rawInput) + { + if (rawInput.IsEmpty) + return string.Empty; + + bool hasCR = rawInput.Contains('\r'); + + ReadOnlySpan normalized; + string? normalizedString = null; + + if (hasCR) + { + normalizedString = NormalizeNewlines(rawInput); + normalized = normalizedString.AsSpan(); + } + else + { + normalized = rawInput; + } + + int lastNewLine = normalized.LastIndexOf('\n'); + + ReadOnlySpan prefix; + ReadOnlySpan contentBody; + + if (lastNewLine >= 0) + { + prefix = normalized.Slice(lastNewLine + 1); + contentBody = normalized.Slice(0, lastNewLine + 1); + } + else + { + prefix = normalized; + contentBody = ReadOnlySpan.Empty; + } + + foreach (char c in prefix) + { + if (c != ' ' && c != '\t') + { + throw new ParseException( + "Multi-line raw string closing delimiter must be on its own line, preceded only by whitespace.", + TextPosition.Start + ); + } + } + + if (contentBody.IsEmpty) + return string.Empty; + + int pos = 0; + if (contentBody[0] == '\n') + pos = 1; + + if (prefix.IsEmpty) + { + var body = contentBody.Slice(pos); + + if (body.Length > 0 && body[^1] == '\n') + body = body.Slice(0, body.Length - 1); + return body.ToString(); + } + + return BuildDedentedString(contentBody, pos, prefix); + } + + private static string NormalizeNewlines(ReadOnlySpan input) + { + int outputLength = 0; + for (int i = 0; i < input.Length; i++) + { + if (input[i] == '\r') + { + outputLength++; + if (i + 1 < input.Length && input[i + 1] == '\n') + i++; + } + else + { + outputLength++; + } + } + + return string.Create( + outputLength, + input.ToString(), + static (span, inputStr) => + { + int writePos = 0; + for (int i = 0; i < inputStr.Length; i++) + { + if (inputStr[i] == '\r') + { + span[writePos++] = '\n'; + if (i + 1 < inputStr.Length && inputStr[i + 1] == '\n') + i++; + } + else + { + span[writePos++] = inputStr[i]; + } + } + } + ); + } + + private static string BuildDedentedString( + ReadOnlySpan contentBody, + int startPos, + ReadOnlySpan prefix + ) + { + int outputLength = 0; + int pos = startPos; + + while (pos < contentBody.Length) + { + int nextNewLine = contentBody.Slice(pos).IndexOf('\n'); + if (nextNewLine < 0) + break; + + nextNewLine += pos; + int lineLength = nextNewLine + 1 - pos; + var line = contentBody.Slice(pos, lineLength); + + bool isWhitespaceOnly = IsWhitespaceOnlyLine(line); + + if (isWhitespaceOnly) + { + outputLength++; + } + else + { + if (!line.StartsWith(prefix)) + { + throw new ParseException( + "Multi-line string indentation error: Line does not match closing delimiter indentation.", + TextPosition.Start + ); + } + outputLength += line.Length - prefix.Length; + } + + pos = nextNewLine + 1; + } + + if (outputLength > 0) + outputLength--; + + if (outputLength <= 0) + return string.Empty; + + string prefixStr = prefix.ToString(); + string contentStr = contentBody.ToString(); + + return string.Create( + outputLength, + (contentStr, startPos, prefixStr), + static (span, state) => + { + var (content, startIdx, prefixS) = state; + int writePos = 0; + int pos = startIdx; + + while (pos < content.Length && writePos < span.Length) + { + int nextNewLine = content.IndexOf('\n', pos); + if (nextNewLine < 0) + break; + + int lineLength = nextNewLine + 1 - pos; + var line = content.AsSpan(pos, lineLength); + + bool isWhitespaceOnly = true; + for (int i = 0; i < line.Length - 1; i++) + { + if (!char.IsWhiteSpace(line[i])) + { + isWhitespaceOnly = false; + break; + } + } + + if (isWhitespaceOnly) + { + if (writePos < span.Length) + span[writePos++] = '\n'; + } + else + { + var dedentedLine = line.Slice(prefixS.Length); + int copyLen = Math.Min(dedentedLine.Length, span.Length - writePos); + dedentedLine.Slice(0, copyLen).CopyTo(span.Slice(writePos)); + writePos += copyLen; + } + + pos = nextNewLine + 1; + } + } + ); + } + + private static bool IsWhitespaceOnlyLine(ReadOnlySpan line) + { + for (int i = 0; i < line.Length - 1; i++) + { + if (!char.IsWhiteSpace(line[i])) + return false; + } + return true; + } +} + +``` +// File: src\Kuddle.Net\Serialization\Attributes\KdlArgumentAttribute.cs`$langusing System; + +namespace Kuddle.Serialization; + +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public sealed class KdlArgumentAttribute(int index) : KdlEntryAttribute +{ + public int Index { get; } = index; +} + +``` +// File: src\Kuddle.Net\Serialization\Attributes\KdlEntryAttribute.cs`$langusing System; + +namespace Kuddle.Serialization; + +/// +/// Base class for KDL entry attributes. Only one entry attribute can be applied per property. +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public abstract class KdlEntryAttribute(string? typeAnnotation = null) : Attribute +{ + /// + /// Optional KDL type annotation (e.g., "uuid", "date-time", "i32"). + /// + public string? TypeAnnotation { get; } = typeAnnotation; +} + +``` +// File: src\Kuddle.Net\Serialization\Attributes\KdlIgnoreAttribute.cs`$langusing System; + +namespace Kuddle.Serialization; + +[AttributeUsage(AttributeTargets.Property)] +public class KdlIgnoreAttribute : Attribute; + +``` +// File: src\Kuddle.Net\Serialization\Attributes\KdlNodeAttribute.cs`$langusing System; + +namespace Kuddle.Serialization; + +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public sealed class KdlNodeAttribute(string? name = null) : KdlEntryAttribute +{ + public string? Name { get; } = name; +} + +``` +// File: src\Kuddle.Net\Serialization\Attributes\KdlNodeCollectionAttribute.cs`$langusing System; + +namespace Kuddle.Serialization; + +/// +/// Maps a collection to a child node (container) that holds the items. +/// +/// The name of the wrapper/container node. +/// The node name for items inside the container. If null, uses the type's default. +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public class KdlNodeCollectionAttribute(string nodeName, string? elementName = null) + : KdlEntryAttribute +{ + public string NodeName { get; } = nodeName; + public string? ElementName { get; } = elementName; +} + +``` +// File: src\Kuddle.Net\Serialization\Attributes\KdlNodeDictionaryAttribute.cs`$langusing System; + +namespace Kuddle.Serialization; + +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public class KdlNodeDictionaryAttribute(string? name = null) : KdlEntryAttribute +{ + public string? Name { get; } = name; +} + +``` +// File: src\Kuddle.Net\Serialization\Attributes\KdlPropertyAttribute.cs`$langusing System; + +namespace Kuddle.Serialization; + +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public sealed class KdlPropertyAttribute(string? key = null) : KdlEntryAttribute +{ + public string? Key { get; } = key; +} + +``` +// File: src\Kuddle.Net\Serialization\Attributes\KdlTypeAttribute.cs`$langusing System; + +namespace Kuddle.Serialization; + +[AttributeUsage( + AttributeTargets.Class | AttributeTargets.Property, + AllowMultiple = false, + Inherited = false +)] +public sealed class KdlTypeAttribute(string name) : KdlEntryAttribute +{ + public string Name { get; set; } = name; +} + +``` +// File: src\Kuddle.Net\Serialization\KdlMemberInfo.cs`$langusing System; +using System.Reflection; + +namespace Kuddle.Serialization; + +/// +/// Represents a mapping from a C# property to a KDL entry (argument, property, or child node). +/// +internal sealed record KdlMemberInfo(PropertyInfo Property, Attribute? Attribute) +{ + public bool IsArgument => Attribute is KdlArgumentAttribute; + public bool IsProperty => Attribute is KdlPropertyAttribute; + public bool IsNode => Attribute is KdlNodeAttribute; + public bool IsWrappedCollection => Attribute is KdlNodeCollectionAttribute; + public string? CollectionElementName => (Attribute as KdlNodeCollectionAttribute)?.ElementName; + public bool IsNodeDictionary => Attribute is KdlNodeDictionaryAttribute; + + //TODO: Add support for property dictionaries + public bool IsPropertyDictionary => false; + + //TODO: Add support for keyed node collections + public bool IsKeyedNodeCollection => false; + + public int ArgumentIndex => Attribute is KdlArgumentAttribute arg ? arg.Index : -1; + + public string Name => + Attribute switch + { + KdlPropertyAttribute p => p.Key ?? Property.Name.ToLowerInvariant(), + KdlNodeAttribute n => n.Name ?? Property.Name.ToLowerInvariant(), + KdlNodeDictionaryAttribute nd => nd.Name ?? Property.Name.ToLowerInvariant(), + KdlNodeCollectionAttribute nc => nc.NodeName ?? Property.Name.ToLowerInvariant(), + _ => Property.Name.ToLowerInvariant(), + }; + + public string? TypeAnnotation => null; +} + +``` +// File: src\Kuddle.Net\Serialization\KdlReader.cs`$langusing System; +using Kuddle.AST; +using Kuddle.Exceptions; +using Kuddle.Parser; +using Kuddle.Validation; +using Parlot.Fluent; + +namespace Kuddle.Serialization; + +public static class KdlReader +{ + private static readonly Parser _parser = KdlGrammar.Document.Compile(); + + /// + /// Parses a KDL string into a KdlDocument AST. + /// + /// + /// + /// + /// + public static KdlDocument Read(string text, KdlReaderOptions? options = null) + { + ArgumentNullException.ThrowIfNull(text); + options ??= KdlReaderOptions.Default; + + if (!_parser.TryParse(text, out var doc, out var error)) + { + if (error != null) + { + throw new KuddleParseException( + error.Message, + error.Position.Column, + error.Position.Line, + error.Position.Offset + ); + } + throw new KuddleParseException("Parsing failed unexpectedly."); + } + + if (options.ValidateReservedTypes) + { + KdlReservedTypeValidator.Validate(doc); + } + + return doc; + } +} + +``` +// File: src\Kuddle.Net\Serialization\KdlReaderOptions.cs`$langnamespace Kuddle.Serialization; + +public record KdlReaderOptions +{ + public static KdlReaderOptions Default => new() { ValidateReservedTypes = true }; + public bool ValidateReservedTypes { get; init; } = true; +} + +``` +// File: src\Kuddle.Net\Serialization\KdlSerializer.cs`$langusing System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading; +using Kuddle.AST; +using Kuddle.Extensions; + +namespace Kuddle.Serialization; + +/// +/// Serializes and deserializes C# objects to/from KDL format. +/// +public static class KdlSerializer +{ + #region Deserialization + + /// + /// Deserializes a KDL document containing multiple nodes of type T. + /// + public static IEnumerable DeserializeMany( + string text, + KdlSerializerOptions? options = null, + CancellationToken cancellationToken = default + ) + where T : new() + { + var doc = KdlReader.Read(text); + var metadata = KdlTypeInfo.For(); + + foreach (var node in doc.Nodes) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return ObjectDeserializer.DeserializeNode(node, options); + } + } + + /// + /// Deserializes a KDL document to a single object of type T. + /// + public static T Deserialize(string text, KdlSerializerOptions? options = null) + where T : new() + { + var doc = KdlReader.Read(text); + + return ObjectDeserializer.DeserializeDocument(doc, options); + } + + #endregion + + #region Serialization + + /// + /// Serializes an object to a KDL string. + /// + public static string Serialize(T instance, KdlSerializerOptions? options = null) + { + var doc = ObjectSerializer.SerializeDocument(instance, options); + return KdlWriter.Write(doc); + } + + /// + /// Serializes multiple objects to a KDL string. + /// + public static string SerializeMany( + IEnumerable items, + KdlSerializerOptions? options = null, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(items); + + var doc = new KdlDocument(); + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + if (item is null) + continue; + + var node = ObjectSerializer.SerializeNode(item, options); + doc.Nodes.Add(node); + } + + return KdlWriter.Write(doc); + } + + #endregion + + #region Helpers + + + #endregion +} + +``` +// File: src\Kuddle.Net\Serialization\KdlSerializerOptions.cs`$langnamespace Kuddle.Serialization; + +/// +/// Options for KDL serialization and deserialization. +/// +public record KdlSerializerOptions +{ + /// + /// Whether to ignore null values when serializing. Default is true. + /// + public bool IgnoreNullValues { get; init; } = true; + + /// + /// Whether property/node name comparison is case-insensitive. Default is true. + /// + public bool CaseInsensitiveNames { get; init; } = true; + + /// + /// Whether to include type annotations in output. Default is true. + /// + public bool WriteTypeAnnotations { get; init; } = true; + + /// + /// Default options instance. + /// + public static KdlSerializerOptions Default { get; } = new(); +} + +``` +// File: src\Kuddle.Net\Serialization\KdlTypeInfo.cs`$langusing System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography.X509Certificates; +using Kuddle.Exceptions; +using Kuddle.Extensions; + +namespace Kuddle.Serialization; + +/// +/// Cached metadata about how a CLR type maps to/from KDL nodes. +/// +/// +[DebuggerDisplay("Type = {Type.Name,nq}")] +internal sealed class KdlTypeInfo +{ + private static readonly ConcurrentDictionary s_cache = new(); + + public Type Type { get; } + public string NodeName { get; } + + public DictionaryInfo? DictionaryDef { get; } + public Type? CollectionElementType { get; } + + /// + /// A strict node cannot be a document node. + /// Document nodes cannot have args or props + /// + public bool IsStrictNode => + ArgumentAttributes.Count > 0 + || Properties.Count > 0 + || Type.GetCustomAttribute() != null; + + /// Properties mapped to KDL arguments, sorted by index. + public IReadOnlyList ArgumentAttributes { get; } + + /// Properties mapped to KDL properties. + public IReadOnlyList Properties { get; } + + /// Properties mapped to child nodes. + public IReadOnlyList Children { get; } + public IReadOnlyList Collections { get; } + public IReadOnlyList Dictionaries { get; } + + private KdlTypeInfo(Type type) + { + Type = type; + + var kdlTypeAttr = type.GetCustomAttribute(); + NodeName = kdlTypeAttr?.Name ?? type.Name.ToLowerInvariant(); + + DictionaryDef = type.GetDictionaryInfo(); + CollectionElementType = type.GetCollectionInfo(); + var allMappings = new List(); + + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if ( + !prop.CanWrite + || prop.GetCustomAttribute() != null + || prop.GetIndexParameters().Length > 0 // Ignore indexers + ) + continue; + + var attrs = prop.GetCustomAttributes() + .Where(a => a is KdlEntryAttribute || a is KdlNodeDictionaryAttribute) + .ToList(); + + if (attrs.Count > 1) + throw new KdlConfigurationException( + $"Property '{type.Name}.{prop.Name}' has multiple KDL attributes. Only one mapping is allowed per property." + ); + + if (attrs.Count == 0 && IsSystemCollectionProperty(prop)) + continue; + Attribute mappingAttr = attrs.Count == 1 ? attrs[0] : InferAttribute(prop); + allMappings.Add(new KdlMemberInfo(prop, mappingAttr)); + } + + var args = allMappings.Where(m => m.IsArgument).OrderBy(m => m.ArgumentIndex).ToList(); + for (int i = 0; i < args.Count; i++) + { + if (args[i].ArgumentIndex != i) + throw new KdlConfigurationException( + $"Property '{type.Name}.{args[i].Property.Name}' declares index {args[i].ArgumentIndex}, but index {i} is missing. Arguments must be contiguous starting at 0." + ); + } + + ArgumentAttributes = args; + Properties = allMappings.Where(m => m.IsProperty).ToList(); + Children = allMappings.Where(m => m.IsNode).ToList(); + Collections = allMappings.Where(m => m.IsWrappedCollection).ToList(); + Dictionaries = allMappings + .Where(m => + m.IsNodeDictionary /* TODO: Add support for IsPropertyDictionary and IsKeyedNodeCollection */ + ) + .ToList(); + } + + private static bool IsSystemCollectionProperty(PropertyInfo prop) => + prop.DeclaringType != null + && prop.DeclaringType.Namespace != null + && prop.DeclaringType.Namespace.StartsWith("system"); + + /// + /// Gets or creates cached metadata for a type. + /// + public static KdlTypeInfo For(Type type) => s_cache.GetOrAdd(type, t => new KdlTypeInfo(t)); + + /// + /// Gets or creates cached metadata for a type. + /// + public static KdlTypeInfo For() => For(typeof(T)); + + /// + /// Checks if a type is a complex type (not primitive, not string, not interface/abstract). + /// + public bool IsComplexType => + !Type.IsValueType + && !Type.IsPrimitive + && Type != typeof(string) + && Type != typeof(object) + && !Type.IsInterface + && !Type.IsAbstract; + + /// + /// Checks if a type is enumerable (but not string or dictionary). + /// + public bool IsIEnumerable => + Type != typeof(string) && !IsDictionary && typeof(IEnumerable).IsAssignableFrom(Type); + + /// + /// Checks if a type is a dictionary. + /// + public bool IsDictionary => + Type.GetInterfaces() + .Any(i => + i.IsGenericType + && ( + i.GetGenericTypeDefinition() == typeof(IDictionary<,>) + || i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) + ) + ); + + private static Attribute InferAttribute(PropertyInfo prop) => + prop.PropertyType switch + { + { IsDictionary: true } => new KdlNodeDictionaryAttribute(prop.Name.ToKebabCase()), + { IsIEnumerable: true } => new KdlNodeAttribute(prop.Name.ToKebabCase()), + { IsKdlScalar: true } => new KdlPropertyAttribute(prop.Name.ToKebabCase()), + _ => new KdlNodeAttribute(prop.Name.ToKebabCase()), + }; +} + +internal sealed record DictionaryInfo(Type KeyType, Type ValueType); + +``` +// File: src\Kuddle.Net\Serialization\KdlValueConverter.cs`$langusing System; +using Kuddle.AST; +using Kuddle.Extensions; +using Kuddle.Parser; + +namespace Kuddle.Serialization; + +/// +/// Provides unified conversion between CLR values and KDL values. +/// +internal static class KdlValueConverter +{ + /// + /// Attempts to convert a KDL value to a CLR type. + /// + public static bool TryFromKdl(KdlValue kdlValue, Type targetType, out object? value) + { + value = default; + + if (kdlValue is KdlNull) + { + if (!IsNullable(targetType)) + { + return false; + } + value = null; + return true; + } + + var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (underlying.IsEnum) + { + kdlValue.TryGetString(out var enumString); + bool success = Enum.TryParse(underlying, enumString, true, out var result); + value = result; + return success; + } + // String + if (underlying == typeof(string) && kdlValue.TryGetString(out var stringVal)) + { + value = stringVal; + return true; + } + + // Integers (signed) + if (underlying == typeof(int) && kdlValue.TryGetInt(out var intVal)) + { + value = intVal; + return true; + } + + if (underlying == typeof(long) && kdlValue.TryGetLong(out var longVal)) + { + value = longVal; + return true; + } + + if (underlying == typeof(short) && kdlValue.TryGetInt(out var shortVal)) + { + value = (short)shortVal; + return true; + } + + if (underlying == typeof(sbyte) && kdlValue.TryGetInt(out var sbyteVal)) + { + value = (sbyte)sbyteVal; + return true; + } + + // Integers (unsigned) + if (underlying == typeof(uint) && kdlValue.TryGetLong(out var uintVal)) + { + value = (uint)uintVal; + return true; + } + + if (underlying == typeof(ulong) && kdlValue.TryGetLong(out var ulongVal)) + { + value = (ulong)ulongVal; + return true; + } + + if (underlying == typeof(ushort) && kdlValue.TryGetInt(out var ushortVal)) + { + value = (ushort)ushortVal; + return true; + } + + if (underlying == typeof(byte) && kdlValue.TryGetInt(out var byteVal)) + { + value = (byte)byteVal; + return true; + } + + // Floating point + if (underlying == typeof(double) && kdlValue.TryGetDouble(out var doubleVal)) + { + value = doubleVal; + return true; + } + + if (underlying == typeof(decimal) && kdlValue.TryGetDecimal(out var decimalVal)) + { + value = decimalVal; + return true; + } + + if (underlying == typeof(float) && kdlValue.TryGetDouble(out var floatVal)) + { + value = (float)floatVal; + return true; + } + + // Boolean + if (underlying == typeof(bool) && kdlValue.TryGetBool(out var boolVal)) + { + value = boolVal; + return true; + } + + // Special types with type annotations + if (underlying == typeof(Guid) && kdlValue.TryGetUuid(out var uuid)) + { + value = uuid; + return true; + } + + if (underlying == typeof(DateTimeOffset) && kdlValue.TryGetDateTime(out var dto)) + { + value = dto; + return true; + } + + if (underlying == typeof(DateTime) && kdlValue.TryGetDateTime(out var dt)) + { + value = dt.DateTime; + return true; + } + + return false; + } + + /// + /// Attempts to convert a CLR value to a KDL value. + /// + public static bool TryToKdl(object? input, out KdlValue kdlValue, string? typeAnnotation = null) + { + if (input is null) + { + kdlValue = KdlValue.Null; + return true; + } + + kdlValue = input switch + { + string s => KdlValue.From(s), + int i => KdlValue.From(i), + long l => KdlValue.From(l), + short sh => KdlValue.From(sh), + sbyte sb => KdlValue.From(sb), + uint ui => KdlValue.From(ui), + ulong ul => KdlValue.From((long)ul), + ushort us => KdlValue.From(us), + byte by => KdlValue.From(by), + double d => KdlValue.From(d), + float f => KdlValue.From((double)f), + decimal m => KdlValue.From(m), + bool b => KdlValue.From(b), + Guid uuid => KdlValue.From(uuid), + DateTimeOffset dto => KdlValue.From(dto), + DateTime dt => KdlValue.From(new DateTimeOffset(dt)), + Enum e => KdlValue.From(e), + _ => null!, + }; + + if (kdlValue is not null && typeAnnotation is not null) + { + kdlValue = kdlValue with { TypeAnnotation = typeAnnotation }; + } + + return kdlValue is not null; + } + + /// + /// Converts a KDL value to a CLR type, throwing on failure. + /// + public static object FromKdlOrThrow( + KdlValue kdlValue, + Type targetType, + string context, + string? expectedTypeAnnotation = null + ) + { + var finalTargetType = + CharacterSets.GetClrType(expectedTypeAnnotation ?? kdlValue.TypeAnnotation) + ?? targetType; + + if (!TryFromKdl(kdlValue, finalTargetType, out var result)) + { + throw new KuddleSerializationException( + $"Cannot convert KDL value '{kdlValue}' to {targetType.Name}. {context}" + ); + } + return result ?? throw new Exception(); + } + + /// + /// Converts a CLR value to a KDL value, throwing on failure. + /// + public static KdlValue ToKdlOrThrow( + object? input, + string context, + string? typeAnnotation = null + ) + { + if (!TryToKdl(input, out var kdlValue, typeAnnotation)) + { + var typeName = input?.GetType().Name ?? "null"; + throw new KuddleSerializationException( + $"Cannot convert CLR value of type '{typeName}' to KDL. {context}" + ); + } + return kdlValue; + } + + private static bool IsNullable(Type type) => + !type.IsValueType || Nullable.GetUnderlyingType(type) != null; +} + +``` +// File: src\Kuddle.Net\Serialization\KdlWriter.cs`$langusing System; +using System.Text; +using Kuddle.AST; +using Kuddle.Extensions; + +namespace Kuddle.Serialization; + +public class KdlWriter +{ + private readonly KdlWriterOptions _options; + private readonly StringBuilder _sb = new(); + private int _depth = 0; + + public KdlWriter(KdlWriterOptions? options = null) + { + _options ??= options ?? KdlWriterOptions.Default; + } + + public static string Write(KdlDocument document, KdlWriterOptions? options = null) + { + var writer = new KdlWriter(options); + writer.WriteDocument(document); + return writer._sb.ToString(); + } + + private void WriteDocument(KdlDocument document) + { + foreach (var node in document.Nodes) + { + WriteNode(node); + _sb.Append(_options.NewLine); + } + } + + private void WriteNode(KdlNode node) + { + if (node.TypeAnnotation != null) + { + _sb.Append('('); + WriteIdentifier(node.TypeAnnotation); + _sb.Append(')'); + } + + WriteString(node.Name); + + foreach (var entry in node.Entries) + { + _sb.Append(_options.SpaceAfterProp); + WriteEntry(entry); + } + + if (node.Children?.Nodes.Count > 0) + { + _sb.Append(" {"); + _sb.Append(_options.NewLine); + + _depth++; + foreach (var child in node.Children.Nodes) + { + WriteIndent(); + WriteNode(child); + _sb.Append(_options.NewLine); + } + _depth--; + + WriteIndent(); + _sb.Append('}'); + } + + if (_options.RoundTrip && node.TerminatedBySemicolon) + { + _sb.Append(';'); + } + } + + private void WriteEntry(KdlEntry entry) + { + if (entry is KdlArgument arg) + { + WriteValue(arg.Value); + } + else if (entry is KdlProperty prop) + { + WriteString(prop.Key); + _sb.Append('='); + WriteValue(prop.Value); + } + } + + private void WriteValue(KdlValue value) + { + if (value.TypeAnnotation != null) + { + _sb.Append('('); + WriteIdentifier(value.TypeAnnotation); + _sb.Append(')'); + } + + switch (value) + { + case KdlNumber n: + _sb.Append(n.ToCanonicalString()); + break; + case KdlBool b: + _sb.Append(b.Value ? "#true" : "#false"); + break; + case KdlNull: + _sb.Append("#null"); + break; + case KdlString s: + WriteString(s); + break; + } + } + + private void WriteIdentifier(string value) + { + if (IsValidBareIdentifier(value)) + { + _sb.Append(value); + } + else + { + WriteQuotedString(value); + } + } + + private void WriteString(KdlString s) + { + StringKind kind; + + if (_options.RoundTrip) + { + kind = s.Kind; + + if (kind == StringKind.Bare && !IsValidBareIdentifier(s.Value)) + { + kind = StringKind.Quoted; + } + } + else + { + kind = + IsValidBareIdentifier(s.Value) || s.Kind == StringKind.Bare + ? StringKind.Bare + : StringKind.Quoted; + } + + switch (kind) + { + case StringKind.Bare: + _sb.Append(s.Value); + return; + case StringKind.Quoted: + _sb.Append('"'); + _sb.Append(EscapeString(s.Value)); + _sb.Append('"'); + return; + } + + bool isRaw = s.Kind.HasFlag(StringKind.Raw); + bool isMulti = s.Kind.HasFlag(StringKind.MultiLine); + + if (isRaw) + { + int hashCount = s.Value.AsSpan().MaxConsecutive('#') + 1; + string hashes = new('#', hashCount); + + string quotes = isMulti ? new string('\"', 3) : new string('\"', 1); + + _sb.Append(hashes).Append(quotes); + + if (isMulti) + _sb.Append('\n'); + + _sb.Append(s.Value); + + if (isMulti) + _sb.Append('\n'); + + _sb.Append(quotes).Append(hashes); + } + else + { + if (isMulti) + { + _sb.Append(new string('\"', 3)); + _sb.Append(s.Value); + _sb.Append(new string('\"', 3)); + } + else + { + _sb.Append(new string('\"', 1)); + _sb.Append(EscapeString(s.Value)); + _sb.Append(new string('\"', 1)); + } + } + } + + private static string EscapeString(string input) + { + if (string.IsNullOrEmpty(input)) + return ""; + + var sb = new StringBuilder(input.Length + 2); + + foreach (char c in input) + { + switch (c) + { + case '\\': + sb.Append("\\\\"); + break; + case '"': + sb.Append("\\\""); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + case '\b': + sb.Append("\\b"); + break; + case '\f': + sb.Append("\\f"); + break; + default: + if (char.IsControl(c)) + { + sb.Append($"\\u{(int)c:X4}"); + } + else + { + sb.Append(c); + } + break; + } + } + return sb.ToString(); + } + + private void WriteQuotedString(string val) + { + _sb.Append('"'); + foreach (char c in val) + { + switch (c) + { + case '\\': + _sb.Append("\\\\"); + break; + case '"': + _sb.Append("\\\""); + break; + case '\b': + _sb.Append("\\b"); + break; + case '\f': + _sb.Append("\\f"); + break; + case '\n': + _sb.Append("\\n"); + break; + case '\r': + _sb.Append("\\r"); + break; + case '\t': + _sb.Append("\\t"); + break; + default: + if (char.IsControl(c) || (_options.EscapeUnicode && c > 127)) + { + _sb.Append($"\\u{(int)c:X4}"); + } + else + { + _sb.Append(c); + } + break; + } + } + _sb.Append('"'); + } + + private void WriteIndent() + { + for (int i = 0; i < _depth; i++) + _sb.Append(_options.IndentChar); + } + + private static bool IsValidBareIdentifier(string id) + { + if (string.IsNullOrEmpty(id)) + return false; + if (id == "true" || id == "false" || id == "null") + return false; + if (char.IsDigit(id[0])) + return false; + + foreach (char c in id) + { + if (char.IsWhiteSpace(c) || "()[]{}/\\\"#;=".Contains(c)) + return false; + } + return true; + } +} + +``` +// File: src\Kuddle.Net\Serialization\KdlWriterOptions.cs`$langnamespace Kuddle.Serialization; + +public record KdlWriterOptions +{ + public static KdlWriterOptions Default => new(); + + public string IndentChar { get; init; } = " "; + public string NewLine { get; init; } = "\n"; + public string SpaceAfterProp { get; init; } = " "; + public bool EscapeUnicode { get; init; } = false; + public bool RoundTrip { get; set; } = true; +} + +``` +// File: src\Kuddle.Net\Serialization\ObjectDeserializer.cs`$langusing System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Kuddle.AST; +using Kuddle.Extensions; + +namespace Kuddle.Serialization; + +internal class ObjectDeserializer +{ + private const StringComparison NodeNameComparison = StringComparison.OrdinalIgnoreCase; + + private readonly KdlSerializerOptions _options; + + public ObjectDeserializer(KdlSerializerOptions? options = null) + { + _options = options ?? KdlSerializerOptions.Default; + } + + internal static T DeserializeDocument(KdlDocument doc, KdlSerializerOptions? options) + where T : new() + { + var worker = new ObjectDeserializer(options); + return worker.DeserializeRoot(doc); + } + + internal T DeserializeRoot(KdlDocument doc) + where T : new() + { + var metadata = KdlTypeInfo.For(); + + if (metadata.IsIEnumerable) + { + throw new KuddleSerializationException( + $"Cannot deserialize to collection type '{metadata.Type.Name}'. Use DeserializeMany() instead." + ); + } + + if (metadata.IsStrictNode) + { + if (doc.Nodes.Count > 1) + throw new KuddleSerializationException( + $"Expected exactly 1 root node, found {doc.Nodes.Count}." + ); + + var rootNode = doc.Nodes[0]; + ValidateNodeName(rootNode, metadata.NodeName); + + var instance = new T(); + MapNodeToObject(rootNode, instance, metadata); + return instance; + } + else + { + // Document Mode: Root properties and children + var instance = new T(); + MapChildNodes(doc.Nodes, instance, metadata); + MapDictionaries(doc.Nodes, null, instance, metadata); + return instance; + } + } + + internal static T DeserializeNode(KdlNode node, KdlSerializerOptions? options) + where T : new() + { + var worker = new ObjectDeserializer(options); + var metadata = KdlTypeInfo.For(); + ValidateNodeName(node, metadata.NodeName); + + var instance = new T(); + worker.MapNodeToObject(node, instance, metadata); + return instance; + } + + private object DeserializeObject(KdlNode node, Type type) + { + var instance = + Activator.CreateInstance(type) + ?? throw new KuddleSerializationException( + $"Failed to create instance of '{type.Name}'." + ); + + var metadata = KdlTypeInfo.For(type); + MapNodeToObject(node, instance, metadata); + return instance; + } + + /// + /// Maps a KDL node's entries and children to an object instance. + /// + private void MapNodeToObject(KdlNode node, object instance, KdlTypeInfo metadata) + { + foreach (var mapping in metadata.ArgumentAttributes) + { + var argValue = + node.Arg(mapping.ArgumentIndex) + ?? throw new KuddleSerializationException( + $"Missing required argument at index {mapping.ArgumentIndex}." + ); + SetPropertyValue(mapping.Property, instance, argValue, mapping.TypeAnnotation); + } + + foreach (var mapping in metadata.Properties) + { + var kdlValue = node.Prop(mapping.Name); + if (kdlValue != null) + { + SetPropertyValue(mapping.Property, instance, kdlValue, mapping.TypeAnnotation); + } + } + + MapChildNodes(node.Children?.Nodes, instance, metadata); + + MapDictionaries(node.Children?.Nodes, node, instance, metadata); + + MapWrappedCollections(node.Children?.Nodes, instance, metadata); + + if (metadata.IsDictionary && metadata.DictionaryDef != null) + { + var (keyType, valueType) = metadata.DictionaryDef; + PopulateNodeDictionary(node.Children?.Nodes, (IDictionary)instance, keyType, valueType); + } + } + + private void MapWrappedCollections(List? nodes, object instance, KdlTypeInfo metadata) + { + if (nodes is null || nodes.Count == 0) + return; + + foreach (var mapping in metadata.Collections) + { + var container = nodes.LastOrDefault(n => + n.Name.Value.Equals(mapping.Name, NodeNameComparison) + ); + + if (container?.Children?.Nodes is null) + continue; + + var itemType = mapping.Property.PropertyType.GetCollectionElementType(); + var itemInfo = KdlTypeInfo.For(itemType); + + var targetName = mapping.CollectionElementName ?? itemInfo.NodeName; + + var items = container + .Children.Nodes.Where(n => n.Name.Value.Equals(targetName, NodeNameComparison)) + .ToList(); + + SetCollectionProperty(mapping.Property, instance, items); + } + } + + /// + /// Maps child KDL nodes to properties marked with [KdlNode]. + /// + private void MapChildNodes(IReadOnlyList? nodes, object instance, KdlTypeInfo metadata) + { + if (nodes is null || nodes.Count == 0) + return; + + foreach (var mapping in metadata.Children) + { + var matches = nodes + .Where(n => n.Name.Value.Equals(mapping.Name, NodeNameComparison)) + .ToList(); + + if (matches.Count == 0) + continue; + + var propType = mapping.Property.PropertyType; + var propMeta = KdlTypeInfo.For(propType); + + if (propMeta.IsIEnumerable) + { + SetCollectionProperty(mapping.Property, instance, matches); + } + else + { + if (matches.Count > 1) + throw new KuddleSerializationException( + $"Expected single node '{mapping.Name}', found {matches.Count}." + ); + + var node = matches[0]; + object value; + + if (propMeta.IsComplexType) + { + value = DeserializeObject(node, propType); + } + else + { + var arg = node.Arg(0); + if (arg is null) + continue; + value = KdlValueConverter.FromKdlOrThrow( + arg, + propType, + mapping.Name, + mapping.TypeAnnotation + ); + } + + mapping.Property.SetValue(instance, value); + } + } + } + + private void MapDictionaries( + List? nodes, + KdlNode? parentNode, + object instance, + KdlTypeInfo metadata + ) + { + if (metadata.Dictionaries.Count == 0) + return; + + foreach (var member in metadata.Dictionaries) + { + var propMeta = KdlTypeInfo.For(member.Property.PropertyType); + if (!propMeta.IsDictionary || propMeta.DictionaryDef is null) + continue; + + var (keyType, valueType) = propMeta.DictionaryDef; + var dict = GetOrCreateDictionary(instance, member); + if (dict is null) + continue; + + if (member.IsNodeDictionary && nodes != null) + { + var container = nodes.LastOrDefault(n => + n.Name.Value.Equals(member.Name, NodeNameComparison) + ); + if (container?.Children?.Nodes != null) + { + PopulateNodeDictionary(container.Children.Nodes, dict, keyType, valueType); + } + } + } + static IDictionary? GetOrCreateDictionary(object instance, KdlMemberInfo member) + { + var dict = (IDictionary?)member.Property.GetValue(instance); + if (dict is null) + { + dict = (IDictionary?)Activator.CreateInstance(member.Property.PropertyType); + member.Property.SetValue(instance, dict); + } + + return dict; + } + } + + private void PopulateNodeDictionary( + IEnumerable? children, + IDictionary dict, + Type keyType, + Type valueType + ) + { + if (children is null) + return; + + foreach (var child in children) + { + object key = Convert.ChangeType(child.Name.Value, keyType); + object value; + + if (!valueType.IsComplexType) + { + // Scalar: Arg 0 + var arg = child.Arg(0); + if (arg is null) + continue; + value = KdlValueConverter.FromKdlOrThrow(arg, valueType, $"Dictionary Value {key}"); + } + else + { + value = DeserializeObject(child, valueType); + } + + if (dict.Contains(key)) + throw new KuddleSerializationException( + $"Duplicate dictionary key detected: '{key}'" + ); + + dict.Add(key, value); + } + } + + private void SetPropertyValue( + PropertyInfo property, + object instance, + KdlValue kdlValue, + string? expectedTypeAnnotation = null + ) + { + var result = KdlValueConverter.FromKdlOrThrow( + kdlValue, + property.PropertyType, + $"Property: {property.DeclaringType?.Name}.{property.Name}", + expectedTypeAnnotation + ); + + property.SetValue(instance, result); + } + + private void SetCollectionProperty(PropertyInfo property, object instance, List nodes) + { + var elementType = property.PropertyType.GetCollectionElementType(); + + var listType = typeof(List<>).MakeGenericType(elementType); + var list = (IList)Activator.CreateInstance(listType)!; + + var elementMetadata = KdlTypeInfo.For(elementType); + + foreach (var node in nodes) + { + object element; + if (elementMetadata.IsComplexType) + { + element = DeserializeObject(node, elementType); + } + else + { + var arg = node.Arg(0); + if (arg is null) + continue; + element = KdlValueConverter.FromKdlOrThrow(arg, elementType, "List Element"); + } + list.Add(element); + } + + var finalValue = ConvertToTargetCollectionType(property.PropertyType, elementType, list); + property.SetValue(instance, finalValue); + } + + private static void ValidateNodeName(KdlNode node, string nodeName) + { + if (!node.Name.Value.Equals(nodeName, NodeNameComparison)) + { + throw new KuddleSerializationException( + $"Expected node '{nodeName}', found '{node.Name.Value}'." + ); + } + } + + private static object ConvertToTargetCollectionType( + Type targetType, + Type elementType, + IList list + ) + { + if (targetType.IsArray) + { + var array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + return array; + } + + if (targetType.IsAssignableFrom(list.GetType())) + { + return list; + } + + return list; + } +} + +``` +// File: src\Kuddle.Net\Serialization\ObjectSerializer.cs`$langusing System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Kuddle.AST; + +namespace Kuddle.Serialization; + +internal class ObjectSerializer +{ + private readonly KdlSerializerOptions _options; + + public ObjectSerializer(KdlSerializerOptions? options = null) + { + _options = options ?? KdlSerializerOptions.Default; + } + + internal static KdlDocument SerializeDocument(T? instance, KdlSerializerOptions? options) + { + ArgumentNullException.ThrowIfNull(instance); + + var type = typeof(T); + var worker = new ObjectSerializer(options); + var metadata = KdlTypeInfo.For(); + + if (!metadata.IsComplexType && !metadata.IsDictionary) + { + throw new KuddleSerializationException( + $"Cannot serialize primitive type '{type.Name}'. Only complex types are supported." + ); + } + var doc = new KdlDocument(); + + if (metadata.IsIEnumerable) + { + var nodes = worker.SerializeCollection((IEnumerable)instance); + doc.Nodes.AddRange(nodes); + } + else + { + var node = worker.SerializeToNode(instance); + doc.Nodes.Add(node); + } + + return doc; + } + + public static KdlNode SerializeNode(object instance, KdlSerializerOptions? options) + { + var worker = new ObjectSerializer(options); + return worker.SerializeToNode(instance); + } + + private IEnumerable SerializeCollection( + IEnumerable collection, + string? overrideNodeName = null + ) + { + foreach (var item in collection) + { + if (item is null) + continue; + + yield return SerializeToNode(item, overrideNodeName); + } + } + + private KdlNode SerializeToNode(object instance, string? overrideNodeName = null) + { + var metadata = KdlTypeInfo.For(instance.GetType()); + var nodeName = overrideNodeName ?? metadata.NodeName; + + var entries = new List(); + + // Serialize arguments (in order) + foreach (var mapping in metadata.ArgumentAttributes) + { + var value = mapping.Property.GetValue(instance); + var kdlValue = KdlValueConverter.ToKdlOrThrow( + value, + $"Argument property: {mapping.Property.Name}", + mapping.TypeAnnotation + ); + + entries.Add(new KdlArgument(kdlValue)); + } + + // Serialize properties + foreach (var mapping in metadata.Properties) + { + var value = mapping.Property.GetValue(instance); + var typeAnnotation = mapping.TypeAnnotation; + + if (!KdlValueConverter.TryToKdl(value, out var kdlValue, typeAnnotation)) + { + continue; // Skip properties that can't be converted + } + + entries.Add(new KdlProperty(KdlValue.From(mapping.Name), kdlValue)); + } + + foreach (var dictMap in metadata.Dictionaries.Where(m => m.IsPropertyDictionary)) + { + var dict = (IDictionary?)dictMap.Property.GetValue(instance); + if (dict is null) + continue; + + foreach (DictionaryEntry entry in dict) + { + var keyStr = entry.Key.ToString()!; + if (entry.Value != null && KdlValueConverter.TryToKdl(entry.Value, out var val)) + { + entries.Add(new KdlProperty(KdlValue.From(keyStr), val)); + } + } + } + // Serialize children + KdlBlock? childBlock = null; + + void AddChild(KdlNode child) + { + childBlock ??= new KdlBlock(); + childBlock.Nodes.Add(child); + } + + foreach (var mapping in metadata.Children) + { + var propValue = mapping.Property.GetValue(instance); + + if (propValue is null) + continue; + + var propType = mapping.Property.PropertyType; + var childMeta = KdlTypeInfo.For(propType); + + if (childMeta.IsIEnumerable) + { + var childNodes = SerializeCollection((IEnumerable)propValue, mapping.Name); + foreach (var child in childNodes) + { + AddChild(child); + } + } + else if (childMeta.IsComplexType) + { + AddChild(SerializeToNode(propValue, mapping.Name)); + } + else + { + var kdlValue = KdlValueConverter.ToKdlOrThrow( + propValue, + $"Child scalar property: {mapping.Property.Name}", + mapping.TypeAnnotation + ); + + var scalarNode = new KdlNode(KdlValue.From(mapping.Name)) + { + Entries = [new KdlArgument(kdlValue)], + }; + AddChild(scalarNode); + } + } + foreach (var dictMap in metadata.Dictionaries) + { + if (dictMap.IsPropertyDictionary) + continue; // Handled above + + var dict = (IDictionary?)dictMap.Property.GetValue(instance); + if (dict is null || dict.Count == 0) + continue; + + var (_, valueType) = KdlTypeInfo.For(dictMap.Property.PropertyType).DictionaryDef!; + + if (dictMap.IsNodeDictionary) + { + // Strategy: Create a container node, put entries inside + // themes { dark { ... } } + var container = new KdlNode(KdlValue.From(dictMap.Name)); + var nodes = new List(); + SerializeDictionaryEntries(nodes, dict, valueType); + + if (nodes.Count > 0) + { + container = container with { Children = new KdlBlock() { Nodes = nodes } }; + AddChild(container); + } + } + else if (dictMap.IsKeyedNodeCollection) + { + // Strategy: Flatten entries as children + // db "A" { ... } + // db "B" { ... } + foreach (var value in dict.Values) + { + if (value is null) + continue; + // For KeyedNodes, the Object contains the Key, so we just serialize the Object + // with the forced Node Name from the attribute. + AddChild(SerializeToNode(value, dictMap.Name)); + } + } + } + + foreach (var mapping in metadata.Collections) + { + var val = mapping.Property.GetValue(instance); + if (val is null) + continue; + + var collection = (IEnumerable)val; + + // 1. Create the Container Node + var container = new KdlNode(KdlValue.From(mapping.Name)); + var nodes = new List(); + // 2. Determine Item Name + var itemType = mapping.Property.PropertyType.GetCollectionElementType(); + var itemMeta = KdlTypeInfo.For(itemType); + var targetName = mapping.CollectionElementName ?? itemMeta.NodeName; + + // 3. Serialize Items as children of the Container + foreach (var item in collection) + { + if (item is null) + continue; + // Recursively serialize, forcing the name to "event" + nodes.Add(SerializeToNode(item, targetName)); + } + + // 4. Attach + if (nodes.Count > 0) + { + container = container with { Children = new KdlBlock() { Nodes = nodes } }; + AddChild(container); + } + } + + if ( + metadata.IsDictionary + && metadata.DictionaryDef != null + && instance is IDictionary selfDict + ) + { + childBlock ??= new KdlBlock(); + + SerializeDictionaryEntries( + childBlock.Nodes, + selfDict, + metadata.DictionaryDef.ValueType + ); + } + return new KdlNode(KdlValue.From(nodeName)) + { + Entries = entries, + Children = childBlock?.Nodes.Count > 0 ? childBlock : null, + }; + } + + /// + /// Helper to convert Dictionary entries into KDL Nodes. + /// Used by [KdlNodeDictionary] and Implicit Dictionary logic. + /// + private void SerializeDictionaryEntries( + List targetList, + IDictionary dict, + Type valueType + ) + { + var isScalar = valueType.IsKdlScalar; + + foreach (DictionaryEntry entry in dict) + { + var keyStr = entry.Key.ToString(); + if (string.IsNullOrEmpty(keyStr) || entry.Value is null) + continue; + + if (isScalar) + { + // Scalar: NodeName=Key, Arg0=Value + // e.g. timeout 5000 + if (KdlValueConverter.TryToKdl(entry.Value, out var kdlVal, null)) + { + targetList.Add( + new KdlNode(KdlValue.From(keyStr)) { Entries = [new KdlArgument(kdlVal)] } + ); + } + } + else + { + // Complex: NodeName=Key, Body=Object + // e.g. dark-mode { ... } + // We recurse SerializeToNode, but we override the name to be the Key + targetList.Add(SerializeToNode(entry.Value, overrideNodeName: keyStr)); + } + } + } +} + +``` +// File: src\Kuddle.Net\Validation\KuddleReservedTypeValidator.cs`$langusing System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Text.RegularExpressions; +using Kuddle.AST; +using Kuddle.Exceptions; +using Kuddle.Parser; + +namespace Kuddle.Validation; + +public static class KdlReservedTypeValidator +{ + public static void Validate(KdlDocument doc) + { + var errors = new List(); + + foreach (var node in doc.Nodes) + { + ValidateNode(node, errors); + } + + if (errors.Count > 0) + { + throw new KuddleValidationException(errors); + } + } + + private static void ValidateNode(KdlNode node, List errors) + { + foreach (var entry in node.Entries) + { + if (entry is KdlArgument arg) + { + ValidateValue(arg.Value, errors); + } + else if (entry is KdlProperty prop) + { + ValidateValue(prop.Value, errors); + } + } + + if (node.Children != null) + { + foreach (var child in node.Children.Nodes) + { + ValidateNode(child, errors); + } + } + } + + private static void ValidateValue(KdlValue val, List errors) + { + if (val.TypeAnnotation == null) + return; + if (!CharacterSets.ReservedTypes.Contains(val.TypeAnnotation)) + return; + + try + { + switch (val.TypeAnnotation) + { + // --- Integers --- + case "u8": + EnsureNumber(val).ToByte(); + break; + case "u16": + EnsureNumber(val).ToUInt16(); + break; + case "u32": + EnsureNumber(val).ToUInt32(); + break; + case "u64": + EnsureNumber(val).ToUInt64(); + break; + case "i8": + EnsureNumber(val).ToSByte(); + break; + case "i16": + EnsureNumber(val).ToInt16(); + break; + case "i32": + EnsureNumber(val).ToInt32(); + break; + case "i64": + EnsureNumber(val).ToInt64(); + break; + + // --- Floats --- + case "f32": + // ToFloat() handles the parsing. We just check if it throws. + EnsureNumber(val).ToFloat(); + break; + case "f64": + EnsureNumber(val).ToDouble(); + break; + + // --- Strings --- + case "uuid": + if (!Guid.TryParse(EnsureString(val), out _)) + throw new FormatException(); + break; + case "date-time": + if (!DateTimeOffset.TryParse(EnsureString(val), out _)) + throw new FormatException(); + break; + case "ipv4": + if ( + !IPAddress.TryParse(EnsureString(val), out var ip4) + || ip4.AddressFamily != AddressFamily.InterNetwork + ) + throw new FormatException(); + break; + case "ipv6": + if ( + !IPAddress.TryParse(EnsureString(val), out var ip6) + || ip6.AddressFamily != AddressFamily.InterNetworkV6 + ) + throw new FormatException(); + break; + case "url": + if (!Uri.TryCreate(EnsureString(val), UriKind.Absolute, out _)) + throw new FormatException(); + break; + case "base64": + Convert.FromBase64String(EnsureString(val)); + break; + case "regex": + try + { + _ = new Regex(EnsureString(val)); + } + catch + { + throw new FormatException(); + } + break; + } + } + catch (Exception ex) when (ex.Message.StartsWith("Expected a")) + { + errors.Add(new KuddleValidationError(ex.Message, val)); + } + catch (Exception) + { + errors.Add( + new KuddleValidationError( + $"Value '{val}' is not a valid '{val.TypeAnnotation}'.", + val + ) + ); + } + } + + private static KdlNumber EnsureNumber(KdlValue val) => + val is KdlNumber num + ? num + : throw new FormatException( + $"Expected a Number for type '{val.TypeAnnotation}', got {val.GetType().Name}" + ); + + private static string EnsureString(KdlValue val) => + val is KdlString str + ? str.Value + : throw new FormatException( + $"Expected a String for type '{val.TypeAnnotation}', got {val.GetType().Name}" + ); +} + +``` +// File: src\Kuddle.Net\GlobalSuppressions.cs`$lang// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage( + "Style", + "IDE0130:Namespace does not match folder structure", + Justification = "", + Scope = "namespace", + Target = "~N:Kuddle.Serialization" +)] + +``` +// File: src\Kuddle.Net.Benchmarks\Program.cs`$langusing BenchmarkDotNet.Running; +using Kuddle.Benchmarks; + +BenchmarkRunner.Run(); + +``` +// File: src\Kuddle.Net.Benchmarks\SerializerBenchmarks.cs`$langusing BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using Kuddle.AST; +using Kuddle.Parser; +using Kuddle.Serialization; +using Parlot.Fluent; + +namespace Kuddle.Benchmarks; + +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +public class SerializerBenchmarks +{ + private string _simpleDocument = string.Empty; + private string _complexDocument = string.Empty; + private string _largeDocument = string.Empty; + + private Parser _compiledParser = null!; + private Parser _nonCompiledParser = null!; + + // Serialization benchmarks + private Package _simplePackage = null!; + private string _simplePackageKdl = string.Empty; + + private Project _complexProject = null!; + private string _complexProjectKdl = string.Empty; + + private List _largePackageList = null!; + private string _largePackageListKdl = string.Empty; + + [GlobalSetup] + public void Setup() + { + _simpleDocument = """ + node1 "value1" + node2 123 + node3 #true + """; + + _complexDocument = """ + package { + name "my-package" + version "1.0.0" + dependencies { + dep1 "^2.0.0" + dep2 "~1.5.0" + } + } + + config (type)"production" { + host "example.com" + port 8080 + ssl #true + features enabled=#true timeout=30 + } + + users { + user id=1 name="Alice" active=#true + user id=2 name="Bob" active=#false + user id=3 name="Charlie" active=#true + } + """; + + var largeDocBuilder = new System.Text.StringBuilder(); + for (int i = 0; i < 100; i++) + { + largeDocBuilder.AppendLine($"node{i} {{"); + for (int j = 0; j < 10; j++) + { + largeDocBuilder.AppendLine($" child{j} \"value{j}\" prop{j}={j}"); + } + largeDocBuilder.AppendLine("}"); + } + _largeDocument = largeDocBuilder.ToString(); + + _compiledParser = KdlGrammar.Document.Compile(); + _nonCompiledParser = KdlGrammar.Document; + + // Setup serialization objects + _simplePackage = new Package + { + Name = "my-lib", + Version = "1.0.0", + Description = "A library", + }; + _simplePackageKdl = KdlSerializer.Serialize(_simplePackage); + + _complexProject = new Project + { + Name = "my-app", + Version = "2.0.0", + Dependencies = + [ + new Dependency { Package = "lodash", Version = "4.17.21" }, + new Dependency { Package = "react", Version = "18.0.0" }, + new Dependency { Package = "typescript", Version = "4.5.0" }, + ], + DevDependencies = + [ + new Dependency { Package = "jest", Version = "27.0.0" }, + new Dependency { Package = "eslint", Version = "8.0.0" }, + ], + }; + _complexProjectKdl = KdlSerializer.Serialize(_complexProject); + + _largePackageList = []; + for (int i = 0; i < 100; i++) + { + _largePackageList.Add( + new Package + { + Name = $"package{i}", + Version = $"1.{i}.0", + Description = $"Description for package {i}", + } + ); + } + _largePackageListKdl = KdlSerializer.SerializeMany(_largePackageList); + } + + [Benchmark] + public KdlDocument? SimpleDocument_NonCompiled() + { + return _nonCompiledParser.Parse(_simpleDocument); + } + + [Benchmark] + public KdlDocument? SimpleDocument_Compiled() + { + return _compiledParser.Parse(_simpleDocument); + } + + [Benchmark] + public KdlDocument? ComplexDocument_NonCompiled() + { + return _nonCompiledParser.Parse(_complexDocument); + } + + [Benchmark] + public KdlDocument? ComplexDocument_Compiled() + { + return _compiledParser.Parse(_complexDocument); + } + + [Benchmark] + public KdlDocument? LargeDocument_NonCompiled() + { + return _nonCompiledParser.Parse(_largeDocument); + } + + [Benchmark] + public KdlDocument? LargeDocument_Compiled() + { + return _compiledParser.Parse(_largeDocument); + } + + // Serialization benchmarks + [Benchmark] + public string SerializeSimplePackage() + { + return KdlSerializer.Serialize(_simplePackage); + } + + [Benchmark] + public string SerializeComplexProject() + { + return KdlSerializer.Serialize(_complexProject); + } + + [Benchmark] + public string SerializeLargePackageList() + { + return KdlSerializer.SerializeMany(_largePackageList); + } + + // Deserialization benchmarks + [Benchmark] + public Package? DeserializeSimplePackage() + { + return KdlSerializer.Deserialize(_simplePackageKdl); + } + + [Benchmark] + public Project? DeserializeComplexProject() + { + return KdlSerializer.Deserialize(_complexProjectKdl); + } + + [Benchmark] + public List? DeserializeLargePackageList() + { + return KdlSerializer.DeserializeMany(_largePackageListKdl).ToList(); + } + + // Test models + public class Package + { + [KdlArgument(0)] + public string Name { get; set; } = string.Empty; + + [KdlProperty("version")] + public string? Version { get; set; } + + [KdlProperty("description")] + public string? Description { get; set; } + } + + public class Project + { + [KdlArgument(0)] + public string Name { get; set; } = string.Empty; + + [KdlProperty("version")] + public string Version { get; set; } = "1.0.0"; + + [KdlNode("dependency")] + public List Dependencies { get; set; } = []; + + [KdlNode("devDependency")] + public List DevDependencies { get; set; } = []; + } + + public class Dependency + { + [KdlArgument(0)] + public string Package { get; set; } = string.Empty; + + [KdlProperty("version")] + public string Version { get; set; } = "*"; + + [KdlProperty("optional")] + public bool Optional { get; set; } + } +} + +``` +// File: src\Kuddle.Net.Tests\AST\KdlNumberTests.cs`$langusing Kuddle.AST; + +namespace Kuddle.Tests.AST; + +public class KdlNumberTests +{ + #region Successful Conversions - All Number Bases + + [Test] + [Arguments("42", 42)] + [Arguments("0x2A", 42)] + [Arguments("0o52", 42)] + [Arguments("0b101010", 42)] + [Arguments("-42", -42)] + [Arguments("-0x2A", -42)] + [Arguments("-0o52", -42)] + [Arguments("-0b101010", -42)] + public async Task ToInt32_ConvertsAllBases(string input, int expected) + { + var sut = new KdlNumber(input); + int result = sut.ToInt32(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("42", (uint)42)] + [Arguments("0x2A", (uint)42)] + [Arguments("0o52", (uint)42)] + [Arguments("0b101010", (uint)42)] + public async Task ToUInt32_ConvertsAllBases(string input, uint expected) + { + var sut = new KdlNumber(input); + uint result = sut.ToUInt32(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("42", (long)42)] + [Arguments("0x2A", (long)42)] + [Arguments("0o52", (long)42)] + [Arguments("0b101010", (long)42)] + [Arguments("-42", (long)-42)] + [Arguments("-0x2A", (long)-42)] + [Arguments("-0o52", (long)-42)] + [Arguments("-0b101010", (long)-42)] + public async Task ToInt64_ConvertsAllBases(string input, long expected) + { + var sut = new KdlNumber(input); + var result = sut.ToInt64(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("42", (ulong)42)] + [Arguments("0x2A", (ulong)42)] + [Arguments("0o52", (ulong)42)] + [Arguments("0b101010", (ulong)42)] + public async Task ToUInt64_ConvertsAllBases(string input, ulong expected) + { + var sut = new KdlNumber(input); + ulong result = sut.ToUInt64(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("42", (short)42)] + [Arguments("0x2A", (short)42)] + [Arguments("0o52", (short)42)] + [Arguments("0b101010", (short)42)] + [Arguments("-42", (short)-42)] + [Arguments("-0x2A", (short)-42)] + [Arguments("-0o52", (short)-42)] + [Arguments("-0b101010", (short)-42)] + public async Task ToInt16_ConvertsAllBases(string input, short expected) + { + var sut = new KdlNumber(input); + short result = sut.ToInt16(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("42", (ushort)42)] + [Arguments("0x2A", (ushort)42)] + [Arguments("0o52", (ushort)42)] + [Arguments("0b101010", (ushort)42)] + public async Task ToUInt16_ConvertsAllBases(string input, ushort expected) + { + var sut = new KdlNumber(input); + ushort result = sut.ToUInt16(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("42", (byte)42)] + [Arguments("0x2A", (byte)42)] + [Arguments("0o52", (byte)42)] + [Arguments("0b101010", (byte)42)] + public async Task ToByte_ConvertsAllBases(string input, byte expected) + { + var sut = new KdlNumber(input); + byte result = sut.ToByte(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("42", (sbyte)42)] + [Arguments("0x2A", (sbyte)42)] + [Arguments("0o52", (sbyte)42)] + [Arguments("0b101010", (sbyte)42)] + [Arguments("-42", (sbyte)-42)] + [Arguments("-0x2A", (sbyte)-42)] + [Arguments("-0o52", (sbyte)-42)] + [Arguments("-0b101010", (sbyte)-42)] + public async Task ToSByte_ConvertsAllBases(string input, sbyte expected) + { + var sut = new KdlNumber(input); + sbyte result = sut.ToSByte(); + await Assert.That(result).IsEqualTo(expected); + } + + #endregion + + #region Overflow and Invalid Conversion Tests + + [Test] + [Arguments("256")] + [Arguments("-1")] + public async Task ToByte_ThrowsOnInvalidValues(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToByte()).Throws(); + } + + [Test] + [Arguments("65536")] + [Arguments("-1")] + public async Task ToUInt16_ThrowsOnInvalidValues(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToUInt16()).Throws(); + } + + [Test] + [Arguments("50000")] + [Arguments("-50000")] + [Arguments("32768")] + [Arguments("-32769")] + public async Task ToInt16_ThrowsOnInvalidValues(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToInt16()).Throws(); + } + + [Test] + [Arguments("-42")] + [Arguments("4294967296")] + [Arguments("-1")] + public async Task ToUInt32_ThrowsOnInvalidValues(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToUInt32()).Throws(); + } + + [Test] + [Arguments("-42")] + [Arguments("18446744073709551616")] + [Arguments("-1")] + public async Task ToUInt64_ThrowsOnInvalidValues(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToUInt64()).Throws(); + } + + [Test] + [Arguments("2147483648")] + [Arguments("-2147483649")] + public async Task ToInt32_ThrowsOnInvalidValues(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToInt32()).Throws(); + } + + [Test] + [Arguments("9_223_372_036_854_775_808")] + [Arguments("-9_223_372_036_854_775_809")] + public async Task ToInt64_ThrowsOnInvalidValues(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToInt64()).Throws(); + } + + #endregion + + #region Special Number Handling Tests + + [Test] + [Arguments("#inf")] + [Arguments("#-inf")] + [Arguments("#nan")] + public async Task ToInt32_ThrowsOnSpecialNumbers(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToInt32()).Throws(); + } + + [Test] + [Arguments("#inf")] + [Arguments("#-inf")] + [Arguments("#nan")] + public async Task ToUInt32_ThrowsOnSpecialNumbers(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToUInt32()).Throws(); + } + + [Test] + [Arguments("#inf")] + [Arguments("#-inf")] + [Arguments("#nan")] + public async Task ToInt64_ThrowsOnSpecialNumbers(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToInt64()).Throws(); + } + + [Test] + [Arguments("#inf")] + [Arguments("#-inf")] + [Arguments("#nan")] + public async Task ToUInt64_ThrowsOnSpecialNumbers(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToUInt64()).Throws(); + } + + [Test] + [Arguments("#inf")] + [Arguments("#-inf")] + [Arguments("#nan")] + public async Task ToInt16_ThrowsOnSpecialNumbers(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToInt16()).Throws(); + } + + [Test] + [Arguments("#inf")] + [Arguments("#-inf")] + [Arguments("#nan")] + public async Task ToUInt16_ThrowsOnSpecialNumbers(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToUInt16()).Throws(); + } + + [Test] + [Arguments("#inf")] + [Arguments("#-inf")] + [Arguments("#nan")] + public async Task ToByte_ThrowsOnSpecialNumbers(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToByte()).Throws(); + } + + [Test] + [Arguments("#inf")] + [Arguments("#-inf")] + [Arguments("#nan")] + public async Task ToSByte_ThrowsOnSpecialNumbers(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToSByte()).Throws(); + } + + [Test] + public async Task ToDouble_HandlesInfinity() + { + var posInf = new KdlNumber("#inf"); + var negInf = new KdlNumber("#-inf"); + var nan = new KdlNumber("#nan"); + + await Assert.That(posInf.ToDouble()).IsEqualTo(double.PositiveInfinity); + await Assert.That(negInf.ToDouble()).IsEqualTo(double.NegativeInfinity); + await Assert.That(double.IsNaN(nan.ToDouble())).IsTrue(); + } + + [Test] + public async Task ToFloat_HandlesInfinity() + { + var posInf = new KdlNumber("#inf"); + var negInf = new KdlNumber("#-inf"); + var nan = new KdlNumber("#nan"); + + await Assert.That(posInf.ToFloat()).IsEqualTo(float.PositiveInfinity); + await Assert.That(negInf.ToFloat()).IsEqualTo(float.NegativeInfinity); + await Assert.That(float.IsNaN(nan.ToFloat())).IsTrue(); + } + + #endregion + + #region Edge Case Tests + + [Test] + [Arguments("0")] + [Arguments("0x0")] + [Arguments("0o0")] + [Arguments("0b0")] + public async Task ToInt32_HandlesZero(string input) + { + var sut = new KdlNumber(input); + var result = sut.ToInt32(); + await Assert.That(result).IsEqualTo(0); + } + + [Test] + [Arguments("2147483647")] + [Arguments("-2147483648")] + public async Task ToInt32_HandlesBoundaries(string input) + { + var sut = new KdlNumber(input); + var result = sut.ToInt32(); + await Assert.That(result).IsEqualTo(int.Parse(input)); + } + + [Test] + [Arguments("4294967295")] + public async Task ToUInt32_HandlesMaxBoundary(string input) + { + var sut = new KdlNumber(input); + var result = sut.ToUInt32(); + await Assert.That(result).IsEqualTo(uint.MaxValue); + } + + [Test] + [Arguments("9223372036854775807")] + [Arguments("-9223372036854775808")] + public async Task ToInt64_HandlesBoundaries(string input) + { + var sut = new KdlNumber(input); + var result = sut.ToInt64(); + await Assert.That(result).IsEqualTo(long.Parse(input)); + } + + [Test] + [Arguments("18446744073709551615")] + public async Task ToUInt64_HandlesMaxBoundary(string input) + { + var sut = new KdlNumber(input); + var result = sut.ToUInt64(); + await Assert.That(result).IsEqualTo(ulong.MaxValue); + } + + [Test] + [Arguments("32767")] + [Arguments("-32768")] + public async Task ToInt16_HandlesBoundaries(string input) + { + var sut = new KdlNumber(input); + var result = sut.ToInt16(); + await Assert.That(result).IsEqualTo(short.Parse(input)); + } + + [Test] + [Arguments("65535")] + public async Task ToUInt16_HandlesMaxBoundary(string input) + { + var sut = new KdlNumber(input); + var result = sut.ToUInt16(); + await Assert.That(result).IsEqualTo(ushort.MaxValue); + } + + [Test] + [Arguments("127")] + [Arguments("-128")] + public async Task ToSByte_HandlesBoundaries(string input) + { + var sut = new KdlNumber(input); + var result = sut.ToSByte(); + await Assert.That(result).IsEqualTo(sbyte.Parse(input)); + } + + [Test] + [Arguments("255")] + public async Task ToByte_HandlesMaxBoundary(string input) + { + var sut = new KdlNumber(input); + var result = sut.ToByte(); + await Assert.That(result).IsEqualTo(byte.MaxValue); + } + + #endregion + + #region Underscore Separator Tests + + [Test] + [Arguments("1_000_000", 1000000)] + [Arguments("0xFF_FF", 65535)] + [Arguments("0o777_777", 262143)] + [Arguments("0b1111_0000_1010_0101", 61605)] + public async Task ToInt32_HandlesUnderscores(string input, int expected) + { + var sut = new KdlNumber(input); + var result = sut.ToInt32(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("1_000_000_000_000", 1000000000000)] + [Arguments("0xFFFF_FFFF_FFFF", 281474976710655)] + public async Task ToInt64_HandlesUnderscores(string input, long expected) + { + var sut = new KdlNumber(input); + var result = sut.ToInt64(); + await Assert.That(result).IsEqualTo(expected); + } + + #endregion + + #region Floating-Point Number Tests + + [Test] + [Arguments("3.14159", 3.14159)] + [Arguments("1.23e-4", 0.000123)] + [Arguments("6.02E23", 6.02e23)] + [Arguments("-2.5", -2.5)] + [Arguments("0.0", 0.0)] + public async Task ToDouble_ConvertsDecimalNumbers(string input, double expected) + { + var sut = new KdlNumber(input); + double result = sut.ToDouble(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("3.14159", 3.14159f)] + [Arguments("1.23e-4", 0.000123f)] + [Arguments("6.02E23", 6.02e23f)] + [Arguments("-2.5", -2.5f)] + [Arguments("0.0", 0.0f)] + public async Task ToFloat_ConvertsDecimalNumbers(string input, float expected) + { + var sut = new KdlNumber(input); + float result = sut.ToFloat(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("3.14159", 3.14159)] + [Arguments("1.23e-4", 0.000123)] + [Arguments("6.02E23", 6.02e23)] + [Arguments("-2.5", -2.5)] + [Arguments("0.0", 0.0)] + public async Task ToDecimal_ConvertsDecimalNumbers(string input, decimal expected) + { + var sut = new KdlNumber(input); + decimal result = sut.ToDecimal(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("42", (double)42)] + [Arguments("0x2A", (double)42)] + [Arguments("0o52", (double)42)] + [Arguments("0b101010", (double)42)] + [Arguments("-42", (double)-42)] + [Arguments("-0x2A", (double)-42)] + [Arguments("-0o52", (double)-42)] + [Arguments("-0b101010", (double)-42)] + public async Task ToDouble_ConvertsAllBases(string input, double expected) + { + var sut = new KdlNumber(input); + double result = sut.ToDouble(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("42", (float)42)] + [Arguments("0x2A", (float)42)] + [Arguments("0o52", (float)42)] + [Arguments("0b101010", (float)42)] + [Arguments("-42", (float)-42)] + [Arguments("-0x2A", (float)-42)] + [Arguments("-0o52", (float)-42)] + [Arguments("-0b101010", (float)-42)] + public async Task ToFloat_ConvertsAllBases(string input, float expected) + { + var sut = new KdlNumber(input); + double result = sut.ToFloat(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("42", 42)] + [Arguments("0x2A", 42)] + [Arguments("0o52", 42)] + [Arguments("0b101010", 42)] + [Arguments("-42", -42)] + [Arguments("-0x2A", -42)] + [Arguments("-0o52", -42)] + [Arguments("-0b101010", -42)] + public async Task ToDecimal_ConvertsAllBases(string input, decimal expected) + { + var sut = new KdlNumber(input); + decimal result = sut.ToDecimal(); + await Assert.That(result).IsEqualTo(expected); + } + + #endregion + + #region Float to Integer Conversion Tests + + [Test] + [Arguments("3.14159")] + [Arguments("1.23e-4")] + [Arguments("6.02E23")] + [Arguments("-2.5")] + public async Task ToInt32_ThrowsOnFloatNumbers(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToInt32()).Throws(); + } + + [Test] + [Arguments("3.14159")] + [Arguments("1.23e-4")] + [Arguments("6.02E23")] + [Arguments("-2.5")] + public async Task ToInt64_ThrowsOnFloatNumbers(string input) + { + var sut = new KdlNumber(input); + await Assert.That(() => sut.ToInt64()).Throws(); + } + + #endregion + + #region Additional KdlNumber Parsing Logic Tests + + [Test] + [Arguments("123", NumberBase.Decimal)] + [Arguments("-100", NumberBase.Decimal)] + [Arguments("10.5", NumberBase.Decimal)] + [Arguments("0xFF", NumberBase.Hex)] + [Arguments("0x1A", NumberBase.Hex)] + [Arguments("0o77", NumberBase.Octal)] + [Arguments("0b1010", NumberBase.Binary)] + public async Task GetBase_ReturnsCorrectBase(string input, NumberBase expected) + { + var sut = new KdlNumber(input); + var result = sut.GetBase(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("0xFF", 255)] + [Arguments("0x1A", 26)] + [Arguments("0o77", 63)] + [Arguments("0b1010", 10)] + public async Task Given_HexString_When_ToInt32_Then_ReturnsCorrectValue( + string raw, + int expected + ) + { + var number = new KdlNumber(raw); + var result = number.ToInt32(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("1_000", 1000)] + [Arguments("0xFF_FF", 65535)] + [Arguments("0o777_777", 262143)] + [Arguments("0b1111_0000", 240)] + public async Task Given_UnderscoreFormatted_When_ToInt32_Then_UnderscoresIgnored( + string raw, + int expected + ) + { + var number = new KdlNumber(raw); + var result = number.ToInt32(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + public async Task Given_LargeNumber_When_ToInt32_Then_ThrowsOverflow() + { + var number = new KdlNumber("9999999999"); + await Assert.That(() => number.ToInt32()).Throws(); + } + + [Test] + [Arguments("10.5")] + [Arguments("1.23e4")] + public async Task Given_FloatString_When_ToInt64_Then_ThrowsFormatException(string raw) + { + var number = new KdlNumber(raw); + await Assert.That(() => number.ToInt64()).Throws(); + } + + #endregion +} + +``` +// File: src\Kuddle.Net.Tests\AST\KdlStringTests.cs`$langusing Kuddle.AST; + +namespace Kuddle.Tests.AST; + +public class KdlStringTests +{ + #region Constructor and Value Property Tests + + [Test] + [Arguments("world", StringKind.Quoted)] + [Arguments("", StringKind.Raw)] + [Arguments("string\nwith\nnewlines", StringKind.Quoted)] + public async Task Constructor_SetsValueAndTypeCorrectly(string input, StringKind type) + { + var sut = new KdlString(input, type); + await Assert.That(sut.Value).IsEqualTo(input); + await Assert.That(sut.Kind).IsEqualTo(type); + } + + #endregion + + #region Equality Tests + + [Test] + public async Task Equals_ReturnsTrueForSameValueAndType() + { + var sut1 = new KdlString("test", StringKind.Quoted); + var sut2 = new KdlString("test", StringKind.Quoted); + await Assert.That(sut1).IsEqualTo(sut2); + } + + [Test] + public async Task Equals_ReturnsFalseForDifferentValue() + { + var sut1 = new KdlString("test1", StringKind.Quoted); + var sut2 = new KdlString("test2", StringKind.Quoted); + await Assert.That(sut1).IsNotEqualTo(sut2); + } + + [Test] + public async Task Equals_ReturnsFalseForDifferentType() + { + var sut1 = new KdlString("test", StringKind.Quoted); + var sut2 = new KdlString("test", StringKind.Bare); + await Assert.That(sut1).IsNotEqualTo(sut2); + } + + [Test] + public async Task Equals_ReturnsFalseForNull() + { + var sut = new KdlString("test", StringKind.Quoted); + await Assert.That(sut.Equals(null)).IsFalse(); + } + + #endregion + + #region ToString Tests + + [Test] + [Arguments("hello", StringKind.Bare)] + [Arguments("world", StringKind.Quoted)] + [Arguments("", StringKind.Raw)] + public async Task ToString_ReturnsValue(string input, StringKind type) + { + var sut = new KdlString(input, type); + await Assert.That(sut.ToString()).IsEqualTo(input); + } + + #endregion + + #region TypeAnnotation Tests + + [Test] + public async Task TypeAnnotation_CanBeSet() + { + var sut = new KdlString("test", StringKind.Quoted) { TypeAnnotation = "custom" }; + await Assert.That(sut.TypeAnnotation).IsEqualTo("custom"); + } + + [Test] + public async Task TypeAnnotation_DefaultsToNull() + { + var sut = new KdlString("test", StringKind.Quoted); + await Assert.That(sut.TypeAnnotation).IsNull(); + } + + #endregion + + #region HashCode Tests + + [Test] + public async Task GetHashCode_ReturnsSameForEqualValuesAndTypes() + { + var sut1 = new KdlString("test", StringKind.Quoted); + var sut2 = new KdlString("test", StringKind.Quoted); + await Assert.That(sut1.GetHashCode()).IsEqualTo(sut2.GetHashCode()); + } + + [Test] + public async Task GetHashCode_ReturnsDifferentForDifferentValues() + { + var sut1 = new KdlString("test1", StringKind.Quoted); + var sut2 = new KdlString("test2", StringKind.Quoted); + await Assert.That(sut1.GetHashCode()).IsNotEqualTo(sut2.GetHashCode()); + } + + [Test] + public async Task GetHashCode_ReturnsDifferentForDifferentTypes() + { + var sut1 = new KdlString("test", StringKind.Quoted); + var sut2 = new KdlString("test", StringKind.Bare); + await Assert.That(sut1.GetHashCode()).IsNotEqualTo(sut2.GetHashCode()); + } + + #endregion + + #region Edge Case Tests + + [Test] + public async Task EmptyString_HandlesCorrectly() + { + var sut = new KdlString("", StringKind.Quoted); + await Assert.That(sut.Value).IsEqualTo(""); + await Assert.That(sut.ToString()).IsEqualTo(""); + await Assert.That(sut.Kind).IsEqualTo(StringKind.Quoted); + } + + [Test] + public async Task StringWithSpecialCharacters_HandlesCorrectly() + { + var input = "special\tchars\n\"quotes\""; + var sut = new KdlString(input, StringKind.Raw); + await Assert.That(sut.Value).IsEqualTo(input); + await Assert.That(sut.Kind).IsEqualTo(StringKind.Raw); + } + + #endregion +} + +``` +// File: src\Kuddle.Net.Tests\Errors\ErrorHandlingTests.cs`$langusing Kuddle.Exceptions; +using Kuddle.Serialization; + +namespace Kuddle.Tests.Errors; + +#pragma warning disable CS8602 // Dereference of a possibly null reference. + +public class ErrorHandlingTests +{ + private static async Task AssertParseFails( + string kdl, + string expectedMessagePart, + int? expectedLine = null + ) + { + var ex = await Assert.ThrowsAsync(async () => KdlReader.Read(kdl)); + + await Assert + .That(ex.Message) + .Contains(expectedMessagePart, StringComparison.OrdinalIgnoreCase); + + if (expectedLine.HasValue) + { + await Assert.That(ex.Line).IsEqualTo(expectedLine.Value); + } + } + + #region Reserved Keywords (Your Custom Logic) + + [Test] + public async Task ReservedKeyword_AsNodeName_ThrowsSpecificError() + { + const string input = "true \"value\""; + + await AssertParseFails(input, "keyword 'true' cannot be used"); + } + + [Test] + public async Task ReservedKeyword_AsPropKey_ThrowsSpecificError() + { + const string input = "node null=10"; + + await AssertParseFails(input, "keyword 'null' cannot be used"); + } + + [Test] + public async Task ReservedKeyword_AsTypeAnnotation_ThrowsSpecificError() + { + const string input = "(false)node"; + + await AssertParseFails(input, "keyword 'false' cannot be used"); + } + + #endregion + + #region Structure & Blocks + + [Test] + public async Task Block_Unclosed_Throws() + { + const string input = + @" +node { + child +"; + + await AssertParseFails(input, "expected", expectedLine: 3); + } + + [Test] + public async Task Block_MissingSpaceBefore_IsAllowed_But_MissingSemiColon_Throws() + { + const string input = "node { child"; + + await AssertParseFails(input, "expected"); + } + + [Test] + public async Task Property_MissingValue_Throws() + { + const string input = "node key="; + + await AssertParseFails(input, "expected"); + } + + [Test] + public async Task TypeAnnotation_Unclosed_Throws() + { + const string input = "node (u8 value"; + + await AssertParseFails(input, "expected"); + } + + #endregion + + #region String Literals + + [Test] + public async Task String_UnclosedQuote_Throws() + { + const string input = "node \"oops"; + + await AssertParseFails(input, "expected"); + } + + [Test] + public async Task String_InvalidEscape_Throws() + { + const string input = "node \"hello \\q world\""; + + await AssertParseFails(input, "expected"); + } + + [Test] + public async Task RawString_MismatchHashes_Throws() + { + const string input = "node ##\"content\"#"; + + await AssertParseFails(input, "expected"); + } + + #endregion +} +#pragma warning restore CS8602 // Dereference of a possibly null reference. + +``` +// File: src\Kuddle.Net.Tests\Extensions\DxTests.cs`$langusing Kuddle.AST; +using Kuddle.Extensions; +using Kuddle.Parser; +using Kuddle.Serialization; + +namespace Kuddle.Tests.Extensions; + +public class DxTests +{ + [Test] + public async Task TryGet_Int_Success() + { + var doc = KdlReader.Read("node 123"); + var val = doc.Nodes[0].Arg(0)!; + + bool success = val.TryGetInt(out int result); + + await Assert.That(success).IsTrue(); + await Assert.That(result).IsEqualTo(123); + } + + [Test] + public async Task TryGet_Int_Failure_WrongType() + { + var doc = KdlReader.Read("node \"hello\""); + var val = doc.Nodes[0].Arg(0)!; + + bool success = val.TryGetInt(out int result); + + await Assert.That(success).IsFalse(); + await Assert.That(result).IsEqualTo(0); // Default + } + + [Test] + public async Task TryGet_Int_Failure_Overflow() + { + // Value larger than Int32 + var doc = KdlReader.Read("node 9999999999"); + var val = doc.Nodes[0].Arg(0)!; + + bool success = val.TryGetInt(out int result); + + await Assert.That(success).IsFalse(); + } + + [Test] + public async Task TryGet_Prop_Navigation_Success() + { + var doc = KdlReader.Read("server port=8080"); + var node = doc.Nodes[0]; + + // Combine finding the prop and converting it + var propVal = node.Prop("port")!; + + if (propVal.TryGetInt(out int port)) + { + await Assert.That(port).IsEqualTo(8080); + } + else + { + Assert.Fail("Failed to get port"); + } + } + + [Test] + public async Task TryGet_Prop_Navigation_Missing() + { + var doc = KdlReader.Read("server host=\"localhost\""); + var node = doc.Nodes[0]; + + var propVal = node.Prop("port")!; // Returns KdlNull + + bool success = propVal.TryGetInt(out _); + await Assert.That(success).IsFalse(); + } + + [Test] + public async Task TryGetUuid_ValidGuidString_ReturnsTrue() + { + var expected = Guid.NewGuid(); + var kdl = $"node \"{expected}\""; // e.g. "f81d4fae-7dec-11d0-a765-00a0c91e6bf6" + var doc = KdlReader.Read(kdl); + var val = doc.Nodes[0].Arg(0)!; + + bool success = val.TryGetUuid(out var result); + + await Assert.That(success).IsTrue(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + public async Task TryGetUuid_InvalidString_ReturnsFalse() + { + var doc = KdlReader.Read("node \"not-a-guid\""); + var val = doc.Nodes[0].Arg(0)!; + + bool success = val.TryGetUuid(out var result); + + await Assert.That(success).IsFalse(); + await Assert.That(result).IsEqualTo(Guid.Empty); + } + + [Test] + public async Task TryGetUuid_FromAnnotatedValue_ReturnsTrue() + { + var expected = Guid.NewGuid(); + var kdl = $"node (uuid)\"{expected}\""; + var doc = KdlReader.Read(kdl); + var val = doc.Nodes[0].Arg(0)!; + + bool success = val.TryGetUuid(out var result); + + await Assert.That(success).IsTrue(); + await Assert.That(result).IsEqualTo(expected); + + await Assert.That(val.TypeAnnotation).IsEqualTo("uuid"); + } + + [Test] + public async Task Factory_FromGuid_CreatesAnnotatedString() + { + var guid = Guid.NewGuid(); + var val = KdlValue.From(guid)!; + + await Assert.That(val).IsOfType(typeof(KdlString)); + + await Assert.That(val.Value).IsEqualTo(guid.ToString()); + + await Assert.That(val.TypeAnnotation).IsEqualTo("uuid"); + } + + [Test] + public async Task TryGetDateTime_ValidIso8601_ReturnsTrue() + { + var now = DateTimeOffset.UtcNow; + var kdl = $"node \"{now:O}\""; + var doc = KdlReader.Read(kdl); + var val = doc.Nodes[0].Arg(0)!; + + bool success = val.TryGetDateTime(out var result); + + await Assert.That(success).IsTrue(); + await Assert.That(result).IsEqualTo(now); + } + + [Test] + public async Task TryGetDateTime_DateOnly_ReturnsTrue() + { + // YYYY-MM-DD + var kdl = "node \"2023-10-25\""; + var doc = KdlReader.Read(kdl); + var val = doc.Nodes[0].Arg(0)!; + + bool success = val.TryGetDateTime(out var result); + + await Assert.That(success).IsTrue(); + await Assert.That(result.Year).IsEqualTo(2023); + await Assert.That(result.Month).IsEqualTo(10); + await Assert.That(result.Day).IsEqualTo(25); + } + + [Test] + public async Task TryGetDateTime_InvalidString_ReturnsFalse() + { + var doc = KdlReader.Read("node \"tomorrow\""); + var val = doc.Nodes[0].Arg(0)!; + + bool success = val.TryGetDateTime(out var result); + + await Assert.That(success).IsFalse(); + await Assert.That(result).IsEqualTo(default(DateTimeOffset)); + } + + [Test] + public async Task Factory_FromDateTime_CreatesAnnotatedString() + { + var date = new DateTimeOffset(2023, 12, 25, 10, 0, 0, TimeSpan.Zero); + var val = KdlValue.From(date)!; + + // 1. Check Runtime Type + await Assert.That(val).IsOfType(typeof(KdlString)); + + // 2. Check Content (ISO 8601) + await Assert.That(val.Value).IsEqualTo(date.ToString("O")); + + // 3. Check Type Annotation + await Assert.That(val.TypeAnnotation).IsEqualTo("date-time"); + } +} + +``` +// File: src\Kuddle.Net.Tests\Grammar\CommentParserTests.cs`$langusing Kuddle.Parser; + +namespace Kuddle.Tests.Grammar; + +public class CommentParserTests +{ + [Test] + public async Task CanParseSimpleSingleLineComment() + { + var sut = KdlGrammar.SingleLineComment; + + var comment = """// I am a single line comment"""; + + bool success = sut.TryParse(comment, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(comment); + } + + [Test] + public async Task CanParseSimpleMultiLineComment() + { + var sut = KdlGrammar.MultiLineComment; + + var comment = """ +/* +some +comments +*/ +"""; + + bool success = sut.TryParse(comment, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(comment); + } + + [Test] + public async Task CanParseNestedMultiLineComment() + { + var sut = KdlGrammar.MultiLineComment; + + var comment = """ +/* + i am the first comment + /* + i am the nested comment + */ +*/ +"""; + + bool success = sut.TryParse(comment, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(comment); + } +} + +``` +// File: src\Kuddle.Net.Tests\Grammar\NodeParserTests.cs`$langusing Kuddle.AST; +using Kuddle.Parser; + +namespace Kuddle.Tests.Grammar; + +#pragma warning disable CS8602 // Dereference of a possibly null reference. + +public class NodeParsersTests +{ + [Test] + public async Task Type_ParsesSimpleType() + { + var sut = KdlGrammar.Type; + var input = "(string)"; + + bool success = sut.TryParse(input, out var result); + + await Assert.That(success).IsTrue(); + await Assert.That(result.Value).IsEqualTo("string"); + } + + [Test] + public async Task Prop_ParsesSimpleProperty() + { + var sut = KdlGrammar.Node; + var input = "node key=value"; + + bool success = sut.TryParse(input, out var node); + + await Assert.That(success).IsTrue(); + await Assert.That(node.Entries).Count().IsEqualTo(1); + + var prop = node.Entries[0] as KdlProperty; + await Assert.That(prop).IsNotNull(); + await Assert.That(prop!.Key.Value).IsEqualTo("key"); + await Assert.That(((KdlString)prop.Value).Value).IsEqualTo("value"); + } + + [Test] + public async Task Node_ParsesComplexLine() + { + var sut = KdlGrammar.Node; + var input = "(my-type)node 123 key=\"value\";"; + + bool success = sut.TryParse(input, out var node); + + await Assert.That(success).IsTrue(); + + await Assert.That(node.Name.Value).IsEqualTo("node"); + await Assert.That(node.TypeAnnotation).IsEqualTo("my-type"); + await Assert.That(node.TerminatedBySemicolon).IsTrue(); + + await Assert.That(node.Entries).Count().IsEqualTo(2); + + var arg = node.Entries[0] as KdlArgument; + await Assert.That(arg).IsNotNull(); + await Assert.That(((KdlNumber)arg!.Value).ToInt32()).IsEqualTo(123); + + var prop = node.Entries[1] as KdlProperty; + await Assert.That(prop).IsNotNull(); + await Assert.That(prop!.Key.Value).IsEqualTo("key"); + } + + [Test] + public async Task Node_ParsesChildren() + { + var sut = KdlGrammar.Node; + var input = "parent { child; }"; + + bool success = sut.TryParse(input, out var node, out var error); + + await Assert.That(success).IsTrue(); + await Assert.That(node.Name.Value).IsEqualTo("parent"); + await Assert.That(node.Children).IsNotNull(); + await Assert.That(node.Children!.Nodes).Count().IsEqualTo(1); + await Assert.That(node.Children.Nodes[0].Name.Value).IsEqualTo("child"); + } + + [Test] + public async Task Node_ParsesMixedContent() + { + var sut = KdlGrammar.Node; + var input = "(type)node 10 prop=#true { child; }"; + + bool success = sut.TryParse(input, out var node); + + await Assert.That(success).IsTrue(); + + // Metadata + await Assert.That(node.Name.Value).IsEqualTo("node"); + await Assert.That(node.TypeAnnotation).IsEqualTo("type"); + + // Entries + await Assert.That(node.Entries).Count().IsEqualTo(2); + await Assert + .That(((KdlNumber)((KdlArgument)node.Entries[0]).Value).ToInt32()) + .IsEqualTo(10); + await Assert.That(((KdlBool)((KdlProperty)node.Entries[1]).Value).Value).IsTrue(); + + // Children + await Assert.That(node.Children).IsNotNull(); + await Assert.That(node.Children!.Nodes).Count().IsEqualTo(1); + } + + [Test] + public async Task Node_SlashDash_SkipsNode() + { + // This tests the logic in 'Nodes' (plural) parser usually + var sut = KdlGrammar.Document; + var input = "node1; /- node2; node3;"; + + bool success = sut.TryParse(input, out var doc); + + await Assert.That(success).IsTrue(); + await Assert.That(doc.Nodes).Count().IsEqualTo(2); + await Assert.That(doc.Nodes[0].Name.Value).IsEqualTo("node1"); + await Assert.That(doc.Nodes[1].Name.Value).IsEqualTo("node3"); + } + + [Test] + public async Task Node_SlashDash_SkipsArg() + { + var sut = KdlGrammar.Node; + var input = "node 1 /- 2 3"; + + bool success = sut.TryParse(input, out var node); + + await Assert.That(success).IsTrue(); + await Assert.That(node.Entries).Count().IsEqualTo(2); + + // Entry 0 should be 1 + var arg1 = node.Entries[0] as KdlArgument; + await Assert.That(((KdlNumber)arg1!.Value).ToInt32()).IsEqualTo(1); + + // Entry 1 should be 3 (2 was skipped) + var arg2 = node.Entries[1] as KdlArgument; + await Assert.That(((KdlNumber)arg2!.Value).ToInt32()).IsEqualTo(3); + } + + [Test] + public async Task SlashDash_SkipsProperty() + { + var sut = KdlGrammar.Node; + var input = "node key=1 /- skipped=2 valid=3"; + + bool success = sut.TryParse(input, out var node); + + await Assert.That(success).IsTrue(); + await Assert.That(node.Entries).Count().IsEqualTo(2); + + var p1 = node.Entries[0] as KdlProperty; + await Assert.That(p1!.Key.Value).IsEqualTo("key"); + + var p2 = node.Entries[1] as KdlProperty; + await Assert.That(p2!.Key.Value).IsEqualTo("valid"); + } + + [Test] + public async Task Node_SlashDash_SkipsChildrenBlock() + { + var sut = KdlGrammar.Node; + // Parsing a node that has a slash-dashed children block + var input = "node /- { child; }"; + + bool success = sut.TryParse(input, out var node); + + await Assert.That(success).IsTrue(); + await Assert.That(node.Name.Value).IsEqualTo("node"); + // The children block should be null because it was skipped + await Assert.That(node.Children).IsNull(); + } + + [Test] + public async Task Nodes_ParsesNodesWithWhitespace() + { + var sut = KdlGrammar.Nodes; + var input = + @" + node1; + + node2; + "; + + bool success = sut.TryParse(input, out var nodes); + + await Assert.That(success).IsTrue(); + await Assert.That(nodes).Count().IsEqualTo(2); + } +} +#pragma warning restore CS8602 // Dereference of a possibly null reference. + +``` +// File: src\Kuddle.Net.Tests\Grammar\NumberParsersTests.cs`$langusing Kuddle.Parser; + +namespace Kuddle.Tests.Grammar; + +public class NumberParsersTests +{ + [Test] + public async Task Decimal_ParsesPositiveInteger() + { + var sut = KdlGrammar.Decimal; + + var input = "42"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Decimal_ParsesNegativeInteger() + { + var sut = KdlGrammar.Decimal; + + var input = "-42"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Decimal_ParsesFractionalNumber() + { + var sut = KdlGrammar.Decimal; + + var input = "3.14159"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Decimal_ParsesScientificNotation() + { + var sut = KdlGrammar.Decimal; + + var input = "1.23e-4"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Decimal_ParsesScientificNotationUppercase() + { + var sut = KdlGrammar.Decimal; + + var input = "6.02E23"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Decimal_ParsesWithUnderscoreSeparators() + { + var sut = KdlGrammar.Decimal; + + var input = "1_000_000"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Decimal_ParsesFractionalWithUnderscores() + { + var sut = KdlGrammar.Decimal; + + var input = "12_34.56_78"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Hex_ParsesHexNumbers() + { + var sut = KdlGrammar.Hex; + + var input = "0xFF"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Hex_ParsesHexWithUnderscores() + { + var sut = KdlGrammar.Hex; + + var input = "0x123_ABC"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Hex_ParsesNegativeHex() + { + var sut = KdlGrammar.Hex; + + var input = "-0x42"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Octal_ParsesOctalNumbers() + { + var sut = KdlGrammar.Octal; + + var input = "0o777"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Octal_ParsesOctalWithUnderscores() + { + var sut = KdlGrammar.Octal; + + var input = "0o123_456"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Octal_ParsesNegativeOctal() + { + var sut = KdlGrammar.Octal; + + var input = "-0o42"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Binary_ParsesBinaryNumbers() + { + var sut = KdlGrammar.Binary; + + var input = "0b1010"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Binary_ParsesBinaryWithUnderscores() + { + var sut = KdlGrammar.Binary; + + var input = "0b1111_0000"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Binary_ParsesNegativeBinary() + { + var sut = KdlGrammar.Binary; + + var input = "-0b101"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task KeywordNumber_ParsesInfinity() + { + var sut = KdlGrammar.KeywordNumber; + + var input = "#inf"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task KeywordNumber_ParsesNegativeInfinity() + { + var sut = KdlGrammar.KeywordNumber; + + var input = "#-inf"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task KeywordNumber_ParsesNaN() + { + var sut = KdlGrammar.KeywordNumber; + + var input = "#nan"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Number_ParsesDecimal() + { + var sut = KdlGrammar.Decimal; + + var input = "42"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Number_ParsesHex() + { + var sut = KdlGrammar.Hex; + + var input = "0xFF"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Number_ParsesKeywordNumber() + { + var sut = KdlGrammar.KeywordNumber; + + var input = "#inf"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task Decimal_RejectsDoubleDots() + { + var sut = KdlGrammar.Decimal.Eof(); + + var input = "12.34.56"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsFalse(); + } +} + +``` +// File: src\Kuddle.Net.Tests\Grammar\StringParserTests.cs`$langusing System.Diagnostics; +using Kuddle.AST; +using Kuddle.Parser; + +namespace Kuddle.Tests.Grammar; + +#pragma warning disable CS8602 // Dereference of a possibly null reference. + +public class StringParserTests +{ + [Test] + [Arguments("+positive")] + [Arguments("-negative")] + public async Task SignedIdent_ParsesSignedIdentifier(string input) + { + var sut = KdlGrammar.SignedIdent; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(input); + } + + [Test] + [Arguments(".one")] + [Arguments(".two")] + [Arguments(".three")] + public async Task DottedIdent_ParsesDottedIdentifier(string input) + { + var sut = KdlGrammar.DottedIdent; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(input); + } + + [Test] + [Arguments(".12")] + [Arguments(".01")] + public async Task DottedIdent_DoesNotParseNumberDottedNumber(string input) + { + var sut = KdlGrammar.DottedIdent; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsFalse(); + } + + [Test] + [Arguments("one")] + [Arguments("two")] + [Arguments("three")] + [Arguments("world123")] + [Arguments("test_case")] + public async Task UnambiguousIdent_ParsesUnambiguousIdentifier(string input) + { + var sut = KdlGrammar.UnambiguousIdent; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(input); + } + + [Test] + [Arguments("+positive")] + [Arguments("-negative")] + [Arguments(".one")] + public async Task UnambiguousIdent_DoesNotParseInvalidIdentifier(string input) + { + var sut = KdlGrammar.UnambiguousIdent; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsFalse(); + } + + [Test] + [Arguments("true")] + [Arguments("false")] + [Arguments("null")] + [Arguments("inf")] + [Arguments("-inf")] + [Arguments("nan")] + public async Task UnambiguousIdent_DoesNotParseDisallowedKeywordString(string input) + { + var sut = KdlGrammar.UnambiguousIdent; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsFalse(); + } + + [Test] + [Arguments("one")] + [Arguments("two")] + [Arguments("three")] + [Arguments("world123")] + [Arguments("test_case")] + [Arguments(".one")] + [Arguments(".two")] + [Arguments(".three")] + [Arguments("+positive")] + [Arguments("-negative")] + public async Task IdentifierString_ParsesIdentifierString(string input) + { + var sut = KdlGrammar.IdentifierString; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(input); + } + + [Test] + [Arguments("\"\\ \"")] + public async Task WsEscape_ParsesWhiteSpace(string input) + { + var sut = KdlGrammar.QuotedString; + + bool success = sut.TryParse(input, out var value, out var error); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(""); + } + + [Test] + public async Task QuotedString_ParsesSingleLineString() + { + var sut = KdlGrammar.QuotedString; + + const string input = """ +"hello world" +"""; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo("hello world"); + } + + [Test] + public async Task QuotedString_ParsesEmptyString() + { + var sut = KdlGrammar.QuotedString; + + const string input = """ +"" +"""; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(""); + } + + [Test] + public async Task QuotedString_ParsesEmptyMultilineString() + { + var sut = KdlGrammar.QuotedString; + + const string input = """" +""" +""" +""""; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(""); + } + + [Test] + [Arguments( + """" +""" +i +am +""" +"""", + "i\nam" + )] + [Arguments( + """" +""" +so am + I! +""" +"""", + "so am\n I!" + )] + [Arguments( + """" +""" + foo + This is the base indentation + bar + """ +"""", + " foo\nThis is the base indentation\n bar " + )] + public async Task MultiLineStringBody_HandlesVarious(string input, string expected) + { + var sut = KdlGrammar.MultiLineQuoted; + + bool success = sut.TryParse(input, out var value); + Debug.WriteLine(input); + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(expected); + } + + [Test] + [Arguments( + """" +""" +lorem ipsum +""" +"""", + "lorem ipsum" + )] + [Arguments( + """" +""" +Lorem ipsum +canis canem edit +""" +"""", + "Lorem ipsum\ncanis canem edit" + )] + public async Task MultiLineQuotedString_CanParseMultiLine(string input, string expected) + { + var sut = KdlGrammar.MultiLineQuoted; + bool success = sut.TryParse(input, out var value, out var error); + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(expected); + } + + [Test] + [Arguments( + """ +"\u{1F600}" +""", + "😀" + )] + public async Task QuotedString_HandlesUnicodeEscapes(string input, string expected) + { + var sut = KdlGrammar.QuotedString; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(expected); + } + + [Test] + [Arguments( + """ +"Hello\nWorld" +""" + )] + [Arguments( + """ +"Hello\n\ + World" +""" + )] + public async Task QuotedString_HandlesWhitespaceEscapes(string input) + { + var sut = KdlGrammar.QuotedString; + + bool success = sut.TryParse(input, out var value, out var error); + + var expected = "Hello\nWorld"; + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(expected); + } + + [Test] + public async Task RawString_ParsesSimpleRawString() + { + var sut = KdlGrammar.RawString; + + var input = """ +#"\n will be literal"# +"""; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(@"\n will be literal"); + } + + [Test] + public async Task RawString_ParsesRawStringWithQuotes() + { + var sut = KdlGrammar.RawString; + + var input = "#\"content with \"quotes\"\"#"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo("content with \"quotes\""); + } + + [Test] + public async Task RawString_HandlesMultipleHashDelimiters() + { + var sut = KdlGrammar.RawString; + + var input = """ +##"hello\n\r\asd"#world"## +"""; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert + .That(value.ToString()) + .IsEqualTo( + """ +hello\n\r\asd"#world +""" + ); + } + + [Test] + public async Task RawString_ParsesMultiLineRawString() + { + var sut = KdlGrammar.RawString; + + var input = """"" +#""" + Here's a """ + multiline string + """ + without escapes. + """# +"""""; + bool success = sut.TryParse(input, out var value); + string expected = """" +Here's a """ + multiline string + """ +without escapes. +"""".Replace("\r\n", "\n"); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(expected); + } + + [Test] + public async Task IdentifierString_SetsStyleToBare() + { + var sut = KdlGrammar.IdentifierString; + var input = "bare_identifier"; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Value).IsEqualTo("bare_identifier"); + await Assert.That(value.Kind).IsEqualTo(StringKind.Bare); + } + + [Test] + public async Task QuotedString_SetsStyleToQuoted() + { + var sut = KdlGrammar.QuotedString; + var input = "\"quoted value\""; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Value).IsEqualTo("quoted value"); + // Should be Quoted only + await Assert.That(value.Kind).IsEqualTo(StringKind.Quoted); + } + + [Test] + public async Task MultiLineString_SetsStyleToMultiline() + { + var sut = KdlGrammar.MultiLineQuoted; + var input = """" +""" +content +""" +""""; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Value).IsEqualTo("content"); + // Should be MultiLine only (not Raw) + await Assert.That(value.Kind).IsEqualTo(StringKind.MultiLine); + } + + [Test] + public async Task RawString_SingleLine_SetsStyleToRawAndQuoted() + { + var sut = KdlGrammar.RawString; + var input = "#\"\"raw content\"\"#"; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Value).IsEqualTo("\"raw content\""); + + // Use HasFlag to verify bitwise combination + await Assert.That(value.Kind.HasFlag(StringKind.Raw)).IsTrue(); + await Assert.That(value.Kind.HasFlag(StringKind.Quoted)).IsTrue(); + await Assert.That(value.Kind.HasFlag(StringKind.MultiLine)).IsFalse(); + } + + [Test] + public async Task RawString_MultiLine_SetsStyleToRawAndMultiline() + { + var sut = KdlGrammar.RawString; + var input = """" +#""" +multi +line +"""# +""""; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Value).IsEqualTo("multi\nline"); + + // Should be Raw AND MultiLine + await Assert.That(value.Kind.HasFlag(StringKind.Raw)).IsTrue(); + await Assert.That(value.Kind.HasFlag(StringKind.MultiLine)).IsTrue(); + await Assert.That(value.Kind.HasFlag(StringKind.Quoted)).IsFalse(); + } + + [Test] + public async Task String_UnifiedParser_DetectsBare() + { + var sut = KdlGrammar.String; + var input = "node_name"; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Kind).IsEqualTo(StringKind.Bare); + } + + [Test] + public async Task String_UnifiedParser_DetectsQuoted() + { + var sut = KdlGrammar.String; + var input = "\"node name\""; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Kind).IsEqualTo(StringKind.Quoted); + } + + [Test] + public async Task String_UnifiedParser_DetectsRaw() + { + var sut = KdlGrammar.String; + var input = @"#""node name""#"; + + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Kind.HasFlag(StringKind.Raw)).IsTrue(); + } + + [Test] + public async Task String_ParsesIdentifierString() + { + var sut = KdlGrammar.String; + + var input = "hello"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(input); + } +} +#pragma warning restore CS8602 // Dereference of a possibly null reference. + +``` +// File: src\Kuddle.Net.Tests\Grammar\WhiteSpaceParsersTests.cs`$langusing Kuddle.Parser; + +namespace Kuddle.Tests.Grammar; + +public class WhiteSpaceParsersTests +{ + [Test] + [Arguments('\u0009')] + [Arguments('\u0020')] + [Arguments('\u00A0')] + [Arguments('\u1680')] + [Arguments('\u2000')] + [Arguments('\u2001')] + [Arguments('\u2002')] + [Arguments('\u2003')] + [Arguments('\u2004')] + [Arguments('\u2005')] + [Arguments('\u2006')] + [Arguments('\u2007')] + [Arguments('\u2008')] + [Arguments('\u2009')] + [Arguments('\u200A')] + [Arguments('\u202F')] + [Arguments('\u205F')] + [Arguments('\u3000')] + public async Task Ws_ParsesUnicodeSpace(char input) + { + var sut = KdlGrammar.Ws; + + bool success = sut.TryParse(input.ToString(), out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(input.ToString()); + } + + [Test] + public async Task Ws_ParsesMultiLineComment() + { + var sut = KdlGrammar.Ws; + + var input = "/* comment */"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(input); + } + + [Test] + public async Task EscLine_ParsesBackslashContinuation() + { + var sut = KdlGrammar.EscLine; + + var input = + @"\ +"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + } + + [Test] + public async Task EscLine_ParsesBackslashWithComment() + { + var sut = KdlGrammar.EscLine; + + var input = @"\ // comment"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.ToString()).IsEqualTo(input); + } + + [Test] + public async Task LineSpace_ParsesWhitespace() + { + var sut = KdlGrammar.LineSpace; + + var input = " "; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task LineSpace_ParsesNewLine() + { + var sut = KdlGrammar.LineSpace; + + var input = "\n"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task LineSpace_ParsesComment() + { + var sut = KdlGrammar.LineSpace; + + var input = "// comment"; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } + + [Test] + public async Task NodeSpace_ParsesSimpleWhitespace() + { + var sut = KdlGrammar.NodeSpace; + + var input = " "; + bool success = sut.TryParse(input, out var value); + + await Assert.That(success).IsTrue(); + await Assert.That(value.Span.ToString()).IsEqualTo(input); + } +} + +``` +// File: src\Kuddle.Net.Tests\Serialization\Models\AppSettings.cs`$langusing Kuddle.Serialization; + +namespace Kuddle.Tests.Serialization.Models; + +public class AppSettings +{ + [KdlNodeDictionary("themes")] + public Dictionary Themes { get; set; } = new(); + + [KdlNodeDictionary("layouts")] + public Dictionary Layouts { get; set; } = new(); +} + +public class Theme : Dictionary { } + +public class LayoutDefinition +{ + [KdlProperty("section")] + public string Section { get; set; } = default!; + + [KdlProperty("size")] + public int Ratio { get; set; } = 1; + + [KdlProperty("split")] + public string SplitDirection { get; set; } = "columns"; + + [KdlNode("child")] + public List Children { get; set; } = []; +} + +public class ElementStyle +{ + [KdlNode("border")] + public BorderStyleSettings? BorderStyle { get; set; } + + [KdlNode("header")] + public PanelHeaderSettings? PanelHeader { get; set; } + + [KdlNode("align")] + public AlignmentSettings? Alignment { get; set; } + + [KdlIgnore] + public bool WrapInPanel { get; internal set; } = true; +} + +public class BorderStyleSettings +{ + [KdlProperty("color")] + public string? ForegroundColor { get; set; } + + [KdlProperty("style")] + public string? Decoration { get; set; } +} + +public class PanelHeaderSettings +{ + [KdlProperty("text")] + public string? Text { get; set; } +} + +public class AlignmentSettings +{ + [KdlProperty("v")] + public VerticalAlignment Vertical { get; set; } + + [KdlProperty("h")] + public HorizontalAlignment Horizontal { get; set; } +} + +public enum VerticalAlignment +{ + Top, + Middle, + Bottom, +} + +public enum HorizontalAlignment +{ + Left, + Center, + Right, +} + +``` +// File: src\Kuddle.Net.Tests\Serialization\Models\TelemetrySnapshot.cs`$langusing Kuddle.Serialization; + +namespace Kuddle.Tests.Serialization.Models; + +public class TelemetrySnapshot +{ + // [KdlNode("id")] + public Guid SnapshotId { get; set; } + public DateTimeOffset CapturedAt { get; set; } + + // Dictionary with complex values + // [KdlNodeDictionary("services")] + public Dictionary Services { get; set; } = new(); + + // Dictionary of dictionaries + // [KdlNodeDictionary("tags")] + public Dictionary> GlobalTags { get; set; } = new(); + + // [KdlNode("env")] + public EnvironmentInfo Environment { get; set; } = new(); + + [KdlNodeCollection("events", "event")] + public List Events { get; set; } = new(); + + // Arbitrary metadata bucket + public Dictionary Metadata { get; set; } = new(); +} + +// [KdlType("info")] +public class ServiceInfo +{ + // [KdlNode] + public string Name { get; set; } = string.Empty; + + // [KdlNode] + public ServiceStatus Status { get; set; } + + // [KdlNode] + public VersionInfo Version { get; set; } = new(); + + // Dictionary with primitive values + public Dictionary Metrics { get; set; } = new(); + + // Dictionary with list values + public Dictionary> Dependencies { get; set; } = new(); + + public List Endpoints { get; set; } = new(); +} + +public class VersionInfo +{ + public string VersionString { get; set; } = string.Empty; + public int Major { get; set; } + public int Minor { get; set; } + public int Patch { get; set; } + + public DateTime? BuildDate { get; set; } +} + +public class DependencyInfo +{ + public string DependencyName { get; set; } = string.Empty; + public DependencyType Type { get; set; } + + // Nullable to test optional fields + public TimeSpan? Latency { get; set; } + + public Dictionary Properties { get; set; } = new(); +} + +public class EndpointInfo +{ + public string Route { get; set; } = string.Empty; + public HttpMethod Method { get; set; } + + public bool RequiresAuth { get; set; } + + // Dictionary keyed by status code + public Dictionary ResponseProfiles { get; set; } = new(); +} + +public class ResponseProfile +{ + public int StatusCode { get; set; } + public string Description { get; set; } = string.Empty; + + public Dictionary Headers { get; set; } = new(); + + // Nested complex object + public PayloadSchema? Payload { get; set; } +} + +public class PayloadSchema +{ + public string ContentType { get; set; } = string.Empty; + + // Dictionary representing schema-like data + public Dictionary Fields { get; set; } = new(); +} + +public class FieldDefinition +{ + public string Type { get; set; } = string.Empty; + public bool Required { get; set; } + + // Recursive-ish structure + public Dictionary? SubFields { get; set; } +} + +public class EventRecord +{ + // [KdlProperty] + public Guid EventId { get; set; } + + // [KdlProperty] + public DateTimeOffset Timestamp { get; set; } + + // [KdlProperty] + public EventSeverity Severity { get; set; } + + // [KdlProperty] + public string Message { get; set; } = string.Empty; + + // Heterogeneous dictionary + // [KdlNode] + public Dictionary Context { get; set; } = new(); +} + +[KdlType("env-info")] +public class EnvironmentInfo +{ + // [KdlProperty] + public string Name { get; set; } = string.Empty; + + // [KdlProperty] + public string Region { get; set; } = string.Empty; + + // Dictionary keyed by machine name + // [KdlNode] + public Dictionary Machines { get; set; } = new(); +} + +public class MachineInfo +{ + public string Os { get; set; } = string.Empty; + public int CpuCores { get; set; } + public long MemoryBytes { get; set; } + + public Dictionary Labels { get; set; } = new(); +} + +public enum ServiceStatus +{ + Unknown, + Healthy, + Degraded, + Unavailable, +} + +public enum DependencyType +{ + Database, + HttpService, + MessageQueue, + Cache, +} + +public enum EventSeverity +{ + Trace, + Info, + Warning, + Error, + Critical, +} + +public enum HttpMethod +{ + Get, + Post, + Put, + Delete, + Patch, +} + +``` +// File: src\Kuddle.Net.Tests\Serialization\DocumentToObjectTests.cs`$langusing Kuddle.Exceptions; +using Kuddle.Serialization; + +namespace Kuddle.Tests.Serialization; + +public class DocumentToObjectTests +{ + class AppConfig + { + [KdlNode("plugin")] + public List Plugins { get; set; } = new(); + + [KdlNode("logging")] + public LogSettings? Logging { get; set; } + + [KdlNode("experiments")] + public Experiments? Experiments { get; set; } + } + + class Plugin + { + [KdlArgument(0)] + public string Name { get; set; } = ""; + } + + class LogSettings + { + [KdlNode("level")] + public string LogLevel { get; set; } = "info"; + } + + class Experiments + { + [KdlProperty("enabled")] + public bool Enabled { get; set; } + } + + // --- Tests --- + + [Test] + public async Task Deserialize_Document_MapsListsAndSingleObjects() + { + var kdl = """ + plugin "Analytics" + plugin "Authentication" + + logging { + level "debug" + } + """; + + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Plugins).Count().IsEqualTo(2); + await Assert.That(result.Plugins[0].Name).IsEqualTo("Analytics"); + await Assert.That(result.Plugins[1].Name).IsEqualTo("Authentication"); + + await Assert.That(result.Logging).IsNotNull(); + + await Assert.That(result.Logging!.LogLevel).IsEqualTo("debug"); + } + + [Test] + public async Task Deserialize_EmptyDocument_ReturnsInitializedObject() + { + var kdl = ""; + + var result = KdlSerializer.Deserialize(kdl); + + await Assert.That(result).IsNotNull(); + await Assert.That(result.Plugins).IsEmpty(); + await Assert.That(result.Logging).IsNull(); + } + + [Test] + public async Task Deserialize_PartialMatch_IgnoresUnmappedNodes() + { + var kdl = """ + plugin "Core" + + // This node is not in AppConfig + garbage_data { + ignore me + } + """; + + var result = KdlSerializer.Deserialize(kdl); + + await Assert.That(result.Plugins).Count().IsEqualTo(1); + await Assert.That(result.Plugins[0].Name).IsEqualTo("Core"); + + await Assert.That(result.Logging).IsNull(); + } + + [Test] + public async Task Deserialize_NestedStructure_WithProperties() + { + var kdl = + @" + experiments enabled=#true + "; + + var result = KdlSerializer.Deserialize(kdl); + + await Assert.That(result.Experiments).IsNotNull(); + await Assert.That(result.Experiments!.Enabled).IsTrue(); + } + + [Test] + public async Task Deserialize_AmbiguousSingleNode_ThrowsOrHandles() + { + var kdl = + @" + logging { level ""info"" } + logging { level ""error"" } + "; + + await Assert.ThrowsAsync(async () => + { + KdlSerializer.Deserialize(kdl); + }); + } +} + +``` +// File: src\Kuddle.Net.Tests\Serialization\KdlTypeInfoTests.cs`$langusing Kuddle.Exceptions; +using Kuddle.Serialization; + +namespace Kuddle.Tests.Serialization; + +public class KdlTypeInfoTests +{ + [Test] + public async Task ArgumentContinuity_MissingIndex_Throws() + { + var ex = Assert.Throws(() => + KdlTypeInfo.For() + ); + await Assert.That(ex).IsNotNull(); + } + + [Test] + public async Task PropertyAndNode_Mapping_Works() + { + var info = KdlTypeInfo.For(); + await Assert.That(info.Properties.Count).IsEqualTo(1); + await Assert.That(info.Children.Count).IsEqualTo(1); + } + + [Test] + public async Task NodeDictionary_Property_IsDetected() + { + var info = KdlTypeInfo.For(); + await Assert.That(info.Dictionaries.Count).IsEqualTo(1); + + var dictInfo = KdlTypeInfo.For>(); + await Assert.That(dictInfo.IsDictionary).IsTrue(); + await Assert.That(dictInfo.DictionaryDef).IsNotNull(); + await Assert.That(dictInfo.DictionaryDef!.ValueType).IsEqualTo(typeof(string)); + } + + [Test] + public async Task CollectionDetection_Works_And_Dictionaries_Are_Not_Collections() + { + var arrInfo = KdlTypeInfo.For(); + await Assert.That(arrInfo.CollectionElementType).IsEqualTo(typeof(int)); + await Assert.That(arrInfo.IsIEnumerable).IsTrue(); + + var dictInfo = KdlTypeInfo.For>(); + await Assert.That(dictInfo.IsIEnumerable).IsFalse(); + } + + [Test] + public async Task KdlTypeAttribute_Overrides_NodeName() + { + var info = KdlTypeInfo.For(); + await Assert.That(info.NodeName).IsEqualTo("my-node"); + } + + [Test] + public async Task IsStrictNode_Behaves_Correctly() + { + var plain = KdlTypeInfo.For(); + await Assert.That(plain.IsStrictNode).IsFalse(); + + var withType = KdlTypeInfo.For(); + await Assert.That(withType.IsStrictNode).IsTrue(); + + var withProp = KdlTypeInfo.For(); + await Assert.That(withProp.IsStrictNode).IsTrue(); + } + + [Test] + public async Task For_Is_Cached() + { + var a = KdlTypeInfo.For(); + var b = KdlTypeInfo.For(); + await Assert.That(object.ReferenceEquals(a, b)).IsTrue(); + } + + [Test] + public async Task KdlTypeInfo_Throws_ForEveryAttributeCombination() + { + var typesToTest = new[] + { + typeof(Conflict_Property_Node), + typeof(Conflict_Property_Argument), + typeof(Conflict_Node_NodeDict), + }; + + foreach (var t in typesToTest) + { + var exception = Assert.Throws(() => KdlTypeInfo.For(t)); + await Assert.That(exception).IsNotNull(); + } + } + + // Test types + + private class Conflict_Property_Node + { + [KdlProperty("k")] + [KdlNode("n")] + public string? Prop { get; set; } + } + + private class Conflict_Property_Argument + { + [KdlProperty("k")] + [KdlArgument(0)] + public string? Prop { get; set; } + } + + private class Conflict_Node_NodeDict + { + [KdlNode("n")] + [KdlNodeDictionary("d")] + public string? Prop { get; set; } + } + + private class ArgContinuityBad + { + [KdlArgument(0)] + public string? A { get; set; } + + [KdlArgument(2)] + public string? B { get; set; } + } + + private class PropertyNodeMap + { + [KdlProperty("k")] + public string? Prop { get; set; } + + [KdlNode("n")] + public string? Child { get; set; } + } + + private class NodeDictHolder + { + [KdlNodeDictionary("d")] + public Dictionary? Dict { get; set; } + } + + [KdlType("my-node")] + private class CustomName { } + + private class PlainDocument { } +} + +``` +// File: src\Kuddle.Net.Tests\Serialization\KuddleParsingTests.cs`$langusing Kuddle.Serialization; + +namespace Kuddle.Tests.Serialization; + +public class KuddleParsingTests +{ + readonly KdlWriterOptions _options = new KdlWriterOptions() with { RoundTrip = false }; + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.BlockCommentTestData) + )] + public async Task TestBlockComment(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.EsclineTestData) + )] + public async Task TestEscline(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.MultilineRawStringTestData) + )] + public async Task TestMultilineRawString(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.MultilineStringTestData) + )] + public async Task TestMultilineString(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.ArgTestData) + )] + public async Task TestArg(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); // Assumes KdlDocument has ToString() for serialization + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.BareIdentTestData) + )] + public async Task TestBareIdent(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.BinaryTestData) + )] + public async Task TestBinary(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.BlankTestData) + )] + public async Task TestBlank(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.BooleanTestData) + )] + public async Task TestBoolean(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.BomTestData) + )] + public async Task TestBom(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.CommentTestData) + )] + public async Task TestComment(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.CommentedTestData) + )] + public async Task TestCommented(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.EmptyTestData) + )] + public async Task TestEmpty(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.EscTestData) + )] + public async Task TestEsc(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.FalseTestData) + )] + public async Task TestFalse(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.IllegalTestData) + )] + public async Task TestIllegal(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.BareEmojiTestData) + )] + public async Task TestBareEmoji(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.AllTestData) + )] + public async Task TestAll(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.AsteriskTestData) + )] + public async Task TestAsterisk(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.BracesTestData) + )] + public async Task TestBraces(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.ChevronsTestData) + )] + public async Task TestChevrons(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.CommaTestData) + )] + public async Task TestComma(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.CrlfTestData) + )] + public async Task TestCrlf(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.DashTestData) + )] + public async Task TestDash(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.DotTestData) + )] + public async Task TestDot(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.EmojiTestData) + )] + public async Task TestEmoji(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.EofTestData) + )] + public async Task TestEof(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.ErrTestData) + )] + public async Task TestErr(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.EscapedTestData) + )] + public async Task TestEscaped(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.HexTestData) + )] + public async Task TestHex(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.InitialTestData) + )] + public async Task TestInitial(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.IntTestData) + )] + public async Task TestInt(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } + + [Test] + [MethodDataSource( + typeof(KuddleParsingTestDataSources), + nameof(KuddleParsingTestDataSources.JustTestData) + )] + public async Task TestJust(ParsingTestData testData) + { + if (File.Exists(testData.ExpectedFile)) + { + var inputKdl = await File.ReadAllTextAsync(testData.InputFile); + var doc = KdlReader.Read(inputKdl); + var expected = await File.ReadAllTextAsync(testData.ExpectedFile); + expected = expected.Replace("\r\n", "\n"); + var serialized = doc.ToString(_options); + await Assert.That(serialized).IsEqualTo(expected); + } + else + { + Assert.Throws(() => KdlReader.Read(testData.InputFile)); + } + } +} + +public record ParsingTestData(string InputFile, string ExpectedFile); + +public static class KuddleParsingTestDataSources +{ + public static IEnumerable> ArgTestData() => GetTestData("arg"); + + public static IEnumerable> BlockCommentTestData() => + GetTestData("block_comment"); + + public static IEnumerable> EsclineTestData() => GetTestData("escline_"); + + public static IEnumerable> MultilineRawStringTestData() => + GetTestData("multiline_raw_string"); + + public static IEnumerable> MultilineStringTestData() => + GetTestData("multiline_string"); + + public static IEnumerable> BareIdentTestData() => + GetTestData("bare_ident"); + + public static IEnumerable> BinaryTestData() => GetTestData("binary"); + + public static IEnumerable> BlankTestData() => GetTestData("blank"); + + public static IEnumerable> BooleanTestData() => GetTestData("boolean"); + + public static IEnumerable> BomTestData() => GetTestData("bom"); + + public static IEnumerable> CommentTestData() => GetTestData("comment"); + + public static IEnumerable> CommentedTestData() => + GetTestData("commented"); + + public static IEnumerable> EmptyTestData() => GetTestData("empty"); + + public static IEnumerable> EscTestData() => GetTestData("esc_"); + + public static IEnumerable> FalseTestData() => GetTestData("false"); + + public static IEnumerable> IllegalTestData() => GetTestData("illegal"); + + public static IEnumerable> BareEmojiTestData() => + GetTestData("bare_emoji"); + + public static IEnumerable> AllTestData() => GetTestData("all"); + + public static IEnumerable> AsteriskTestData() => GetTestData("asterisk"); + + public static IEnumerable> BracesTestData() => GetTestData("braces"); + + public static IEnumerable> ChevronsTestData() => GetTestData("chevrons"); + + public static IEnumerable> CommaTestData() => GetTestData("comma"); + + public static IEnumerable> CrlfTestData() => GetTestData("crlf"); + + public static IEnumerable> DashTestData() => GetTestData("dash"); + + public static IEnumerable> DotTestData() => GetTestData("dot"); + + public static IEnumerable> EmojiTestData() => GetTestData("emoji"); + + public static IEnumerable> EofTestData() => GetTestData("eof"); + + public static IEnumerable> ErrTestData() => GetTestData("err"); + + public static IEnumerable> EscapedTestData() => GetTestData("escaped"); + + public static IEnumerable> HexTestData() => GetTestData("hex"); + + public static IEnumerable> InitialTestData() => GetTestData("initial"); + + public static IEnumerable> IntTestData() => GetTestData("int"); + + public static IEnumerable> JustTestData() => GetTestData("just"); + + private static IEnumerable> GetTestData(string prefix) + { + var inputDir = "test_cases/input"; + var expectedDir = "test_cases/expected_kdl"; + var inputFiles = Directory.GetFiles(inputDir, $"{prefix}*.kdl"); + inputFiles = inputFiles.Where(x => !x.EndsWith("_fail.kdl")).ToArray(); + foreach (var inputFile in inputFiles) + { + var fileName = Path.GetFileName(inputFile); + var expectedFile = Path.Combine(expectedDir, fileName); + yield return () => new ParsingTestData(inputFile, expectedFile); + } + } +} + +``` +// File: src\Kuddle.Net.Tests\Serialization\KuddleWriterTests.cs`$langusing Kuddle.AST; +using Kuddle.Parser; +using Kuddle.Serialization; + +namespace Kuddle.Tests.Serialization; + +public class KuddleWriterTests +{ + [Test] + public async Task Write_SimpleNode_FormatsCorrectly() + { + var kdl = "node 1 2 key=\"val\""; + var doc = KdlReader.Read(kdl); + + var output = KdlWriter.Write(doc, KdlWriterOptions.Default); + + await Assert.That(output.Trim()).IsEqualTo("node 1 2 key=\"val\""); + } + + [Test] + public async Task Write_NestedStructure_IndentsCorrectly() + { + var kdl = "parent { child; }"; + var doc = KdlReader.Read(kdl); + + var output = KdlWriter.Write(doc); + + var expected = @"parent { + child; +} +".Replace("\r\n", "\n"); + await Assert.That(output).IsEqualTo(expected); + } + + [Test] + public async Task Write_ComplexString_EscapesCorrectly() + { + var kdl = "node \"line1\\nline2\""; + var doc = KdlReader.Read(kdl); + + var output = KdlWriter.Write(doc); + + await Assert.That(output.Trim()).IsEqualTo("node \"line1\\nline2\""); + } + + [Test] + public async Task Write_BareIdentifier_QuotesIfInvalid() + { + var doc = new KdlDocument + { + Nodes = [new KdlNode(new KdlString("node name", StringKind.Quoted))], + }; + + var output = KdlWriter.Write(doc); + + await Assert.That(output.Trim()).IsEqualTo("\"node name\""); + } +} + +``` +// File: src\Kuddle.Net.Tests\Serialization\NodeToObjectTests.cs`$langusing Kuddle.Exceptions; +using Kuddle.Serialization; + +namespace Kuddle.Tests.Serialization; + +public class NodeToObjectTests +{ + [KdlType("database")] + class DbConfig + { + [KdlArgument(0)] + public string Name { get; set; } = ""; + + [KdlProperty("port")] + public int Port { get; set; } + + [KdlProperty("enabled")] + public bool Enabled { get; set; } = true; + } + + public class Server + { + [KdlArgument(0)] + public string Host { get; set; } = ""; + } + + [Test] + public async Task Deserialize_ExplicitName_MapsArgumentsAndProperties() + { + var kdl = "database production port=5432 enabled=#false"; + + var result = KdlSerializer.Deserialize(kdl); + + await Assert.That(result).IsNotNull(); + await Assert.That(result.Name).IsEqualTo("production"); + await Assert.That(result.Port).IsEqualTo(5432); + await Assert.That(result.Enabled).IsFalse(); + } + + [Test] + public async Task Deserialize_ImplicitName_UsesClassName() + { + var kdl = "Server \"localhost\""; + + var result = KdlSerializer.Deserialize(kdl); + + await Assert.That(result).IsNotNull(); + await Assert.That(result.Host).IsEqualTo("localhost"); + } + + [Test] + public async Task Deserialize_MissingProperty_UsesDefault() + { + var kdl = "database \"local\" port=3000"; + + var result = KdlSerializer.Deserialize(kdl); + + await Assert.That(result.Name).IsEqualTo("local"); + await Assert.That(result.Enabled).IsTrue(); + } + + [Test] + public async Task Deserialize_MismatchNodeName_Throws() + { + var kdl = "table \"production\" port=5432"; + + await Assert.ThrowsAsync(async () => + { + KdlSerializer.Deserialize(kdl); + }); + } + + [Test] + public async Task Deserialize_MultipleRootNodes_Throws() + { + var kdl = + @" + database ""primary"" port=5432 + database ""replica"" port=5433 + "; + + await Assert.ThrowsAsync(async () => + { + KdlSerializer.Deserialize(kdl); + }); + } + + [Test] + public async Task Deserialize_TypeMismatch_Throws() + { + var kdl = "database \"db\" port=\"not-a-number\""; + + await Assert.ThrowsAsync(async () => + { + KdlSerializer.Deserialize(kdl); + }); + } + + [Test] + public async Task Deserialize_DocumentRoot_RejectsProperties() + { + var kdl = "database \"db\" port=5432 unknown_prop=123"; + + var result = KdlSerializer.Deserialize(kdl); + await Assert.That(result.Port).IsEqualTo(5432); + } +} + +``` +// File: src\Kuddle.Net.Tests\Serialization\ObjectMapperTests.cs`$langusing Kuddle.AST; +using Kuddle.Serialization; +using Kuddle.Tests.Serialization.Models; + +namespace Kuddle.Tests.Serialization; + +/// +/// Tests for KdlSerializer.Deserialize() and KdlSerializer.Serialize() +/// which map between KDL documents and strongly-typed C# objects. +/// +public class ObjectMapperTests +{ + #region Basic Mapping Tests + + [Test] + public async Task DeserializeSimpleObject_WithArgument_MapsCorrectly() + { + // Arrange + var kdl = """ + package "my-lib" + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Name).IsEqualTo("my-lib"); + } + + [Test] + public async Task DeserializeSimpleObject_WithProperties_MapsCorrectly() + { + // Arrange + var kdl = """ + package "my-lib" version="1.0.0" description="A cool library" + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Name).IsEqualTo("my-lib"); + await Assert.That(result.Version).IsEqualTo("1.0.0"); + await Assert.That(result.Description).IsEqualTo("A cool library"); + } + + [Test] + public async Task DeserializeObject_WithMissingOptionalProperty_UsesDefault() + { + // Arrange + var kdl = """ + package "my-lib" version="1.0.0" + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Description).IsNull(); + await Assert.That(result.Name).IsEqualTo("my-lib"); + await Assert.That(result.Version).IsEqualTo("1.0.0"); + } + + #endregion + + #region Nested Children Tests + + [Test] + public async Task DeserializeObject_WithChildren_MapsChildListCorrectly() + { + // Arrange + var kdl = """ + project "my-app" version="2.0.0" { + dependency "lodash" version="4.17.21" + dependency "react" version="18.0.0" + } + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Name).IsEqualTo("my-app"); + await Assert.That(result.Version).IsEqualTo("2.0.0"); + await Assert.That(result.Dependencies).Count().IsEqualTo(2); + await Assert.That(result.Dependencies[0].Package).IsEqualTo("lodash"); + await Assert.That(result.Dependencies[1].Package).IsEqualTo("react"); + } + + [Test] + public async Task DeserializeObject_WithMultipleChildTypes_MapsEachTypeToCorrectList() + { + // Arrange + var kdl = """ + project "my-app" { + dependency "lodash" version="4.0" + devDependency "jest" version="27.0" + dependency "react" version="18.0" + devDependency "typescript" version="4.0" + } + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Dependencies).Count().IsEqualTo(2); + await Assert.That(result.DevDependencies).Count().IsEqualTo(2); + await Assert.That(result.Dependencies[0].Package).IsEqualTo("lodash"); + await Assert.That(result.DevDependencies[0].Package).IsEqualTo("jest"); + } + + [Test] + public async Task DeserializeObject_WithNoChildren_InitializesEmptyLists() + { + // Arrange + var kdl = """ + project "standalone" + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Dependencies).IsEmpty(); + await Assert.That(result.DevDependencies).IsEmpty(); + } + + #endregion + + #region Numeric Type Tests + + [Test] + public async Task DeserializeObject_WithIntProperty_MapsCorrectly() + { + // Arrange + var kdl = """ + settings timeout=5000 + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Timeout).IsEqualTo(5000); + } + + [Test] + public async Task DeserializeObject_WithLongProperty_MapsCorrectly() + { + // Arrange + var kdl = """ + settings retries=9223372036854775807 + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Retries).IsEqualTo(long.MaxValue); + } + + [Test] + public async Task DeserializeObject_WithDoubleProperty_MapsCorrectly() + { + // Arrange + var kdl = """ + settings ratio=3.14159 + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Ratio).IsEqualTo(3.14159).Within(0.00001); + } + + [Test] + public async Task DeserializeObject_WithBoolProperty_MapsCorrectly() + { + // Arrange + var kdl = """ + settings enabled=#true + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Enabled).IsTrue(); + } + + [Test] + public async Task DeserializeObject_WithAllNumericTypes_MapsAllCorrectly() + { + // Arrange + var kdl = """ + settings timeout=500 retries=10 ratio=0.95 enabled=#true + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Timeout).IsEqualTo(500); + await Assert.That(result.Retries).IsEqualTo(10); + await Assert.That(result.Ratio).IsEqualTo(0.95).Within(0.0001); + await Assert.That(result.Enabled).IsTrue(); + } + + #endregion + + #region Type Annotation Tests + + [Test] + public async Task DeserializeObject_WithGuidProperty_ParsesUuidTypeAnnotation() + { + // Arrange + var id = Guid.NewGuid(); + var kdl = $""" + user "alice" id=(uuid)"{id:D}" + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Username).IsEqualTo("alice"); + await Assert.That(result.Id).IsEqualTo(id); + } + + [Test] + public async Task DeserializeObject_WithDateTimeProperty_ParsesDateTimeTypeAnnotation() + { + // Arrange + var now = DateTimeOffset.UtcNow; + var kdl = $""" + user "bob" createdAt=(date-time)"{now:O}" + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.CreatedAt).IsEqualTo(now); + } + + #endregion + + #region Serialization Tests + + [Test] + public async Task SerializeObject_WithSimpleProperties_GeneratesValidKdl() + { + // Arrange + var obj = new Package + { + Name = "my-lib", + Version = "1.0.0", + Description = "A library", + }; + + // Act + var kdl = KdlSerializer.Serialize(obj); + + // Assert + await Assert.That(kdl).Contains("package"); + await Assert.That(kdl).Contains("my-lib"); + await Assert.That(kdl).Contains("version"); + await Assert.That(kdl).Contains("1.0.0"); + } + + [Test] + public async Task SerializeObject_WithNestedChildren_GeneratesValidKdl() + { + // Arrange + var obj = new Project + { + Name = "my-app", + Version = "1.0.0", + Dependencies = + [ + new Dependency { Package = "lodash", Version = "4.17" }, + new Dependency { Package = "react", Version = "18.0" }, + ], + }; + + // Act + var kdl = KdlSerializer.Serialize(obj); + + // Assert + await Assert.That(kdl).Contains("project"); + await Assert.That(kdl).Contains("my-app"); + await Assert.That(kdl).Contains("dependency"); + await Assert.That(kdl).Contains("lodash"); + await Assert.That(kdl).Contains("react"); + } + + [Test] + public async Task RoundTrip_SimpleObject_PreservesData() + { + // Arrange + var original = new Package + { + Name = "test-pkg", + Version = "2.5.0", + Description = "Test", + }; + + // Act + var kdl = KdlSerializer.Serialize(original); + var deserialized = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(deserialized.Name).IsEqualTo(original.Name); + await Assert.That(deserialized.Version).IsEqualTo(original.Version); + await Assert.That(deserialized.Description).IsEqualTo(original.Description); + } + + [Test] + public async Task RoundTrip_NestedObject_PreservesData() + { + // Arrange + var original = new Project + { + Name = "my-app", + Version = "1.2.3", + Dependencies = + [ + new Dependency + { + Package = "dep1", + Version = "1.0", + Optional = false, + }, + new Dependency + { + Package = "dep2", + Version = "2.0", + Optional = true, + }, + ], + }; + + // Act + var kdl = KdlSerializer.Serialize(original); + var deserialized = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(deserialized.Name).IsEqualTo(original.Name); + await Assert.That(deserialized.Dependencies).Count().IsEqualTo(2); + await Assert.That(deserialized.Dependencies[0].Package).IsEqualTo("dep1"); + await Assert.That(deserialized.Dependencies[1].Optional).IsTrue(); + } + + #endregion + + #region Edge Cases + + [Test] + public async Task DeserializeObject_WithEmptyString_MapsCorrectly() + { + // Arrange + var kdl = """ + package "" version="" + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Name).IsEqualTo(""); + await Assert.That(result.Version).IsEqualTo(""); + } + + [Test] + public async Task DeserializeObject_WithSpecialCharactersInString_MapsCorrectly() + { + // Arrange + var kdl = """ + package "my-lib@1.0" description="Unicode: 你好世界 🚀" + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Name).IsEqualTo("my-lib@1.0"); + await Assert.That(result.Description).Contains("世界"); + } + + [Test] + public async Task DeserializeObject_WithNegativeNumbers_MapsCorrectly() + { + // Arrange + var kdl = """ + settings timeout=-1000 ratio=-3.14 + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Timeout).IsEqualTo(-1000); + await Assert.That(result.Ratio).IsEqualTo(-3.14).Within(0.0001); + } + + [Test] + public async Task DeserializeObject_WithHexNumbers_MapsCorrectly() + { + // Arrange + var kdl = """ + settings timeout=0xFF retries=0x10 + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Timeout).IsEqualTo(255); + await Assert.That(result.Retries).IsEqualTo(16); + } + + #endregion + + #region Error Handling Tests + + [Test] + public async Task DeserializeObject_WithWrongNodeName_ThrowsException() + { + // Arrange + var kdl = """ + application "wrong-node" + """; + + // Act & Assert + await Assert + .That(async () => KdlSerializer.Deserialize(kdl)) + .Throws(); + } + + [Test] + public async Task DeserializeToDictionary_MapsCorrectly() + { + // Arrange + var kdl = """ +// 1. The "themes" dictionary (Key = Theme Name) +themes { + // Key: "dark-mode" -> Value: Theme (which is also a Dictionary) + dark-mode { + // Key: "window" -> Value: ElementStyle + window { + align v="Top" h="Left" + border color="#FFFFFF" style="solid" + } + + // Key: "button" -> Value: ElementStyle + button { + header text="Click Me" + align v="Middle" h="Center" + } + } + + // Key: "high-contrast" + high-contrast { + window { + border color="#FFFF00" + } + } +} + +// 2. The "layouts" dictionary (Key = Layout Name) +layouts { + // Key: "dashboard" -> Value: LayoutDefinition + dashboard section="main-view" size=1 split="rows" { + + // Recursive List (Children) + child section="top-bar" size=1 + + child section="content-area" size=4 split="columns" { + child section="sidebar" size=1 + child section="grid" size=3 + } + } +} +"""; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + var dashboard = result.Layouts["dashboard"]; + await Assert.That(dashboard).IsNotNull(); + await Assert.That(dashboard.Section).IsEqualTo("main-view"); + await Assert.That(dashboard.Ratio).IsEqualTo(1); + await Assert.That(dashboard.SplitDirection).IsEqualTo("rows"); + + var contentArea = dashboard.Children.FirstOrDefault(c => c.Section == "content-area"); + await Assert.That(contentArea!.Ratio).IsEqualTo(4); + } + + [Test] + public async Task DeserializeToEnumerable_WithDifferentNodeNames_ThrowsException() + { + // Arrange + var kdl = """ + package "my-lib" version="1.0.0" + reference "my-dep1" version="2.1.0" + node "my-dep2" version="3.2.1" + """; + + // Act & Assert + await Assert + .That(async () => KdlSerializer.Deserialize>(kdl)) + .Throws(); + } + + [Test] + public async Task DeserializeObject_WithInvalidNumericValue_ThrowsException() + { + // Arrange + var kdl = """ + settings timeout="not-a-number" + """; + + // Act & Assert + await Assert + .That(async () => KdlSerializer.Deserialize(kdl)) + .Throws(); + } + + [Test] + public async Task DeserializeObject_WithMissingRequiredArgument_ThrowsException() + { + // Arrange + var kdl = """ + package + """; + + // Act & Assert + await Assert + .That(async () => KdlSerializer.Deserialize(kdl)) + .Throws(); + } + + #endregion + + #region Test Models + + /// Simple class with properties and arguments. + public class Package + { + [KdlArgument(0)] + public string Name { get; set; } = string.Empty; + + [KdlProperty("version")] + public string? Version { get; set; } + + [KdlProperty("description")] + public string? Description { get; set; } + } + + /// Class with nested children (list of child nodes). + public class Project + { + [KdlArgument(0)] + public string Name { get; set; } = string.Empty; + + [KdlProperty("version")] + public string Version { get; set; } = "1.0.0"; + + [KdlNode("dependency")] + public List Dependencies { get; set; } = []; + + [KdlNode("devDependency")] + public List DevDependencies { get; set; } = []; + } + + public class Dependency + { + [KdlArgument(0)] + public string Package { get; set; } = string.Empty; + + [KdlProperty("version")] + public string Version { get; set; } = "*"; + + [KdlProperty("optional")] + public bool Optional { get; set; } + } + + /// Class with numeric properties. + public class Settings + { + [KdlProperty("timeout")] + public int Timeout { get; set; } + + [KdlProperty("retries")] + public long Retries { get; set; } + + [KdlProperty("ratio")] + public double Ratio { get; set; } + + [KdlProperty("enabled")] + public bool Enabled { get; set; } + } + + /// Class with type annotations (uuid, date-time). + public class User + { + [KdlArgument(0)] + public string Username { get; set; } = string.Empty; + + [KdlProperty("id")] + public Guid Id { get; set; } + + [KdlProperty("createdAt")] + public DateTimeOffset CreatedAt { get; set; } + } + + /// Class demonstrating polymorphism with type discriminators. + public class Resource + { + [KdlArgument(0)] + public string Name { get; set; } = string.Empty; + + [KdlProperty("type")] + public string Type { get; set; } = string.Empty; // Used as discriminator + } + + public class FileResource : Resource + { + [KdlProperty("path")] + public string Path { get; set; } = string.Empty; + } + + public class UrlResource : Resource + { + [KdlProperty("url")] + public string Url { get; set; } = string.Empty; + } + + #endregion +} + +``` +// File: src\Kuddle.Net.Tests\Serialization\TelemetrySnapshotTests.cs`$langusing System.Diagnostics; +using Kuddle.Serialization; +using Kuddle.Tests.Serialization.Models; + +namespace Kuddle.Tests.Serialization; + +public class TelemetrySnapshotTests +{ + [Test] + public async Task RoundTrip_TelemetrySnapshot_SerializesAndDeserializes() + { + var original = new TelemetrySnapshot + { + SnapshotId = Guid.NewGuid(), + CapturedAt = DateTimeOffset.UtcNow, + Services = + { + ["svc1"] = new ServiceInfo + { + Name = "Inventory", + Status = ServiceStatus.Healthy, + Version = new VersionInfo + { + VersionString = "1.2.3", + Major = 1, + Minor = 2, + Patch = 3, + }, + Metrics = { ["cpu"] = 0.75 }, + Dependencies = + { + ["dep-type"] = new List + { + new() { DependencyName = "db", Type = DependencyType.Database }, + }, + }, + Endpoints = new List + { + new() + { + Route = "/items", + Method = Models.HttpMethod.Get, + RequiresAuth = true, + }, + }, + }, + }, + GlobalTags = + { + ["global"] = new Dictionary + { + ["region"] = "uk-south", + ["timezone"] = "gmt", + }, + }, + Environment = new EnvironmentInfo + { + Name = "prod", + Region = "uk-west", + Machines = + { + ["host1"] = new MachineInfo + { + Os = "linux", + CpuCores = 4, + MemoryBytes = 8L * 1024 * 1024 * 1024, + }, + }, + }, + Events = new List + { + new EventRecord + { + EventId = Guid.NewGuid(), + Timestamp = DateTimeOffset.UtcNow, + Severity = EventSeverity.Info, + Message = "Started", + }, + new EventRecord + { + EventId = Guid.NewGuid(), + Timestamp = DateTimeOffset.UtcNow.AddSeconds(10), + Severity = EventSeverity.Warning, + Message = "Slow start", + }, + }, + }; + + var kdl = KdlSerializer.Serialize(original); + + Debug.WriteLine(kdl); + + var deserialized = KdlSerializer.Deserialize(kdl); + + await Assert.That(deserialized).IsNotNull(); + await Assert.That(deserialized.SnapshotId).IsEqualTo(original.SnapshotId); + await Assert.That(deserialized.CapturedAt).IsEqualTo(original.CapturedAt); + await Assert.That(deserialized.Services).ContainsKey("svc1"); + await Assert.That(deserialized.Services["svc1"].Name).IsEqualTo("Inventory"); + await Assert.That(deserialized.Services["svc1"].Metrics["cpu"]).IsEqualTo(0.75); + await Assert.That(deserialized.GlobalTags["global"]["region"]).IsEqualTo("uk-south"); + await Assert.That(deserialized.GlobalTags["global"]["timezone"]).IsEqualTo("gmt"); + await Assert.That(deserialized.Environment.Name).IsEqualTo("prod"); + await Assert.That(deserialized.Environment.Region).IsEqualTo("uk-west"); + await Assert.That(deserialized.Environment.Machines).ContainsKey("host1"); + await Assert.That(deserialized.Events).Count().IsEqualTo(2); + } +} + +``` +// File: src\Kuddle.Net.Tests\Serialization\TypeAnnotationTests.cs`$lang// using System; +// using Kuddle.Serialization; + +// namespace Kuddle.Tests.Serialization; + +// public class TypeAnnotationTests +// { +// #region Test Models + +// public class PersonWithAnnotations +// { +// [KdlArgument(0, "uuid")] +// public Guid Id { get; set; } + +// [KdlProperty("name")] +// public string Name { get; set; } = ""; + +// [KdlProperty("created", "date-time")] +// public DateTimeOffset CreatedAt { get; set; } + +// [KdlProperty("age", "i32")] +// public int Age { get; set; } +// } + +// public class ProductWithTypeAnnotations +// { +// [KdlArgument(0)] +// public string Name { get; set; } = ""; + +// [KdlProperty("sku", "uuid")] +// public Guid Sku { get; set; } + +// [KdlProperty("price", "decimal64")] +// public decimal Price { get; set; } + +// [KdlProperty("stock", "u32")] +// public uint StockCount { get; set; } +// } + +// public class EventWithChildAnnotations +// { +// [KdlArgument(0)] +// public string Name { get; set; } = ""; + +// [KdlNode("timestamp", "date-time")] +// public DateTimeOffset Timestamp { get; set; } + +// [KdlNode("correlation-id", "uuid")] +// public Guid CorrelationId { get; set; } +// } + +// #endregion + +// [Test] +// public async Task Serialize_WithUuidTypeAnnotation_WritesAnnotation() +// { +// // Arrange +// var person = new PersonWithAnnotations +// { +// Id = Guid.Parse("123e4567-e89b-12d3-a456-426614174000"), +// Name = "Alice", +// CreatedAt = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), +// Age = 30, +// }; + +// // Act +// var kdl = KdlSerializer.Serialize(person); + +// // Assert +// await Assert.That(kdl).Contains("(uuid)\"123e4567-e89b-12d3-a456-426614174000\""); +// await Assert.That(kdl).Contains("(date-time)\"2024-01-01T00:00:00.0000000+00:00\""); +// await Assert.That(kdl).Contains("(i32)30"); +// } + +// [Test] +// public async Task Deserialize_WithUuidTypeAnnotation_ParsesCorrectly() +// { +// // Arrange +// var kdl = """ +// personwithannotations (uuid)"123e4567-e89b-12d3-a456-426614174000" name="Alice" created=(date-time)"2024-01-01T00:00:00.0000000+00:00" age=(i32)30 +// """; + +// // Act +// var person = KdlSerializer.Deserialize(kdl); + +// // Assert +// await Assert.That(person.Id).IsEqualTo(Guid.Parse("123e4567-e89b-12d3-a456-426614174000")); +// await Assert.That(person.Name).IsEqualTo("Alice"); +// await Assert +// .That(person.CreatedAt) +// .IsEqualTo(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); +// await Assert.That(person.Age).IsEqualTo(30); +// } + +// [Test] +// public async Task Serialize_WithMixedTypeAnnotations_WritesAllAnnotations() +// { +// // Arrange +// var product = new ProductWithTypeAnnotations +// { +// Name = "Widget", +// Sku = Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), +// Price = 99.99m, +// StockCount = 42, +// }; + +// // Act +// var kdl = KdlSerializer.Serialize(product); + +// // Assert +// await Assert.That(kdl).Contains("Widget"); +// await Assert.That(kdl).Contains("(uuid)\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\""); +// await Assert.That(kdl).Contains("(decimal64)99.99"); +// await Assert.That(kdl).Contains("(u32)42"); +// } + +// [Test] +// public async Task Deserialize_WithMixedTypeAnnotations_ParsesAllValues() +// { +// // Arrange +// var kdl = """ +// productwithtypeannotations "Widget" sku=(uuid)"a1b2c3d4-e5f6-7890-abcd-ef1234567890" price=(decimal64)99.99 stock=(u32)42 +// """; + +// // Act +// var product = KdlSerializer.Deserialize(kdl); + +// // Assert +// await Assert.That(product.Name).IsEqualTo("Widget"); +// await Assert +// .That(product.Sku) +// .IsEqualTo(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); +// await Assert.That(product.Price).IsEqualTo(99.99m); +// await Assert.That(product.StockCount).IsEqualTo(42u); +// } + +// [Test] +// public async Task Serialize_WithChildNodeAnnotations_WritesAnnotationsOnChildren() +// { +// // Arrange +// var evt = new EventWithChildAnnotations +// { +// Name = "UserLoggedIn", +// Timestamp = new DateTimeOffset(2024, 12, 17, 10, 30, 0, TimeSpan.Zero), +// CorrelationId = Guid.Parse("11111111-2222-3333-4444-555555555555"), +// }; + +// // Act +// var kdl = KdlSerializer.Serialize(evt); + +// // Assert +// await Assert +// .That(kdl) +// .Contains("timestamp (date-time)\"2024-12-17T10:30:00.0000000+00:00\""); +// await Assert +// .That(kdl) +// .Contains("correlation-id (uuid)\"11111111-2222-3333-4444-555555555555\""); +// } + +// [Test] +// public async Task Deserialize_WithChildNodeAnnotations_ParsesChildValues() +// { +// // Arrange +// var kdl = """ +// eventwithchildannotations "UserLoggedIn" { +// timestamp (date-time)"2024-12-17T10:30:00.0000000+00:00" +// correlation-id (uuid)"11111111-2222-3333-4444-555555555555" +// } +// """; + +// // Act +// var evt = KdlSerializer.Deserialize(kdl); + +// // Assert +// await Assert.That(evt.Name).IsEqualTo("UserLoggedIn"); +// await Assert +// .That(evt.Timestamp) +// .IsEqualTo(new DateTimeOffset(2024, 12, 17, 10, 30, 0, TimeSpan.Zero)); +// await Assert +// .That(evt.CorrelationId) +// .IsEqualTo(Guid.Parse("11111111-2222-3333-4444-555555555555")); +// } + +// [Test] +// public async Task RoundTrip_WithTypeAnnotations_PreservesValues() +// { +// // Arrange +// var original = new PersonWithAnnotations +// { +// Id = Guid.NewGuid(), +// Name = "Bob", +// CreatedAt = DateTimeOffset.UtcNow, +// Age = 25, +// }; + +// // Act +// var kdl = KdlSerializer.Serialize(original); +// var deserialized = KdlSerializer.Deserialize(kdl); + +// // Assert +// await Assert.That(deserialized.Id).IsEqualTo(original.Id); +// await Assert.That(deserialized.Name).IsEqualTo(original.Name); +// await Assert.That(deserialized.CreatedAt).IsEqualTo(original.CreatedAt); +// await Assert.That(deserialized.Age).IsEqualTo(original.Age); +// } + +// [Test] +// public async Task Deserialize_WithoutTypeAnnotationInKdl_StillWorks() +// { +// // Arrange - KDL without type annotation, but C# model has [KdlTypeAnnotation] +// // The deserializer should still work because Guid/DateTimeOffset have their own parsing logic +// var kdl = """ +// personwithannotations "123e4567-e89b-12d3-a456-426614174000" name="Alice" created="2024-01-01T00:00:00.0000000+00:00" age=30 +// """; + +// // Act +// var person = KdlSerializer.Deserialize(kdl); + +// // Assert - should parse without type annotations in the KDL +// await Assert.That(person.Id).IsEqualTo(Guid.Parse("123e4567-e89b-12d3-a456-426614174000")); +// await Assert.That(person.Name).IsEqualTo("Alice"); +// await Assert +// .That(person.CreatedAt) +// .IsEqualTo(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); +// await Assert.That(person.Age).IsEqualTo(30); +// } +// } + +``` +// File: src\Kuddle.Net.Tests\Validation\ReservedTypeValidatorTests.cs`$langusing Kuddle.AST; +using Kuddle.Exceptions; +using Kuddle.Serialization; +using Kuddle.Validation; + +namespace Kuddle.Tests.Validation; + +#pragma warning disable CS8602 // Dereference of a possibly null reference. + +public class ReservedTypeValidatorTests +{ + [Test] + public async Task Given_ValidU8_When_Validated_Then_NoExceptionThrown() + { + // Arrange + var doc = Parse("node 255"); + + // Act & Assert + await Assert.That(() => KdlReservedTypeValidator.Validate(doc)).ThrowsNothing(); + } + + [Test] + public async Task Given_OverflowingU8_When_Validated_Then_ThrowsValidationException() + { + // Arrange + var doc = Parse("node (u8)256"); + + // Act + var exception = await Assert + .That(() => KdlReservedTypeValidator.Validate(doc)) + .Throws(); + + // Assert + await Assert.That(exception.Errors).IsNotEmpty(); + await Assert.That(exception.Errors.First().Message).Contains("not a valid 'u8'"); + } + + [Test] + public async Task Given_StringForIntType_When_Validated_Then_ThrowsMismatchError() + { + // Arrange + var doc = Parse("node (u8)\"not a number\""); + + // Act + var exception = Assert.Throws(() => + KdlReservedTypeValidator.Validate(doc) + ); + + // Assert + await Assert.That(exception.Errors.First().Message).Contains("Expected a Number"); + } + + [Test] + public async Task Given_InvalidUuid_When_Validated_Then_ThrowsError() + { + // Arrange + var doc = Parse("node (uuid)\"im-not-a-uuid\""); + + // Act + var exception = Assert.Throws(() => + KdlReservedTypeValidator.Validate(doc) + ); + + // Assert + await Assert.That(exception.Errors.First().Message).Contains("not a valid 'uuid'"); + } + + [Test] + public async Task Given_NodeNameWithReservedType_When_Validated_Then_IgnoresIt() + { + // Arrange + var doc = Parse("node (u8)123"); + + // Act & Assert + await Assert.That(() => KdlReservedTypeValidator.Validate(doc)).ThrowsNothing(); + } + + private static KdlDocument Parse(string input) + { + return KdlReader.Read( + input, + KdlReaderOptions.Default with + { + ValidateReservedTypes = false, + } + ); + } +} +#pragma warning restore CS8602 // Dereference of a possibly null reference. + +``` diff --git a/.github/codebase.ps1 b/.github/codebase.ps1 new file mode 100644 index 0000000..ee3f948 --- /dev/null +++ b/.github/codebase.ps1 @@ -0,0 +1,67 @@ +# codebase.ps1 - Generate codebase documentation for AI analysis +# This script creates a comprehensive text file containing the directory structure +# and all source code files from your project's source directory for AI processing. +# +# Customize the $sourceDirectory path to match your project's structure. + +$repoRoot = git rev-parse --show-toplevel + +Write-Host "Repository root: $repoRoot" + +$sourceDirectory = Join-Path $repoRoot 'src' # Change this to your source directory +Write-Host "Source directory: $sourceDirectory" + +$outputDir = "$repoRoot/.ai/outputs" # Change this to your preferred output location +if (-not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir -Force +} + +$outputPath = Join-Path $outputDir 'codebase.txt' +Write-Host "Output path: $outputPath" + +# Build directory tree +$directoryTree = Get-ChildItem -Directory -Path $sourceDirectory -Recurse -Exclude obj, bin | ForEach-Object { + $indent = ' ' * ($_.FullName.Split('\').Length - $sourceDirectory.Split('\').Length) + "$indent- $($_.Name)" +} | Out-String + +$contextBlock = "$directoryTree`n# --- Start of Code Files ---`n`n" +Set-Content -Path $outputPath -Value $contextBlock + +# Extension -> language mapping +$languageMap = @{ + '.cs' = 'csharp' + '.ps1' = 'powershell' + '.json' = 'json' + '.xml' = 'xml' + '.yml' = 'yaml' + '.yaml' = 'yaml' + '.md' = 'markdown' + '.sh' = 'bash' + '.ts' = 'typescript' + '.js' = 'javascript' +} + +# Grab all files except bin/obj +$allFiles = Get-ChildItem -Path $sourceDirectory -Recurse -File -Include *.cs, *.ps1, *.json, *.xml, *.yml, *.yaml, *.md, *.sh, *.ts, *.js + +foreach ($file in $allFiles) { + $relativePath = $file.FullName.Substring($PWD.Path.Length + 1) + + # Determine language based on extension (default: text) + $ext = $file.Extension.ToLower() + $lang = if ($languageMap.ContainsKey($ext)) { $languageMap[$ext] } else { 'text' } + + $filePathHeader = @" +// File: $relativePath +"@ + + $codeBlockStart = @" +```$lang +"@ + $codeBlockEnd = "`n``````" + + $fileContent = Get-Content -Path $file.FullName | Out-String + $formattedContent = $filePathHeader + $codeBlockStart + $fileContent + $codeBlockEnd + Add-Content -Path $outputPath -Value $formattedContent +} \ No newline at end of file diff --git a/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs b/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs index aae16e1..9a7a1f9 100644 --- a/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs @@ -1,144 +1,144 @@ -using Kuddle.Exceptions; -using Kuddle.Serialization; - -namespace Kuddle.Tests.Serialization; - -public class KdlTypeInfoTests -{ - [Test] - public async Task ArgumentContinuity_MissingIndex_Throws() - { - var ex = Assert.Throws(() => - KdlTypeInfo.For() - ); - await Assert.That(ex).IsNotNull(); - } - - [Test] - public async Task PropertyAndNode_Mapping_Works() - { - var info = KdlTypeInfo.For(); - await Assert.That(info.Properties.Count).IsEqualTo(1); - await Assert.That(info.Children.Count).IsEqualTo(1); - } - - [Test] - public async Task NodeDictionary_Property_IsDetected() - { - var info = KdlTypeInfo.For(); - await Assert.That(info.Dictionaries.Count).IsEqualTo(1); - - var dictInfo = KdlTypeInfo.For>(); - await Assert.That(dictInfo.IsDictionary).IsTrue(); - await Assert.That(dictInfo.DictionaryDef).IsNotNull(); - await Assert.That(dictInfo.DictionaryDef!.ValueType).IsEqualTo(typeof(string)); - } - - [Test] - public async Task CollectionDetection_Works_And_Dictionaries_Are_Not_Collections() - { - var arrInfo = KdlTypeInfo.For(); - await Assert.That(arrInfo.CollectionElementType).IsEqualTo(typeof(int)); - await Assert.That(arrInfo.IsIEnumerable).IsTrue(); - - var dictInfo = KdlTypeInfo.For>(); - await Assert.That(dictInfo.IsIEnumerable).IsFalse(); - } - - [Test] - public async Task KdlTypeAttribute_Overrides_NodeName() - { - var info = KdlTypeInfo.For(); - await Assert.That(info.NodeName).IsEqualTo("my-node"); - } - - [Test] - public async Task IsStrictNode_Behaves_Correctly() - { - var plain = KdlTypeInfo.For(); - await Assert.That(plain.IsStrictNode).IsFalse(); - - var withType = KdlTypeInfo.For(); - await Assert.That(withType.IsStrictNode).IsTrue(); - - var withProp = KdlTypeInfo.For(); - await Assert.That(withProp.IsStrictNode).IsTrue(); - } - - [Test] - public async Task For_Is_Cached() - { - var a = KdlTypeInfo.For(); - var b = KdlTypeInfo.For(); - await Assert.That(object.ReferenceEquals(a, b)).IsTrue(); - } - - [Test] - public async Task KdlTypeInfo_Throws_ForEveryAttributeCombination() - { - var typesToTest = new[] - { - typeof(Conflict_Property_Node), - typeof(Conflict_Property_Argument), - typeof(Conflict_Node_NodeDict), - }; - - foreach (var t in typesToTest) - { - var exception = Assert.Throws(() => KdlTypeInfo.For(t)); - await Assert.That(exception).IsNotNull(); - } - } - - // Test types - - private class Conflict_Property_Node - { - [KdlProperty("k")] - [KdlNode("n")] - public string? Prop { get; set; } - } - - private class Conflict_Property_Argument - { - [KdlProperty("k")] - [KdlArgument(0)] - public string? Prop { get; set; } - } - - private class Conflict_Node_NodeDict - { - [KdlNode("n")] - [KdlNodeDictionary("d")] - public string? Prop { get; set; } - } - - private class ArgContinuityBad - { - [KdlArgument(0)] - public string? A { get; set; } - - [KdlArgument(2)] - public string? B { get; set; } - } - - private class PropertyNodeMap - { - [KdlProperty("k")] - public string? Prop { get; set; } - - [KdlNode("n")] - public string? Child { get; set; } - } - - private class NodeDictHolder - { - [KdlNodeDictionary("d")] - public Dictionary? Dict { get; set; } - } - - [KdlType("my-node")] - private class CustomName { } - - private class PlainDocument { } -} +// using Kuddle.Exceptions; +// using Kuddle.Serialization; + +// namespace Kuddle.Tests.Serialization; + +// public class KdlTypeInfoTests +// { +// [Test] +// public async Task ArgumentContinuity_MissingIndex_Throws() +// { +// var ex = Assert.Throws(() => +// KdlTypeMapping.For() +// ); +// await Assert.That(ex).IsNotNull(); +// } + +// [Test] +// public async Task PropertyAndNode_Mapping_Works() +// { +// var info = KdlTypeMapping.For(); +// await Assert.That(info.Properties.Count).IsEqualTo(1); +// await Assert.That(info.Children.Count).IsEqualTo(1); +// } + +// [Test] +// public async Task NodeDictionary_Property_IsDetected() +// { +// var info = KdlTypeMapping.For(); +// await Assert.That(info.Children.Count).IsEqualTo(1); + +// var dictInfo = KdlTypeMapping.For>(); +// await Assert.That(dictInfo.IsDictionary).IsTrue(); +// await Assert.That(dictInfo.DictionaryDef).IsNotNull(); +// await Assert.That(dictInfo.DictionaryDef!.ValueType).IsEqualTo(typeof(string)); +// } + +// [Test] +// public async Task CollectionDetection_Works_And_Dictionaries_Are_Not_Collections() +// { +// var arrInfo = KdlTypeMapping.For(); +// await Assert.That(arrInfo.CollectionElementType).IsEqualTo(typeof(int)); +// await Assert.That(arrInfo.IsIEnumerable).IsTrue(); + +// var dictInfo = KdlTypeMapping.For>(); +// await Assert.That(dictInfo.IsIEnumerable).IsFalse(); +// } + +// [Test] +// public async Task KdlTypeAttribute_Overrides_NodeName() +// { +// var info = KdlTypeMapping.For(); +// await Assert.That(info.NodeName).IsEqualTo("my-node"); +// } + +// [Test] +// public async Task IsStrictNode_Behaves_Correctly() +// { +// var plain = KdlTypeMapping.For(); +// await Assert.That(plain.IsStrictNode).IsFalse(); + +// var withType = KdlTypeMapping.For(); +// await Assert.That(withType.IsStrictNode).IsTrue(); + +// var withProp = KdlTypeMapping.For(); +// await Assert.That(withProp.IsStrictNode).IsTrue(); +// } + +// [Test] +// public async Task For_Is_Cached() +// { +// var a = KdlTypeMapping.For(); +// var b = KdlTypeMapping.For(); +// await Assert.That(object.ReferenceEquals(a, b)).IsTrue(); +// } + +// [Test] +// public async Task KdlTypeInfo_Throws_ForEveryAttributeCombination() +// { +// var typesToTest = new[] +// { +// typeof(Conflict_Property_Node), +// typeof(Conflict_Property_Argument), +// typeof(Conflict_Node_NodeDict), +// }; + +// foreach (var t in typesToTest) +// { +// var exception = Assert.Throws(() => KdlTypeMapping.For(t)); +// await Assert.That(exception).IsNotNull(); +// } +// } + +// // Test types + +// private class Conflict_Property_Node +// { +// [KdlProperty("k")] +// [KdlNode("n")] +// public string? Prop { get; set; } +// } + +// private class Conflict_Property_Argument +// { +// [KdlProperty("k")] +// [KdlArgument(0)] +// public string? Prop { get; set; } +// } + +// private class Conflict_Node_NodeDict +// { +// [KdlNode("n")] +// [KdlNodeDictionary("d")] +// public string? Prop { get; set; } +// } + +// private class ArgContinuityBad +// { +// [KdlArgument(0)] +// public string? A { get; set; } + +// [KdlArgument(2)] +// public string? B { get; set; } +// } + +// private class PropertyNodeMap +// { +// [KdlProperty("k")] +// public string? Prop { get; set; } + +// [KdlNode("n")] +// public string? Child { get; set; } +// } + +// private class NodeDictHolder +// { +// [KdlNodeDictionary("d")] +// public Dictionary? Dict { get; set; } +// } + +// [KdlType("my-node")] +// private class CustomName { } + +// private class PlainDocument { } +// } diff --git a/src/Kuddle.Net/AST/KdlNode.cs b/src/Kuddle.Net/AST/KdlNode.cs index 2f22b43..b8c22a7 100644 --- a/src/Kuddle.Net/AST/KdlNode.cs +++ b/src/Kuddle.Net/AST/KdlNode.cs @@ -66,4 +66,6 @@ public IEnumerable Properties } } } + + public bool HasChildren => Children != null && Children.Nodes!.Count > 0; } diff --git a/src/Kuddle.Net/Extensions/ParserExtensions.cs b/src/Kuddle.Net/Extensions/ParserExtensions.cs index ddfea8c..0e7916c 100644 --- a/src/Kuddle.Net/Extensions/ParserExtensions.cs +++ b/src/Kuddle.Net/Extensions/ParserExtensions.cs @@ -11,10 +11,13 @@ internal static class ParserExtensions /// In Release builds, this is a no-op to enable Parlot compilation. /// [DebuggerStepThrough] - public static Parser Debug(this Parser parser, string name) + public static Parser Debug(this Parser parser, string name, bool enableDebug = false) { #if DEBUG - return new DebugParser(parser, name); + if (enableDebug) + return new DebugParser(parser, name); + else + return parser; #else return parser; #endif diff --git a/src/Kuddle.Net/Extensions/TypeExtensions.cs b/src/Kuddle.Net/Extensions/TypeExtensions.cs index c14de32..dcdc6ce 100644 --- a/src/Kuddle.Net/Extensions/TypeExtensions.cs +++ b/src/Kuddle.Net/Extensions/TypeExtensions.cs @@ -2,7 +2,6 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Reflection; namespace Kuddle.Serialization; @@ -10,20 +9,6 @@ internal static class TypeExtensions { extension(Type type) { - internal bool IsNodeDefinition => - type.GetProperties() - .Any(p => - p.GetCustomAttribute() != null - || p.GetCustomAttribute() != null - ); - internal bool IsComplexType => - !type.IsValueType - && !type.IsPrimitive - && type != typeof(string) - && type != typeof(object) - && !type.IsInterface - && !type.IsAbstract; - internal bool IsDictionary => type.IsGenericType && type.GetInterfaces() @@ -40,101 +25,35 @@ internal static class TypeExtensions && !type.IsDictionary && type.IsAssignableTo(typeof(IEnumerable)); - internal (PropertyInfo, KdlArgumentAttribute)[] GetKdlArgProps() => - type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Select(p => new - { - Property = p, - ArgAttr = p.GetCustomAttribute(), - }) - .Where(x => x.ArgAttr is not null) - .OrderBy(x => x.ArgAttr!.Index) - .Select(x => (x.Property, x.ArgAttr!)) - .ToArray(); - - internal (PropertyInfo, KdlPropertyAttribute)[] GetKdlPropProps() => - type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Select(p => new - { - Property = p, - PropAttr = p.GetCustomAttribute(), - }) - .Where(x => x.PropAttr is not null) - .Select(x => (x.Property, x.PropAttr!)) - .ToArray(); - - internal (PropertyInfo, KdlNodeAttribute)[] GetKdlChildProps() => - type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Select(p => new - { - Property = p, - ChildAttr = p.GetCustomAttribute(), - }) - .Where(x => x.ChildAttr is not null) - .Select(x => (x.Property, x.ChildAttr!)) - .ToArray(); - - internal DictionaryInfo? GetDictionaryInfo() - { - if (type == typeof(string)) - return null; - - var args = type.GetInterfaces() - .Append(type) - .FirstOrDefault(i => - i.IsGenericType - && ( - i.GetGenericTypeDefinition() == typeof(IDictionary<,>) - || i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) - ) - ) - ?.GetGenericArguments(); - - return args is null ? null : new DictionaryInfo(args[0], args[1]); - } + internal bool IsKdlScalar => + type.IsPrimitive + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(Guid); - internal Type? GetCollectionInfo() + public Type? GetCollectionElementType() { if (type == typeof(string)) return null; - - // Exclude dictionaries from being treated as simple collections - if (type.IsDictionary) - return null; - - // Arrays + // Array if (type.IsArray) return type.GetElementType(); // IEnumerable - var enumInterface = type.GetInterfaces() + var enumerable = type.GetInterfaces() .Append(type) .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) ); - if (enumInterface != null) - { - return enumInterface.GetGenericArguments()[0]; - } + if (enumerable != null) + return enumerable.GetGenericArguments()[0]; + // Not a collection return null; } - - internal bool IsKdlScalar => - type.IsPrimitive - || type.IsEnum - || type == typeof(string) - || type == typeof(decimal) - || type == typeof(DateTime) - || type == typeof(DateTimeOffset) - || type == typeof(Guid); - - public Type GetCollectionElementType() => - type.IsArray ? type.GetElementType()! - : type.IsGenericType ? type.GetGenericArguments()[0] - : throw new KuddleSerializationException( - $"Unsupported collection type '{type.FullName}'." - ); } } diff --git a/src/Kuddle.Net/Serialization/KdlMemberInfo.cs b/src/Kuddle.Net/Serialization/KdlMemberInfo.cs index 39183e8..7a4fc04 100644 --- a/src/Kuddle.Net/Serialization/KdlMemberInfo.cs +++ b/src/Kuddle.Net/Serialization/KdlMemberInfo.cs @@ -1,5 +1,6 @@ using System; using System.Reflection; +using System.Security.Cryptography.X509Certificates; namespace Kuddle.Serialization; @@ -35,3 +36,11 @@ internal sealed record KdlMemberInfo(PropertyInfo Property, Attribute? Attribute public string? TypeAnnotation => null; } + +internal enum KdlMemberKind +{ + Argument, + Property, + ChildNode, + TypeAnnotation, +} diff --git a/src/Kuddle.Net/Serialization/KdlMemberMap.cs b/src/Kuddle.Net/Serialization/KdlMemberMap.cs new file mode 100644 index 0000000..854a2b8 --- /dev/null +++ b/src/Kuddle.Net/Serialization/KdlMemberMap.cs @@ -0,0 +1,46 @@ +using System; +using System.Reflection; + +namespace Kuddle.Serialization; + +internal sealed record KdlMemberMap +{ + public KdlMemberMap( + PropertyInfo property, + KdlMemberKind kind, + string kdlName, + int argumentIndex = -1, + string? typeAnnotation = null + ) + { + Property = property; + Kind = kind; + KdlName = kdlName; + ArgumentIndex = argumentIndex; + TypeAnnotation = typeAnnotation; + IsDictionary = property.PropertyType.IsDictionary; + ElementType = property.PropertyType.GetCollectionElementType(); + IsCollection = ElementType != null; + if (IsDictionary && ElementType != null) + { + DictionaryKeyProperty = ElementType.GetProperty("Key"); + DictionaryValueProperty = ElementType.GetProperty("Value"); + } + } + + public PropertyInfo Property { get; } + public KdlMemberKind Kind { get; } + public string KdlName { get; } + public int ArgumentIndex { get; } + public Type? ElementType { get; } + public bool IsCollection { get; } + public bool IsDictionary { get; } + public string? TypeAnnotation { get; } + + public PropertyInfo? DictionaryKeyProperty { get; } + public PropertyInfo? DictionaryValueProperty { get; } + + public object? GetValue(object instance) => Property.GetValue(instance); + + public void SetValue(object instance, object? value) => Property.SetValue(instance, value); +} diff --git a/src/Kuddle.Net/Serialization/KdlSerializer.cs b/src/Kuddle.Net/Serialization/KdlSerializer.cs index a3eb5de..229d45a 100644 --- a/src/Kuddle.Net/Serialization/KdlSerializer.cs +++ b/src/Kuddle.Net/Serialization/KdlSerializer.cs @@ -28,7 +28,7 @@ public static IEnumerable DeserializeMany( where T : new() { var doc = KdlReader.Read(text); - var metadata = KdlTypeInfo.For(); + var metadata = KdlTypeMapping.For(); foreach (var node in doc.Nodes) { @@ -87,10 +87,5 @@ public static string SerializeMany( return KdlWriter.Write(doc); } - #endregion - - #region Helpers - - #endregion } diff --git a/src/Kuddle.Net/Serialization/KdlTypeInfo.cs b/src/Kuddle.Net/Serialization/KdlTypeInfo.cs index 445997b..e69de29 100644 --- a/src/Kuddle.Net/Serialization/KdlTypeInfo.cs +++ b/src/Kuddle.Net/Serialization/KdlTypeInfo.cs @@ -1,159 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Security.Cryptography.X509Certificates; -using Kuddle.Exceptions; -using Kuddle.Extensions; - -namespace Kuddle.Serialization; - -/// -/// Cached metadata about how a CLR type maps to/from KDL nodes. -/// -/// -[DebuggerDisplay("Type = {Type.Name,nq}")] -internal sealed class KdlTypeInfo -{ - private static readonly ConcurrentDictionary s_cache = new(); - - public Type Type { get; } - public string NodeName { get; } - - public DictionaryInfo? DictionaryDef { get; } - public Type? CollectionElementType { get; } - - /// - /// A strict node cannot be a document node. - /// Document nodes cannot have args or props - /// - public bool IsStrictNode => - ArgumentAttributes.Count > 0 - || Properties.Count > 0 - || Type.GetCustomAttribute() != null; - - /// Properties mapped to KDL arguments, sorted by index. - public IReadOnlyList ArgumentAttributes { get; } - - /// Properties mapped to KDL properties. - public IReadOnlyList Properties { get; } - - /// Properties mapped to child nodes. - public IReadOnlyList Children { get; } - public IReadOnlyList Collections { get; } - public IReadOnlyList Dictionaries { get; } - - private KdlTypeInfo(Type type) - { - Type = type; - - var kdlTypeAttr = type.GetCustomAttribute(); - NodeName = kdlTypeAttr?.Name ?? type.Name.ToLowerInvariant(); - - DictionaryDef = type.GetDictionaryInfo(); - CollectionElementType = type.GetCollectionInfo(); - var allMappings = new List(); - - foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if ( - !prop.CanWrite - || prop.GetCustomAttribute() != null - || prop.GetIndexParameters().Length > 0 // Ignore indexers - ) - continue; - - var attrs = prop.GetCustomAttributes() - .Where(a => a is KdlEntryAttribute || a is KdlNodeDictionaryAttribute) - .ToList(); - - if (attrs.Count > 1) - throw new KdlConfigurationException( - $"Property '{type.Name}.{prop.Name}' has multiple KDL attributes. Only one mapping is allowed per property." - ); - - if (attrs.Count == 0 && IsSystemCollectionProperty(prop)) - continue; - Attribute mappingAttr = attrs.Count == 1 ? attrs[0] : InferAttribute(prop); - allMappings.Add(new KdlMemberInfo(prop, mappingAttr)); - } - - var args = allMappings.Where(m => m.IsArgument).OrderBy(m => m.ArgumentIndex).ToList(); - for (int i = 0; i < args.Count; i++) - { - if (args[i].ArgumentIndex != i) - throw new KdlConfigurationException( - $"Property '{type.Name}.{args[i].Property.Name}' declares index {args[i].ArgumentIndex}, but index {i} is missing. Arguments must be contiguous starting at 0." - ); - } - - ArgumentAttributes = args; - Properties = allMappings.Where(m => m.IsProperty).ToList(); - Children = allMappings.Where(m => m.IsNode).ToList(); - Collections = allMappings.Where(m => m.IsWrappedCollection).ToList(); - Dictionaries = allMappings - .Where(m => - m.IsNodeDictionary /* TODO: Add support for IsPropertyDictionary and IsKeyedNodeCollection */ - ) - .ToList(); - } - - private static bool IsSystemCollectionProperty(PropertyInfo prop) => - prop.DeclaringType != null - && prop.DeclaringType.Namespace != null - && prop.DeclaringType.Namespace.StartsWith("system"); - - /// - /// Gets or creates cached metadata for a type. - /// - public static KdlTypeInfo For(Type type) => s_cache.GetOrAdd(type, t => new KdlTypeInfo(t)); - - /// - /// Gets or creates cached metadata for a type. - /// - public static KdlTypeInfo For() => For(typeof(T)); - - /// - /// Checks if a type is a complex type (not primitive, not string, not interface/abstract). - /// - public bool IsComplexType => - !Type.IsValueType - && !Type.IsPrimitive - && Type != typeof(string) - && Type != typeof(object) - && !Type.IsInterface - && !Type.IsAbstract; - - /// - /// Checks if a type is enumerable (but not string or dictionary). - /// - public bool IsIEnumerable => - Type != typeof(string) && !IsDictionary && typeof(IEnumerable).IsAssignableFrom(Type); - - /// - /// Checks if a type is a dictionary. - /// - public bool IsDictionary => - Type.GetInterfaces() - .Any(i => - i.IsGenericType - && ( - i.GetGenericTypeDefinition() == typeof(IDictionary<,>) - || i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) - ) - ); - - private static Attribute InferAttribute(PropertyInfo prop) => - prop.PropertyType switch - { - { IsDictionary: true } => new KdlNodeDictionaryAttribute(prop.Name.ToKebabCase()), - { IsIEnumerable: true } => new KdlNodeAttribute(prop.Name.ToKebabCase()), - { IsKdlScalar: true } => new KdlPropertyAttribute(prop.Name.ToKebabCase()), - _ => new KdlNodeAttribute(prop.Name.ToKebabCase()), - }; -} - -internal sealed record DictionaryInfo(Type KeyType, Type ValueType); diff --git a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs new file mode 100644 index 0000000..4dedfd0 --- /dev/null +++ b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Kuddle.AST; +using Kuddle.Exceptions; +using Kuddle.Extensions; + +namespace Kuddle.Serialization; + +internal sealed record KdlTypeMapping +{ + private static readonly ConcurrentDictionary s_cache = new(); + + private KdlTypeMapping(Type type) + { + Type = type; + + var typeAttr = type.GetCustomAttribute(); + NodeName = typeAttr?.Name ?? type.Name.ToKebabCase(); + + IsDictionary = type.IsDictionary; + if (IsDictionary) + { + var elementType = type.GetCollectionElementType(); + DictionaryKeyProperty = elementType?.GetProperty("Key"); + DictionaryValueProperty = elementType?.GetProperty("Value"); + } + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var prop in props) + { + if (!prop.IsKdlSerializable()) + continue; + + var map = CreateMemberMap(prop); + + switch (map.Kind) + { + case KdlMemberKind.Argument: + Arguments.Add(map); + break; + case KdlMemberKind.Property: + Properties.Add(map); + break; + case KdlMemberKind.ChildNode: + Children.Add(map); + break; + } + } + ValidateMapping(); + } + + private void ValidateMapping() + { + // Sort arguments and check for continuity + var sortedArgs = Arguments.OrderBy(a => a.ArgumentIndex).ToList(); + for (int i = 0; i < sortedArgs.Count; i++) + { + if (sortedArgs[i].ArgumentIndex != i) + throw new KdlConfigurationException( + $"Type '{Type.Name}' has non-contiguous KDL arguments. Expected index {i}, found {sortedArgs[i].ArgumentIndex}." + ); + } + + Arguments.Clear(); + Arguments.AddRange(sortedArgs); + } + + private KdlMemberMap CreateMemberMap(PropertyInfo prop) + { + var all = prop.GetCustomAttributes(); + if (prop.GetCustomAttribute() is not KdlEntryAttribute attr) + { + attr = InferAttribute(prop); + } + var typeAnnotation = attr.TypeAnnotation; + + // -- Explicit Mapping via Attributes -- + + return attr switch + { + KdlArgumentAttribute arg => new KdlMemberMap( + prop, + KdlMemberKind.Argument, + "", + arg.Index, + typeAnnotation + ), + + KdlPropertyAttribute p => new KdlMemberMap( + prop, + KdlMemberKind.Property, + p.Key ?? prop.Name.ToKebabCase(), + -1, + typeAnnotation + ), + + KdlNodeAttribute n => new KdlMemberMap( + prop, + KdlMemberKind.ChildNode, + n.Name ?? prop.Name.ToKebabCase(), + -1, + typeAnnotation + ), + KdlNodeCollectionAttribute nc => new KdlMemberMap( + prop, + KdlMemberKind.ChildNode, + nc.NodeName, + -1, + typeAnnotation + ), + KdlNodeDictionaryAttribute nd => new KdlMemberMap( + prop, + KdlMemberKind.ChildNode, + nd.Name ?? prop.Name.ToKebabCase(), + -1, + typeAnnotation + ), + _ => new KdlMemberMap( + prop, + KdlMemberKind.ChildNode, + prop.Name.ToKebabCase(), + -1, + typeAnnotation + ), + }; + } + + private static KdlEntryAttribute InferAttribute(PropertyInfo prop) => + prop.PropertyType switch + { + { IsDictionary: true } => new KdlNodeDictionaryAttribute(prop.Name.ToKebabCase()), + { IsIEnumerable: true } => new KdlNodeCollectionAttribute(prop.Name.ToKebabCase()), + { IsKdlScalar: true } => new KdlPropertyAttribute(prop.Name.ToKebabCase()), + _ => new KdlNodeAttribute(prop.Name.ToKebabCase()), + }; + + public Type Type { get; init; } + public string NodeName { get; init; } + public bool IsDictionary { get; } + public List Properties { get; } = []; + public List Children { get; } = []; + + internal List Arguments { get; } = []; + public Type? KeyType { get; } + public Type? ValueType { get; } + public PropertyInfo? DictionaryKeyProperty { get; } + public PropertyInfo? DictionaryValueProperty { get; } + + /// + /// Gets or creates cached metadata for a type. + /// + public static KdlTypeMapping For(Type type) => + s_cache.GetOrAdd(type, t => new KdlTypeMapping(t)); + + /// + /// Gets or creates cached metadata for a type. + /// + public static KdlTypeMapping For() => For(typeof(T)); +} + +public static class KdlTypeMappingExtensions +{ + extension(KdlValue value) { } + + extension(PropertyInfo info) + { + internal bool IsKdlSerializable() => + info.CanWrite + && info.GetCustomAttribute() == null + && info.GetIndexParameters().Length == 0; + } +} diff --git a/src/Kuddle.Net/Serialization/KdlValueConverter.cs b/src/Kuddle.Net/Serialization/KdlValueConverter.cs index e005d64..1aede5c 100644 --- a/src/Kuddle.Net/Serialization/KdlValueConverter.cs +++ b/src/Kuddle.Net/Serialization/KdlValueConverter.cs @@ -208,6 +208,7 @@ public static object FromKdlOrThrow( /// /// Converts a CLR value to a KDL value, throwing on failure. /// + [Obsolete] public static KdlValue ToKdlOrThrow( object? input, string context, @@ -224,6 +225,21 @@ public static KdlValue ToKdlOrThrow( return kdlValue; } + /// + /// Converts a CLR value to a KDL value, throwing on failure. + /// + public static KdlValue ToKdlOrThrow(object? input, string? typeAnnotation = null) + { + if (!TryToKdl(input, out var kdlValue, typeAnnotation)) + { + var typeName = input?.GetType().Name ?? "null"; + throw new KuddleSerializationException( + $"Cannot convert CLR value of type '{typeName}' to KDL." + ); + } + return kdlValue; + } + private static bool IsNullable(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) != null; } diff --git a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs index 57e5b32..aaa40ec 100644 --- a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Security.Cryptography.X509Certificates; using Kuddle.AST; using Kuddle.Extensions; @@ -23,50 +24,51 @@ internal static T DeserializeDocument(KdlDocument doc, KdlSerializerOptions? where T : new() { var worker = new ObjectDeserializer(options); - return worker.DeserializeRoot(doc); - } - internal T DeserializeRoot(KdlDocument doc) - where T : new() - { - var metadata = KdlTypeInfo.For(); + var mapping = KdlTypeMapping.For(); + var instance = new T(); - if (metadata.IsIEnumerable) + if (mapping.Arguments.Count > 0 || mapping.Properties.Count > 0) { - throw new KuddleSerializationException( - $"Cannot deserialize to collection type '{metadata.Type.Name}'. Use DeserializeMany() instead." - ); - } + if (doc.Nodes.Count == 0) + return instance; - if (metadata.IsStrictNode) - { - if (doc.Nodes.Count > 1) - throw new KuddleSerializationException( - $"Expected exactly 1 root node, found {doc.Nodes.Count}." + var rootNode = + doc.Nodes.FirstOrDefault(n => + n.Name.Value.Equals(mapping.NodeName, NodeNameComparison) + ) + ?? throw new KuddleSerializationException( + $"Root node '{mapping.NodeName}' not found in document." ); - var rootNode = doc.Nodes[0]; - ValidateNodeName(rootNode, metadata.NodeName); - - var instance = new T(); - MapNodeToObject(rootNode, instance, metadata); - return instance; + worker.MapNodeToObject(rootNode, instance, mapping); } else { - // Document Mode: Root properties and children - var instance = new T(); - MapChildNodes(doc.Nodes, instance, metadata); - MapDictionaries(doc.Nodes, null, instance, metadata); - return instance; + // Mode B: Document Mode or Intrinsic Dictionary at root + if (mapping.IsDictionary) + { + worker.PopulateDictionary( + (IDictionary)instance, + doc.Nodes, + mapping.DictionaryKeyProperty!.PropertyType, + mapping.DictionaryValueProperty!.PropertyType + ); + } + else + { + worker.MapChildren(doc.Nodes, instance, mapping); + } } + + return instance; } internal static T DeserializeNode(KdlNode node, KdlSerializerOptions? options) where T : new() { var worker = new ObjectDeserializer(options); - var metadata = KdlTypeInfo.For(); + var metadata = KdlTypeMapping.For(); ValidateNodeName(node, metadata.NodeName); var instance = new T(); @@ -74,266 +76,188 @@ internal static T DeserializeNode(KdlNode node, KdlSerializerOptions? options return instance; } - private object DeserializeObject(KdlNode node, Type type) - { - var instance = - Activator.CreateInstance(type) - ?? throw new KuddleSerializationException( - $"Failed to create instance of '{type.Name}'." - ); - - var metadata = KdlTypeInfo.For(type); - MapNodeToObject(node, instance, metadata); - return instance; - } - /// /// Maps a KDL node's entries and children to an object instance. /// - private void MapNodeToObject(KdlNode node, object instance, KdlTypeInfo metadata) + private void MapNodeToObject(KdlNode node, object instance, KdlTypeMapping mapping) { - foreach (var mapping in metadata.ArgumentAttributes) + foreach (var map in mapping.Arguments) { - var argValue = - node.Arg(mapping.ArgumentIndex) - ?? throw new KuddleSerializationException( - $"Missing required argument at index {mapping.ArgumentIndex}." + var kdlValue = node.Arg(map.ArgumentIndex); + if (kdlValue != null) + { + var val = KdlValueConverter.FromKdlOrThrow( + kdlValue, + map.Property.PropertyType, + map.KdlName, + map.TypeAnnotation ); - SetPropertyValue(mapping.Property, instance, argValue, mapping.TypeAnnotation); + map.SetValue(instance, val); + } } - foreach (var mapping in metadata.Properties) + foreach (var map in mapping.Properties) { - var kdlValue = node.Prop(mapping.Name); + var kdlValue = node.Prop(map.KdlName); if (kdlValue != null) { - SetPropertyValue(mapping.Property, instance, kdlValue, mapping.TypeAnnotation); + var val = KdlValueConverter.FromKdlOrThrow( + kdlValue, + map.Property.PropertyType, + map.KdlName, + map.TypeAnnotation + ); + map.SetValue(instance, val); } } - - MapChildNodes(node.Children?.Nodes, instance, metadata); - - MapDictionaries(node.Children?.Nodes, node, instance, metadata); - - MapWrappedCollections(node.Children?.Nodes, instance, metadata); - - if (metadata.IsDictionary && metadata.DictionaryDef != null) + if (node.Children != null) { - var (keyType, valueType) = metadata.DictionaryDef; - PopulateNodeDictionary(node.Children?.Nodes, (IDictionary)instance, keyType, valueType); - } - } - - private void MapWrappedCollections(List? nodes, object instance, KdlTypeInfo metadata) - { - if (nodes is null || nodes.Count == 0) - return; - - foreach (var mapping in metadata.Collections) - { - var container = nodes.LastOrDefault(n => - n.Name.Value.Equals(mapping.Name, NodeNameComparison) - ); - - if (container?.Children?.Nodes is null) - continue; - - var itemType = mapping.Property.PropertyType.GetCollectionElementType(); - var itemInfo = KdlTypeInfo.For(itemType); - - var targetName = mapping.CollectionElementName ?? itemInfo.NodeName; - - var items = container - .Children.Nodes.Where(n => n.Name.Value.Equals(targetName, NodeNameComparison)) - .ToList(); - - SetCollectionProperty(mapping.Property, instance, items); + // Mode B: Document Mode or Intrinsic Dictionary at root + if (mapping.IsDictionary) + { + PopulateDictionary( + (IDictionary)instance, + node.Children.Nodes, + mapping.DictionaryKeyProperty!.PropertyType, + mapping.DictionaryValueProperty!.PropertyType + ); + } + else + { + MapChildren(node.Children.Nodes, instance, mapping); + } } } /// /// Maps child KDL nodes to properties marked with [KdlNode]. /// - private void MapChildNodes(IReadOnlyList? nodes, object instance, KdlTypeInfo metadata) + private void MapChildren(List? nodes, object instance, KdlTypeMapping mapping) { if (nodes is null || nodes.Count == 0) return; - foreach (var mapping in metadata.Children) + foreach (var map in mapping.Children) { - var matches = nodes - .Where(n => n.Name.Value.Equals(mapping.Name, NodeNameComparison)) + List matches = nodes + .Where(n => n.Name.Value.Equals(map.KdlName, NodeNameComparison)) .ToList(); - if (matches.Count == 0) + if (matches is null || matches.Count == 0) continue; - var propType = mapping.Property.PropertyType; - var propMeta = KdlTypeInfo.For(propType); - - if (propMeta.IsIEnumerable) - { - SetCollectionProperty(mapping.Property, instance, matches); - } - else + if (map.IsDictionary) { - if (matches.Count > 1) - throw new KuddleSerializationException( - $"Expected single node '{mapping.Name}', found {matches.Count}." - ); - - var node = matches[0]; - object value; - - if (propMeta.IsComplexType) + var container = matches.Last(); + if (container.Children != null) { - value = DeserializeObject(node, propType); - } - else - { - var arg = node.Arg(0); - if (arg is null) - continue; - value = KdlValueConverter.FromKdlOrThrow( - arg, - propType, - mapping.Name, - mapping.TypeAnnotation + var dict = EnsureInstance(instance, map) as IDictionary; + PopulateDictionary( + dict!, + container.Children.Nodes, + map.DictionaryKeyProperty!.PropertyType, + map.DictionaryValueProperty!.PropertyType ); } + } + else if (map.IsCollection) + { + KdlNode container = matches.Last(); + + List nodesToProcess = container.HasChildren + ? container.Children?.Nodes! + : matches; - mapping.Property.SetValue(instance, value); + PopulateCollection(instance, nodesToProcess, map); + } + else + { + var childInstance = DeserializeObject(matches.Last(), map.Property.PropertyType); + map.SetValue(instance, childInstance); } } } - private void MapDictionaries( - List? nodes, - KdlNode? parentNode, - object instance, - KdlTypeInfo metadata + private void PopulateCollection( + object parentInstance, + IEnumerable nodes, + KdlMemberMap map ) { - if (metadata.Dictionaries.Count == 0) - return; + var list = CreateList(map.ElementType!); + var elementMapping = KdlTypeMapping.For(map.ElementType!); - foreach (var member in metadata.Dictionaries) + foreach (var node in nodes) { - var propMeta = KdlTypeInfo.For(member.Property.PropertyType); - if (!propMeta.IsDictionary || propMeta.DictionaryDef is null) - continue; - - var (keyType, valueType) = propMeta.DictionaryDef; - var dict = GetOrCreateDictionary(instance, member); - if (dict is null) - continue; - - if (member.IsNodeDictionary && nodes != null) + // If the element name matches (or we are in a wrapped block), deserialize it + object? item; + if (map.ElementType!.IsKdlScalar) { - var container = nodes.LastOrDefault(n => - n.Name.Value.Equals(member.Name, NodeNameComparison) - ); - if (container?.Children?.Nodes != null) - { - PopulateNodeDictionary(container.Children.Nodes, dict, keyType, valueType); - } + var kdlVal = node.Arg(0); + item = + kdlVal != null + ? KdlValueConverter.FromKdlOrThrow( + kdlVal, + map.ElementType!, + node.Name.Value + ) + : null; } - } - static IDictionary? GetOrCreateDictionary(object instance, KdlMemberInfo member) - { - var dict = (IDictionary?)member.Property.GetValue(instance); - if (dict is null) + else { - dict = (IDictionary?)Activator.CreateInstance(member.Property.PropertyType); - member.Property.SetValue(instance, dict); + item = DeserializeObject(node, map.ElementType!); } - return dict; + if (item != null) + list.Add(item); } + + map.SetValue( + parentInstance, + ConvertCollection(list, map.Property.PropertyType, map.ElementType!) + ); } - private void PopulateNodeDictionary( - IEnumerable? children, + private void PopulateDictionary( IDictionary dict, + IEnumerable nodes, Type keyType, Type valueType ) { - if (children is null) - return; - - foreach (var child in children) + foreach (var node in nodes) { - object key = Convert.ChangeType(child.Name.Value, keyType); - object value; + object key = Convert.ChangeType(node.Name.Value, keyType); + object? value; - if (!valueType.IsComplexType) + if (valueType.IsKdlScalar) { - // Scalar: Arg 0 - var arg = child.Arg(0); - if (arg is null) - continue; - value = KdlValueConverter.FromKdlOrThrow(arg, valueType, $"Dictionary Value {key}"); + var arg = node.Arg(0); + value = + arg != null + ? KdlValueConverter.FromKdlOrThrow(arg, valueType, key.ToString()!) + : null; } else { - value = DeserializeObject(child, valueType); + value = DeserializeObject(node, valueType); } - if (dict.Contains(key)) - throw new KuddleSerializationException( - $"Duplicate dictionary key detected: '{key}'" - ); - - dict.Add(key, value); + if (value != null) + dict[key] = value; } } - private void SetPropertyValue( - PropertyInfo property, - object instance, - KdlValue kdlValue, - string? expectedTypeAnnotation = null - ) - { - var result = KdlValueConverter.FromKdlOrThrow( - kdlValue, - property.PropertyType, - $"Property: {property.DeclaringType?.Name}.{property.Name}", - expectedTypeAnnotation - ); - - property.SetValue(instance, result); - } - - private void SetCollectionProperty(PropertyInfo property, object instance, List nodes) + private object DeserializeObject(KdlNode node, Type type) { - var elementType = property.PropertyType.GetCollectionElementType(); - - var listType = typeof(List<>).MakeGenericType(elementType); - var list = (IList)Activator.CreateInstance(listType)!; - - var elementMetadata = KdlTypeInfo.For(elementType); - - foreach (var node in nodes) - { - object element; - if (elementMetadata.IsComplexType) - { - element = DeserializeObject(node, elementType); - } - else - { - var arg = node.Arg(0); - if (arg is null) - continue; - element = KdlValueConverter.FromKdlOrThrow(arg, elementType, "List Element"); - } - list.Add(element); - } + var instance = + Activator.CreateInstance(type) + ?? throw new KuddleSerializationException( + $"Failed to create instance of '{type.Name}'." + ); - var finalValue = ConvertToTargetCollectionType(property.PropertyType, elementType, list); - property.SetValue(instance, finalValue); + MapNodeToObject(node, instance, KdlTypeMapping.For(type)); + return instance; } private static void ValidateNodeName(KdlNode node, string nodeName) @@ -346,11 +270,24 @@ private static void ValidateNodeName(KdlNode node, string nodeName) } } - private static object ConvertToTargetCollectionType( - Type targetType, - Type elementType, - IList list - ) + private object EnsureInstance(object parent, KdlMemberMap map) + { + var current = map.GetValue(parent); + if (current != null) + return current; + + var newInstance = Activator.CreateInstance(map.Property.PropertyType)!; + map.SetValue(parent, newInstance); + return newInstance; + } + + private IList CreateList(Type elementType) + { + var listType = typeof(List<>).MakeGenericType(elementType); + return (IList)Activator.CreateInstance(listType)!; + } + + private object ConvertCollection(IList list, Type targetType, Type elementType) { if (targetType.IsArray) { @@ -358,12 +295,6 @@ IList list list.CopyTo(array, 0); return array; } - - if (targetType.IsAssignableFrom(list.GetType())) - { - return list; - } - - return list; + return list; // Assuming List is compatible with target (IEnumerable/IReadOnlyList) } } diff --git a/src/Kuddle.Net/Serialization/ObjectSerializer.cs b/src/Kuddle.Net/Serialization/ObjectSerializer.cs index 1ff21af..1369867 100644 --- a/src/Kuddle.Net/Serialization/ObjectSerializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectSerializer.cs @@ -2,6 +2,8 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; +using System.Threading.Tasks.Dataflow; using Kuddle.AST; namespace Kuddle.Serialization; @@ -21,25 +23,28 @@ internal static KdlDocument SerializeDocument(T? instance, KdlSerializerOptio var type = typeof(T); var worker = new ObjectSerializer(options); - var metadata = KdlTypeInfo.For(); - if (!metadata.IsComplexType && !metadata.IsDictionary) + if (type.IsKdlScalar) { throw new KuddleSerializationException( $"Cannot serialize primitive type '{type.Name}'. Only complex types are supported." ); } + var doc = new KdlDocument(); - if (metadata.IsIEnumerable) + // If the root object itself is a collection, we treat every item as a top-level node. + if (typeof(T).IsIEnumerable && instance is IEnumerable enumerable) { - var nodes = worker.SerializeCollection((IEnumerable)instance); - doc.Nodes.AddRange(nodes); + foreach (var item in enumerable) + { + if (item != null) + doc.Nodes.Add(worker.SerializeObject(item)); + } } else { - var node = worker.SerializeToNode(instance); - doc.Nodes.Add(node); + doc.Nodes.Add(worker.SerializeObject(instance)); } return doc; @@ -48,249 +53,137 @@ internal static KdlDocument SerializeDocument(T? instance, KdlSerializerOptio public static KdlNode SerializeNode(object instance, KdlSerializerOptions? options) { var worker = new ObjectSerializer(options); - return worker.SerializeToNode(instance); + return worker.SerializeObject(instance); } - private IEnumerable SerializeCollection( - IEnumerable collection, - string? overrideNodeName = null - ) + private KdlNode SerializeObject(object instance, string? overrideNodeName = null) { - foreach (var item in collection) - { - if (item is null) - continue; + var mapping = KdlTypeMapping.For(instance.GetType()); + var node = new KdlNode(KdlValue.From(overrideNodeName ?? mapping.NodeName)); - yield return SerializeToNode(item, overrideNodeName); - } - } - - private KdlNode SerializeToNode(object instance, string? overrideNodeName = null) - { - var metadata = KdlTypeInfo.For(instance.GetType()); - var nodeName = overrideNodeName ?? metadata.NodeName; - - var entries = new List(); - - // Serialize arguments (in order) - foreach (var mapping in metadata.ArgumentAttributes) + foreach (var map in mapping.Arguments) { - var value = mapping.Property.GetValue(instance); - var kdlValue = KdlValueConverter.ToKdlOrThrow( - value, - $"Argument property: {mapping.Property.Name}", - mapping.TypeAnnotation - ); - - entries.Add(new KdlArgument(kdlValue)); + var val = KdlValueConverter.ToKdlOrThrow(map.GetValue(instance), map.TypeAnnotation); + node.Entries.Add(new KdlArgument(val)); } - // Serialize properties - foreach (var mapping in metadata.Properties) + foreach (var map in mapping.Properties) { - var value = mapping.Property.GetValue(instance); - var typeAnnotation = mapping.TypeAnnotation; - - if (!KdlValueConverter.TryToKdl(value, out var kdlValue, typeAnnotation)) - { - continue; // Skip properties that can't be converted - } + var raw = map.GetValue(instance); + if (raw == null && _options.IgnoreNullValues) + continue; - entries.Add(new KdlProperty(KdlValue.From(mapping.Name), kdlValue)); + var val = KdlValueConverter.ToKdlOrThrow(raw, map.TypeAnnotation); + node.Entries.Add(new KdlProperty(KdlValue.From(map.KdlName), val)); } - foreach (var dictMap in metadata.Dictionaries.Where(m => m.IsPropertyDictionary)) + var childNodes = new List(); + if (mapping.Children.Count > 0) { - var dict = (IDictionary?)dictMap.Property.GetValue(instance); - if (dict is null) - continue; - - foreach (DictionaryEntry entry in dict) + foreach (var map in mapping.Children) { - var keyStr = entry.Key.ToString()!; - if (entry.Value != null && KdlValueConverter.TryToKdl(entry.Value, out var val)) + var childData = map.GetValue(instance); + if (childData is null) + continue; + + if (map.IsDictionary) + { + // For dictionaries, we treat the Dictionary Keys as the Node Names + var container = new KdlNode(KdlValue.From(map.KdlName)); + var items = SerializeDictionary((IEnumerable)childData, map); + container = container with + { + Children = new KdlBlock { Nodes = items.ToList() }, + }; + childNodes.Add(container); + // childNodes.AddRange(SerializeDictionary((IDictionary)childData, map)); + } + else if (map.IsCollection) + { + // For collections, we serialize each item as a child node + childNodes.AddRange(SerializeCollection((IEnumerable)childData, map)); + } + else { - entries.Add(new KdlProperty(KdlValue.From(keyStr), val)); + // Single complex object + childNodes.Add(SerializeObject(childData, map.KdlName)); } } } - // Serialize children - KdlBlock? childBlock = null; - void AddChild(KdlNode child) + // if (mapping.IsDictionary && instance is IEnumerable enumerable) + // { + // childNodes.AddRange( + // SerializeDictionaryContent( + // enumerable, + // mapping.DictionaryKeyProperty, + // mapping.DictionaryValueProperty, + // null + // ) + // ); + // } + + if (childNodes.Count > 0) { - childBlock ??= new KdlBlock(); - childBlock.Nodes.Add(child); + node = node with { Children = new KdlBlock { Nodes = childNodes } }; } + return node; + } - foreach (var mapping in metadata.Children) + private IEnumerable SerializeCollection(IEnumerable enumerable, KdlMemberMap map) + { + foreach (var item in enumerable) { - var propValue = mapping.Property.GetValue(instance); - - if (propValue is null) + if (item is null) continue; - var propType = mapping.Property.PropertyType; - var childMeta = KdlTypeInfo.For(propType); - - if (childMeta.IsIEnumerable) - { - var childNodes = SerializeCollection((IEnumerable)propValue, mapping.Name); - foreach (var child in childNodes) - { - AddChild(child); - } - } - else if (childMeta.IsComplexType) - { - AddChild(SerializeToNode(propValue, mapping.Name)); - } - else - { - var kdlValue = KdlValueConverter.ToKdlOrThrow( - propValue, - $"Child scalar property: {mapping.Property.Name}", - mapping.TypeAnnotation - ); - - var scalarNode = new KdlNode(KdlValue.From(mapping.Name)) - { - Entries = [new KdlArgument(kdlValue)], - }; - AddChild(scalarNode); - } + yield return MapToNode(item, map.KdlName, map.TypeAnnotation); } - foreach (var dictMap in metadata.Dictionaries) - { - if (dictMap.IsPropertyDictionary) - continue; // Handled above - - var dict = (IDictionary?)dictMap.Property.GetValue(instance); - if (dict is null || dict.Count == 0) - continue; - - var (_, valueType) = KdlTypeInfo.For(dictMap.Property.PropertyType).DictionaryDef!; - - if (dictMap.IsNodeDictionary) - { - // Strategy: Create a container node, put entries inside - // themes { dark { ... } } - var container = new KdlNode(KdlValue.From(dictMap.Name)); - var nodes = new List(); - SerializeDictionaryEntries(nodes, dict, valueType); + } - if (nodes.Count > 0) - { - container = container with { Children = new KdlBlock() { Nodes = nodes } }; - AddChild(container); - } - } - else if (dictMap.IsKeyedNodeCollection) - { - // Strategy: Flatten entries as children - // db "A" { ... } - // db "B" { ... } - foreach (var value in dict.Values) - { - if (value is null) - continue; - // For KeyedNodes, the Object contains the Key, so we just serialize the Object - // with the forced Node Name from the attribute. - AddChild(SerializeToNode(value, dictMap.Name)); - } - } + private KdlNode MapToNode(object item, string kdlName, string? typeAnnotation) + { + if (item.GetType().IsKdlScalar) + { + var val = KdlValueConverter.ToKdlOrThrow(item, typeAnnotation); + return new KdlNode(KdlValue.From(kdlName)) { Entries = [new KdlArgument(val)] }; } - - foreach (var mapping in metadata.Collections) + else { - var val = mapping.Property.GetValue(instance); - if (val is null) - continue; - - var collection = (IEnumerable)val; - - // 1. Create the Container Node - var container = new KdlNode(KdlValue.From(mapping.Name)); - var nodes = new List(); - // 2. Determine Item Name - var itemType = mapping.Property.PropertyType.GetCollectionElementType(); - var itemMeta = KdlTypeInfo.For(itemType); - var targetName = mapping.CollectionElementName ?? itemMeta.NodeName; - - // 3. Serialize Items as children of the Container - foreach (var item in collection) - { - if (item is null) - continue; - // Recursively serialize, forcing the name to "event" - nodes.Add(SerializeToNode(item, targetName)); - } - - // 4. Attach - if (nodes.Count > 0) - { - container = container with { Children = new KdlBlock() { Nodes = nodes } }; - AddChild(container); - } + return SerializeObject(item, kdlName); } + } - if ( - metadata.IsDictionary - && metadata.DictionaryDef != null - && instance is IDictionary selfDict - ) + private IEnumerable SerializeDictionary(IEnumerable dict, KdlMemberMap map) + { + foreach (var item in dict) { - childBlock ??= new KdlBlock(); + var key = map.DictionaryKeyProperty!.GetValue(item); + var val = map.DictionaryValueProperty!.GetValue(item); + if (key == null || val == null) + continue; - SerializeDictionaryEntries( - childBlock.Nodes, - selfDict, - metadata.DictionaryDef.ValueType - ); + yield return MapToNode(val!, key.ToString()!, map.TypeAnnotation); } - return new KdlNode(KdlValue.From(nodeName)) - { - Entries = entries, - Children = childBlock?.Nodes.Count > 0 ? childBlock : null, - }; } - /// - /// Helper to convert Dictionary entries into KDL Nodes. - /// Used by [KdlNodeDictionary] and Implicit Dictionary logic. - /// - private void SerializeDictionaryEntries( - List targetList, - IDictionary dict, - Type valueType + private IEnumerable SerializeDictionaryContent( + IEnumerable dict, + PropertyInfo? keyProp, + PropertyInfo? valProp, + string? typeAnno ) { - var isScalar = valueType.IsKdlScalar; - - foreach (DictionaryEntry entry in dict) + foreach (var item in dict) { - var keyStr = entry.Key.ToString(); - if (string.IsNullOrEmpty(keyStr) || entry.Value is null) + var key = keyProp?.GetValue(item); + var val = valProp?.GetValue(item); + if (key == null || val == null) continue; - if (isScalar) - { - // Scalar: NodeName=Key, Arg0=Value - // e.g. timeout 5000 - if (KdlValueConverter.TryToKdl(entry.Value, out var kdlVal, null)) - { - targetList.Add( - new KdlNode(KdlValue.From(keyStr)) { Entries = [new KdlArgument(kdlVal)] } - ); - } - } - else - { - // Complex: NodeName=Key, Body=Object - // e.g. dark-mode { ... } - // We recurse SerializeToNode, but we override the name to be the Key - targetList.Add(SerializeToNode(entry.Value, overrideNodeName: keyStr)); - } + // MapToNode handles the recursion: + // If 'val' is a dictionary, it calls SerializeObject, which triggers step (B) above. + yield return MapToNode(val, key.ToString()!, typeAnno); } } } From 0f64693ebf0e7eb79d665b1a925936408c3d92c2 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Thu, 25 Dec 2025 20:23:43 +0000 Subject: [PATCH 11/19] wip --- .../Serialization/Models/TelemetrySnapshot.cs | 2 +- .../Serialization/TelemetrySnapshotTests.cs | 1 + src/Kuddle.Net/Serialization/KdlSerializer.cs | 42 +++--- .../Serialization/KdlTypeMapping.cs | 2 + .../Serialization/ObjectSerializer.cs | 129 +++++++++--------- 5 files changed, 93 insertions(+), 83 deletions(-) diff --git a/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs index d5ab871..51f1bf9 100644 --- a/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs +++ b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs @@ -123,7 +123,7 @@ public class EventRecord // Heterogeneous dictionary // [KdlNode] - public Dictionary Context { get; set; } = new(); + public Dictionary? Context { get; set; } } [KdlType("env-info")] diff --git a/src/Kuddle.Net.Tests/Serialization/TelemetrySnapshotTests.cs b/src/Kuddle.Net.Tests/Serialization/TelemetrySnapshotTests.cs index f5d103a..3eef997 100644 --- a/src/Kuddle.Net.Tests/Serialization/TelemetrySnapshotTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/TelemetrySnapshotTests.cs @@ -84,6 +84,7 @@ public async Task RoundTrip_TelemetrySnapshot_SerializesAndDeserializes() Message = "Slow start", }, }, + Metadata = { ["a"] = "one", ["b"] = "two" }, }; var kdl = KdlSerializer.Serialize(original); diff --git a/src/Kuddle.Net/Serialization/KdlSerializer.cs b/src/Kuddle.Net/Serialization/KdlSerializer.cs index 229d45a..27fdabd 100644 --- a/src/Kuddle.Net/Serialization/KdlSerializer.cs +++ b/src/Kuddle.Net/Serialization/KdlSerializer.cs @@ -61,31 +61,31 @@ public static string Serialize(T instance, KdlSerializerOptions? options = nu return KdlWriter.Write(doc); } - /// - /// Serializes multiple objects to a KDL string. - /// - public static string SerializeMany( - IEnumerable items, - KdlSerializerOptions? options = null, - CancellationToken cancellationToken = default - ) - { - ArgumentNullException.ThrowIfNull(items); + // /// + // /// Serializes multiple objects to a KDL string. + // /// + // public static string SerializeMany( + // IEnumerable items, + // KdlSerializerOptions? options = null, + // CancellationToken cancellationToken = default + // ) + // { + // ArgumentNullException.ThrowIfNull(items); - var doc = new KdlDocument(); + // var doc = new KdlDocument(); - foreach (var item in items) - { - cancellationToken.ThrowIfCancellationRequested(); - if (item is null) - continue; + // foreach (var item in items) + // { + // cancellationToken.ThrowIfCancellationRequested(); + // if (item is null) + // continue; - var node = ObjectSerializer.SerializeNode(item, options); - doc.Nodes.Add(node); - } + // var node = ObjectSerializer.SerializeNode(item, options); + // doc.Nodes.Add(node); + // } - return KdlWriter.Write(doc); - } + // return KdlWriter.Write(doc); + // } #endregion } diff --git a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs index 4dedfd0..f6fb060 100644 --- a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs +++ b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs @@ -49,6 +49,7 @@ private KdlTypeMapping(Type type) } } ValidateMapping(); + HasMembers = Arguments.Count + Properties.Count + Children.Count > 1; } private void ValidateMapping() @@ -147,6 +148,7 @@ private static KdlEntryAttribute InferAttribute(PropertyInfo prop) => public Type? ValueType { get; } public PropertyInfo? DictionaryKeyProperty { get; } public PropertyInfo? DictionaryValueProperty { get; } + public bool HasMembers { get; } /// /// Gets or creates cached metadata for a type. diff --git a/src/Kuddle.Net/Serialization/ObjectSerializer.cs b/src/Kuddle.Net/Serialization/ObjectSerializer.cs index 1369867..2aac5ac 100644 --- a/src/Kuddle.Net/Serialization/ObjectSerializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectSerializer.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Reflection; using System.Threading.Tasks.Dataflow; @@ -50,84 +51,90 @@ internal static KdlDocument SerializeDocument(T? instance, KdlSerializerOptio return doc; } - public static KdlNode SerializeNode(object instance, KdlSerializerOptions? options) - { - var worker = new ObjectSerializer(options); - return worker.SerializeObject(instance); - } + // public static KdlNode SerializeNode(object instance, KdlSerializerOptions? options) + // { + // var worker = new ObjectSerializer(options); + // return worker.SerializeObject(instance); + // } private KdlNode SerializeObject(object instance, string? overrideNodeName = null) { var mapping = KdlTypeMapping.For(instance.GetType()); var node = new KdlNode(KdlValue.From(overrideNodeName ?? mapping.NodeName)); - - foreach (var map in mapping.Arguments) + // Debug.WriteLine(node); + if (mapping.IsDictionary && !mapping.HasMembers) { - var val = KdlValueConverter.ToKdlOrThrow(map.GetValue(instance), map.TypeAnnotation); - node.Entries.Add(new KdlArgument(val)); + var items = SerializeDictionaryContent( + (IEnumerable)instance, + mapping.DictionaryKeyProperty, + mapping.DictionaryValueProperty + ); + return node with { Children = new KdlBlock { Nodes = items.ToList() } }; } - - foreach (var map in mapping.Properties) + else { - var raw = map.GetValue(instance); - if (raw == null && _options.IgnoreNullValues) - continue; - - var val = KdlValueConverter.ToKdlOrThrow(raw, map.TypeAnnotation); - node.Entries.Add(new KdlProperty(KdlValue.From(map.KdlName), val)); - } + foreach (var map in mapping.Arguments) + { + var val = KdlValueConverter.ToKdlOrThrow( + map.GetValue(instance), + map.TypeAnnotation + ); + node.Entries.Add(new KdlArgument(val)); + } - var childNodes = new List(); - if (mapping.Children.Count > 0) - { - foreach (var map in mapping.Children) + foreach (var map in mapping.Properties) { - var childData = map.GetValue(instance); - if (childData is null) + var raw = map.GetValue(instance); + if (raw == null && _options.IgnoreNullValues) continue; - if (map.IsDictionary) + var val = KdlValueConverter.ToKdlOrThrow(raw, map.TypeAnnotation); + node.Entries.Add(new KdlProperty(KdlValue.From(map.KdlName), val)); + } + + var childNodes = new List(); + if (mapping.Children.Count > 0) + { + foreach (var map in mapping.Children) { - // For dictionaries, we treat the Dictionary Keys as the Node Names - var container = new KdlNode(KdlValue.From(map.KdlName)); - var items = SerializeDictionary((IEnumerable)childData, map); - container = container with + var childData = map.GetValue(instance); + if (childData is null) + continue; + + if (map.IsDictionary) { - Children = new KdlBlock { Nodes = items.ToList() }, - }; - childNodes.Add(container); - // childNodes.AddRange(SerializeDictionary((IDictionary)childData, map)); - } - else if (map.IsCollection) - { - // For collections, we serialize each item as a child node - childNodes.AddRange(SerializeCollection((IEnumerable)childData, map)); - } - else - { - // Single complex object - childNodes.Add(SerializeObject(childData, map.KdlName)); + // Debug.WriteLine($"Dictionary KdlMemberMap for {map.Property.PropertyType}:"); + + // For dictionaries, we treat the Dictionary Keys as the Node Names + var container = new KdlNode(KdlValue.From(map.KdlName)); + var items = SerializeDictionary((IEnumerable)childData, map); + container = container with + { + Children = new KdlBlock { Nodes = items.ToList() }, + }; + // Debug.WriteLine(container); + childNodes.Add(container); + // childNodes.AddRange(SerializeDictionary((IDictionary)childData, map)); + } + else if (map.IsCollection) + { + // For collections, we serialize each item as a child node + childNodes.AddRange(SerializeCollection((IEnumerable)childData, map)); + } + else + { + // Single complex object + childNodes.Add(SerializeObject(childData, map.KdlName)); + } } } - } - // if (mapping.IsDictionary && instance is IEnumerable enumerable) - // { - // childNodes.AddRange( - // SerializeDictionaryContent( - // enumerable, - // mapping.DictionaryKeyProperty, - // mapping.DictionaryValueProperty, - // null - // ) - // ); - // } - - if (childNodes.Count > 0) - { - node = node with { Children = new KdlBlock { Nodes = childNodes } }; + if (childNodes.Count > 0) + { + node = node with { Children = new KdlBlock { Nodes = childNodes } }; + } + return node; } - return node; } private IEnumerable SerializeCollection(IEnumerable enumerable, KdlMemberMap map) @@ -171,7 +178,7 @@ private IEnumerable SerializeDictionaryContent( IEnumerable dict, PropertyInfo? keyProp, PropertyInfo? valProp, - string? typeAnno + string? typeAnno = null ) { foreach (var item in dict) From fbf4ef1dc1f99bfdaee19b57a496545d50bc5d0c Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Fri, 26 Dec 2025 11:23:54 +0000 Subject: [PATCH 12/19] make serialization/deserialization more declarative --- .ai/outputs/codebase.txt | 6034 ++--------------- .github/codebase.ps1 | 10 +- .../Serialization/DocumentToObjectTests.cs | 6 +- .../Serialization/Models/TelemetrySnapshot.cs | 1 - .../Serialization/NodeToObjectTests.cs | 5 +- .../Serialization/ObjectMapperTests.cs | 28 +- src/Kuddle.Net/Extensions/TypeExtensions.cs | 5 +- src/Kuddle.Net/Serialization/KdlMemberInfo.cs | 46 - src/Kuddle.Net/Serialization/KdlMemberKind.cs | 9 + src/Kuddle.Net/Serialization/KdlMemberMap.cs | 12 +- src/Kuddle.Net/Serialization/KdlTypeInfo.cs | 0 .../Serialization/KdlTypeMapping.cs | 25 +- .../Serialization/KdlValueConverter.cs | 20 - .../Serialization/ObjectDeserializer.cs | 59 +- .../Serialization/ObjectSerializer.cs | 134 +- 15 files changed, 652 insertions(+), 5742 deletions(-) delete mode 100644 src/Kuddle.Net/Serialization/KdlMemberInfo.cs create mode 100644 src/Kuddle.Net/Serialization/KdlMemberKind.cs delete mode 100644 src/Kuddle.Net/Serialization/KdlTypeInfo.cs diff --git a/.ai/outputs/codebase.txt b/.ai/outputs/codebase.txt index bd974b5..a8c2ccb 100644 --- a/.ai/outputs/codebase.txt +++ b/.ai/outputs/codebase.txt @@ -7,17 +7,6 @@ - Attributes - Validation - Kuddle.Net.Benchmarks - - Kuddle.Net.Tests - - AST - - Errors - - Extensions - - Grammar - - Serialization - - Models - - test_cases - - expected_kdl - - input - - Validation # --- Start of Code Files --- @@ -136,6 +125,8 @@ public sealed record KdlNode(KdlString Name) : KdlObject } } } + + public bool HasChildren => Children != null && Children.Nodes!.Count > 0; } ``` @@ -853,10 +844,13 @@ internal static class ParserExtensions /// In Release builds, this is a no-op to enable Parlot compilation. /// [DebuggerStepThrough] - public static Parser Debug(this Parser parser, string name) + public static Parser Debug(this Parser parser, string name, bool enableDebug = false) { #if DEBUG - return new DebugParser(parser, name); + if (enableDebug) + return new DebugParser(parser, name); + else + return parser; #else return parser; #endif @@ -961,7 +955,6 @@ public static class SpanExtensions using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Reflection; namespace Kuddle.Serialization; @@ -969,20 +962,6 @@ internal static class TypeExtensions { extension(Type type) { - internal bool IsNodeDefinition => - type.GetProperties() - .Any(p => - p.GetCustomAttribute() != null - || p.GetCustomAttribute() != null - ); - internal bool IsComplexType => - !type.IsValueType - && !type.IsPrimitive - && type != typeof(string) - && type != typeof(object) - && !type.IsInterface - && !type.IsAbstract; - internal bool IsDictionary => type.IsGenericType && type.GetInterfaces() @@ -999,102 +978,36 @@ internal static class TypeExtensions && !type.IsDictionary && type.IsAssignableTo(typeof(IEnumerable)); - internal (PropertyInfo, KdlArgumentAttribute)[] GetKdlArgProps() => - type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Select(p => new - { - Property = p, - ArgAttr = p.GetCustomAttribute(), - }) - .Where(x => x.ArgAttr is not null) - .OrderBy(x => x.ArgAttr!.Index) - .Select(x => (x.Property, x.ArgAttr!)) - .ToArray(); - - internal (PropertyInfo, KdlPropertyAttribute)[] GetKdlPropProps() => - type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Select(p => new - { - Property = p, - PropAttr = p.GetCustomAttribute(), - }) - .Where(x => x.PropAttr is not null) - .Select(x => (x.Property, x.PropAttr!)) - .ToArray(); - - internal (PropertyInfo, KdlNodeAttribute)[] GetKdlChildProps() => - type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Select(p => new - { - Property = p, - ChildAttr = p.GetCustomAttribute(), - }) - .Where(x => x.ChildAttr is not null) - .Select(x => (x.Property, x.ChildAttr!)) - .ToArray(); - - internal DictionaryInfo? GetDictionaryInfo() - { - if (type == typeof(string)) - return null; - - var args = type.GetInterfaces() - .Append(type) - .FirstOrDefault(i => - i.IsGenericType - && ( - i.GetGenericTypeDefinition() == typeof(IDictionary<,>) - || i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) - ) - ) - ?.GetGenericArguments(); - - return args is null ? null : new DictionaryInfo(args[0], args[1]); - } + internal bool IsKdlScalar => + type.IsPrimitive + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(Guid); - internal Type? GetCollectionInfo() + public Type? GetCollectionElementType() { if (type == typeof(string)) return null; - - // Exclude dictionaries from being treated as simple collections - if (type.IsDictionary) - return null; - - // Arrays + // Array if (type.IsArray) return type.GetElementType(); // IEnumerable - var enumInterface = type.GetInterfaces() + var enumerable = type.GetInterfaces() .Append(type) .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) ); - if (enumInterface != null) - { - return enumInterface.GetGenericArguments()[0]; - } + if (enumerable != null) + return enumerable.GetGenericArguments()[0]; + // Not a collection return null; } - - internal bool IsKdlScalar => - type.IsPrimitive - || type.IsEnum - || type == typeof(string) - || type == typeof(decimal) - || type == typeof(DateTime) - || type == typeof(DateTimeOffset) - || type == typeof(Guid); - - public Type GetCollectionElementType() => - type.IsArray ? type.GetElementType()! - : type.IsGenericType ? type.GetGenericArguments()[0] - : throw new KuddleSerializationException( - $"Unsupported collection type '{type.FullName}'." - ); } } @@ -2869,42 +2782,62 @@ public sealed class KdlTypeAttribute(string name) : KdlEntryAttribute } ``` -// File: src\Kuddle.Net\Serialization\KdlMemberInfo.cs`$langusing System; +// File: src\Kuddle.Net\Serialization\KdlMemberKind.cs`$langnamespace Kuddle.Serialization; + +internal enum KdlMemberKind +{ + Argument, + Property, + ChildNode, + TypeAnnotation, +} + +``` +// File: src\Kuddle.Net\Serialization\KdlMemberMap.cs`$langusing System; using System.Reflection; namespace Kuddle.Serialization; -/// -/// Represents a mapping from a C# property to a KDL entry (argument, property, or child node). -/// -internal sealed record KdlMemberInfo(PropertyInfo Property, Attribute? Attribute) +internal sealed record KdlMemberMap { - public bool IsArgument => Attribute is KdlArgumentAttribute; - public bool IsProperty => Attribute is KdlPropertyAttribute; - public bool IsNode => Attribute is KdlNodeAttribute; - public bool IsWrappedCollection => Attribute is KdlNodeCollectionAttribute; - public string? CollectionElementName => (Attribute as KdlNodeCollectionAttribute)?.ElementName; - public bool IsNodeDictionary => Attribute is KdlNodeDictionaryAttribute; - - //TODO: Add support for property dictionaries - public bool IsPropertyDictionary => false; - - //TODO: Add support for keyed node collections - public bool IsKeyedNodeCollection => false; + public KdlMemberMap( + PropertyInfo property, + KdlMemberKind kind, + string kdlName, + int argumentIndex = -1, + string? typeAnnotation = null + ) + { + Property = property; + Kind = kind; + KdlName = kdlName; + ArgumentIndex = argumentIndex; + TypeAnnotation = typeAnnotation; + IsDictionary = property.PropertyType.IsDictionary; + var elementType = property.PropertyType.GetCollectionElementType(); + IsCollection = elementType != null; + if (IsDictionary && elementType != null) + { + DictionaryKeyProperty = elementType.GetProperty("Key"); + DictionaryValueProperty = elementType.GetProperty("Value"); + } + ElementType = elementType; + } - public int ArgumentIndex => Attribute is KdlArgumentAttribute arg ? arg.Index : -1; + public PropertyInfo Property { get; } + public KdlMemberKind Kind { get; } + public string KdlName { get; } + public int ArgumentIndex { get; } + public Type? ElementType { get; } + public bool IsCollection { get; } + public bool IsDictionary { get; } + public string? TypeAnnotation { get; } + public PropertyInfo? DictionaryKeyProperty { get; } + public PropertyInfo? DictionaryValueProperty { get; } - public string Name => - Attribute switch - { - KdlPropertyAttribute p => p.Key ?? Property.Name.ToLowerInvariant(), - KdlNodeAttribute n => n.Name ?? Property.Name.ToLowerInvariant(), - KdlNodeDictionaryAttribute nd => nd.Name ?? Property.Name.ToLowerInvariant(), - KdlNodeCollectionAttribute nc => nc.NodeName ?? Property.Name.ToLowerInvariant(), - _ => Property.Name.ToLowerInvariant(), - }; + public object? GetValue(object instance) => Property.GetValue(instance); - public string? TypeAnnotation => null; + public void SetValue(object instance, object? value) => Property.SetValue(instance, value); } ``` @@ -2996,7 +2929,7 @@ public static class KdlSerializer where T : new() { var doc = KdlReader.Read(text); - var metadata = KdlTypeInfo.For(); + var metadata = KdlTypeMapping.For(); foreach (var node in doc.Nodes) { @@ -3029,36 +2962,31 @@ public static class KdlSerializer return KdlWriter.Write(doc); } - /// - /// Serializes multiple objects to a KDL string. - /// - public static string SerializeMany( - IEnumerable items, - KdlSerializerOptions? options = null, - CancellationToken cancellationToken = default - ) - { - ArgumentNullException.ThrowIfNull(items); - - var doc = new KdlDocument(); - - foreach (var item in items) - { - cancellationToken.ThrowIfCancellationRequested(); - if (item is null) - continue; - - var node = ObjectSerializer.SerializeNode(item, options); - doc.Nodes.Add(node); - } + // /// + // /// Serializes multiple objects to a KDL string. + // /// + // public static string SerializeMany( + // IEnumerable items, + // KdlSerializerOptions? options = null, + // CancellationToken cancellationToken = default + // ) + // { + // ArgumentNullException.ThrowIfNull(items); - return KdlWriter.Write(doc); - } + // var doc = new KdlDocument(); - #endregion + // foreach (var item in items) + // { + // cancellationToken.ThrowIfCancellationRequested(); + // if (item is null) + // continue; - #region Helpers + // var node = ObjectSerializer.SerializeNode(item, options); + // doc.Nodes.Add(node); + // } + // return KdlWriter.Write(doc); + // } #endregion } @@ -3093,166 +3021,176 @@ public record KdlSerializerOptions } ``` -// File: src\Kuddle.Net\Serialization\KdlTypeInfo.cs`$langusing System; -using System.Collections; +// File: src\Kuddle.Net\Serialization\KdlTypeMapping.cs`$langusing System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Reflection; -using System.Security.Cryptography.X509Certificates; +using Kuddle.AST; using Kuddle.Exceptions; using Kuddle.Extensions; namespace Kuddle.Serialization; -/// -/// Cached metadata about how a CLR type maps to/from KDL nodes. -/// -/// -[DebuggerDisplay("Type = {Type.Name,nq}")] -internal sealed class KdlTypeInfo +internal sealed record KdlTypeMapping { - private static readonly ConcurrentDictionary s_cache = new(); - - public Type Type { get; } - public string NodeName { get; } - - public DictionaryInfo? DictionaryDef { get; } - public Type? CollectionElementType { get; } + private static readonly ConcurrentDictionary s_cache = new(); - /// - /// A strict node cannot be a document node. - /// Document nodes cannot have args or props - /// - public bool IsStrictNode => - ArgumentAttributes.Count > 0 - || Properties.Count > 0 - || Type.GetCustomAttribute() != null; - - /// Properties mapped to KDL arguments, sorted by index. - public IReadOnlyList ArgumentAttributes { get; } - - /// Properties mapped to KDL properties. - public IReadOnlyList Properties { get; } - - /// Properties mapped to child nodes. - public IReadOnlyList Children { get; } - public IReadOnlyList Collections { get; } - public IReadOnlyList Dictionaries { get; } - - private KdlTypeInfo(Type type) + private KdlTypeMapping(Type type) { Type = type; - var kdlTypeAttr = type.GetCustomAttribute(); - NodeName = kdlTypeAttr?.Name ?? type.Name.ToLowerInvariant(); + var typeAttr = type.GetCustomAttribute(); + NodeName = typeAttr?.Name ?? type.Name.ToKebabCase(); - DictionaryDef = type.GetDictionaryInfo(); - CollectionElementType = type.GetCollectionInfo(); - var allMappings = new List(); - - foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + IsDictionary = type.IsDictionary; + if (IsDictionary) { - if ( - !prop.CanWrite - || prop.GetCustomAttribute() != null - || prop.GetIndexParameters().Length > 0 // Ignore indexers - ) + var elementType = type.GetCollectionElementType(); + DictionaryKeyProperty = elementType?.GetProperty("Key"); + DictionaryValueProperty = elementType?.GetProperty("Value"); + } + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var prop in props) + { + if (!prop.IsKdlSerializable()) continue; - var attrs = prop.GetCustomAttributes() - .Where(a => a is KdlEntryAttribute || a is KdlNodeDictionaryAttribute) - .ToList(); - - if (attrs.Count > 1) - throw new KdlConfigurationException( - $"Property '{type.Name}.{prop.Name}' has multiple KDL attributes. Only one mapping is allowed per property." - ); + var map = CreateMemberMap(prop); - if (attrs.Count == 0 && IsSystemCollectionProperty(prop)) - continue; - Attribute mappingAttr = attrs.Count == 1 ? attrs[0] : InferAttribute(prop); - allMappings.Add(new KdlMemberInfo(prop, mappingAttr)); + switch (map.Kind) + { + case KdlMemberKind.Argument: + Arguments.Add(map); + break; + case KdlMemberKind.Property: + Properties.Add(map); + break; + case KdlMemberKind.ChildNode: + Children.Add(map); + break; + } } + ValidateMapping(); + } + + public Type Type { get; init; } + public string NodeName { get; init; } + public bool IsDictionary { get; } + public List Properties { get; } = []; + public List Children { get; } = []; + internal List Arguments { get; } = []; + public PropertyInfo? DictionaryKeyProperty { get; } + public PropertyInfo? DictionaryValueProperty { get; } - var args = allMappings.Where(m => m.IsArgument).OrderBy(m => m.ArgumentIndex).ToList(); - for (int i = 0; i < args.Count; i++) + private void ValidateMapping() + { + // Sort arguments and check for continuity + var sortedArgs = Arguments.OrderBy(a => a.ArgumentIndex).ToList(); + for (int i = 0; i < sortedArgs.Count; i++) { - if (args[i].ArgumentIndex != i) + if (sortedArgs[i].ArgumentIndex != i) throw new KdlConfigurationException( - $"Property '{type.Name}.{args[i].Property.Name}' declares index {args[i].ArgumentIndex}, but index {i} is missing. Arguments must be contiguous starting at 0." + $"Type '{Type.Name}' has non-contiguous KDL arguments. Expected index {i}, found {sortedArgs[i].ArgumentIndex}." ); } - ArgumentAttributes = args; - Properties = allMappings.Where(m => m.IsProperty).ToList(); - Children = allMappings.Where(m => m.IsNode).ToList(); - Collections = allMappings.Where(m => m.IsWrappedCollection).ToList(); - Dictionaries = allMappings - .Where(m => - m.IsNodeDictionary /* TODO: Add support for IsPropertyDictionary and IsKeyedNodeCollection */ - ) - .ToList(); + Arguments.Clear(); + Arguments.AddRange(sortedArgs); + } + + private KdlMemberMap CreateMemberMap(PropertyInfo prop) + { + var all = prop.GetCustomAttributes(); + if (prop.GetCustomAttribute() is not KdlEntryAttribute attr) + { + attr = InferAttribute(prop); + } + var typeAnnotation = attr.TypeAnnotation; + + return attr switch + { + KdlArgumentAttribute arg => new KdlMemberMap( + prop, + KdlMemberKind.Argument, + "", + arg.Index, + typeAnnotation + ), + + KdlPropertyAttribute p => new KdlMemberMap( + prop, + KdlMemberKind.Property, + p.Key ?? prop.Name.ToKebabCase(), + -1, + typeAnnotation + ), + + KdlNodeAttribute n => new KdlMemberMap( + prop, + KdlMemberKind.ChildNode, + n.Name ?? prop.Name.ToKebabCase(), + -1, + typeAnnotation + ), + KdlNodeCollectionAttribute nc => new KdlMemberMap( + prop, + KdlMemberKind.ChildNode, + nc.NodeName, + -1, + typeAnnotation + ), + KdlNodeDictionaryAttribute nd => new KdlMemberMap( + prop, + KdlMemberKind.ChildNode, + nd.Name ?? prop.Name.ToKebabCase(), + -1, + typeAnnotation + ), + _ => new KdlMemberMap( + prop, + KdlMemberKind.ChildNode, + prop.Name.ToKebabCase(), + -1, + typeAnnotation + ), + }; } - private static bool IsSystemCollectionProperty(PropertyInfo prop) => - prop.DeclaringType != null - && prop.DeclaringType.Namespace != null - && prop.DeclaringType.Namespace.StartsWith("system"); + private static KdlEntryAttribute InferAttribute(PropertyInfo prop) => + prop.PropertyType switch + { + { IsDictionary: true } => new KdlNodeDictionaryAttribute(prop.Name.ToKebabCase()), + { IsIEnumerable: true } => new KdlNodeCollectionAttribute(prop.Name.ToKebabCase()), + { IsKdlScalar: true } => new KdlPropertyAttribute(prop.Name.ToKebabCase()), + _ => new KdlNodeAttribute(prop.Name.ToKebabCase()), + }; /// /// Gets or creates cached metadata for a type. /// - public static KdlTypeInfo For(Type type) => s_cache.GetOrAdd(type, t => new KdlTypeInfo(t)); + public static KdlTypeMapping For(Type type) => + s_cache.GetOrAdd(type, t => new KdlTypeMapping(t)); /// /// Gets or creates cached metadata for a type. /// - public static KdlTypeInfo For() => For(typeof(T)); - - /// - /// Checks if a type is a complex type (not primitive, not string, not interface/abstract). - /// - public bool IsComplexType => - !Type.IsValueType - && !Type.IsPrimitive - && Type != typeof(string) - && Type != typeof(object) - && !Type.IsInterface - && !Type.IsAbstract; - - /// - /// Checks if a type is enumerable (but not string or dictionary). - /// - public bool IsIEnumerable => - Type != typeof(string) && !IsDictionary && typeof(IEnumerable).IsAssignableFrom(Type); + public static KdlTypeMapping For() => For(typeof(T)); +} - /// - /// Checks if a type is a dictionary. - /// - public bool IsDictionary => - Type.GetInterfaces() - .Any(i => - i.IsGenericType - && ( - i.GetGenericTypeDefinition() == typeof(IDictionary<,>) - || i.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>) - ) - ); +public static class KdlTypeMappingExtensions +{ + extension(KdlValue value) { } - private static Attribute InferAttribute(PropertyInfo prop) => - prop.PropertyType switch - { - { IsDictionary: true } => new KdlNodeDictionaryAttribute(prop.Name.ToKebabCase()), - { IsIEnumerable: true } => new KdlNodeAttribute(prop.Name.ToKebabCase()), - { IsKdlScalar: true } => new KdlPropertyAttribute(prop.Name.ToKebabCase()), - _ => new KdlNodeAttribute(prop.Name.ToKebabCase()), - }; + extension(PropertyInfo info) + { + internal bool IsKdlSerializable() => + info.CanWrite + && info.GetCustomAttribute() == null + && info.GetIndexParameters().Length == 0; + } } -internal sealed record DictionaryInfo(Type KeyType, Type ValueType); - ``` // File: src\Kuddle.Net\Serialization\KdlValueConverter.cs`$langusing System; using Kuddle.AST; @@ -3464,6 +3402,7 @@ internal static class KdlValueConverter /// /// Converts a CLR value to a KDL value, throwing on failure. /// + [Obsolete] public static KdlValue ToKdlOrThrow( object? input, string context, @@ -3480,6 +3419,21 @@ internal static class KdlValueConverter return kdlValue; } + /// + /// Converts a CLR value to a KDL value, throwing on failure. + /// + public static KdlValue ToKdlOrThrow(object? input, string? typeAnnotation = null) + { + if (!TryToKdl(input, out var kdlValue, typeAnnotation)) + { + var typeName = input?.GetType().Name ?? "null"; + throw new KuddleSerializationException( + $"Cannot convert CLR value of type '{typeName}' to KDL." + ); + } + return kdlValue; + } + private static bool IsNullable(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) != null; } @@ -3818,6 +3772,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Security.Cryptography.X509Certificates; using Kuddle.AST; using Kuddle.Extensions; @@ -3838,50 +3793,66 @@ internal class ObjectDeserializer where T : new() { var worker = new ObjectDeserializer(options); - return worker.DeserializeRoot(doc); - } - internal T DeserializeRoot(KdlDocument doc) - where T : new() - { - var metadata = KdlTypeInfo.For(); + var mapping = KdlTypeMapping.For(); + var instance = new T(); - if (metadata.IsIEnumerable) + if (mapping.Arguments.Count > 0 || mapping.Properties.Count > 0) { - throw new KuddleSerializationException( - $"Cannot deserialize to collection type '{metadata.Type.Name}'. Use DeserializeMany() instead." - ); - } + var matches = doc + .Nodes.Where(n => n.Name.Value.Equals(mapping.NodeName, NodeNameComparison)) + .ToList(); - if (metadata.IsStrictNode) - { - if (doc.Nodes.Count > 1) + if (matches.Count == 0) + { + // If the document has content, but none of it is the node we want, it's an error. + if (doc.Nodes.Count > 0) + { + var foundNames = string.Join(", ", doc.Nodes.Select(n => $"'{n.Name.Value}'")); + throw new KuddleSerializationException( + $"Expected root node '{mapping.NodeName}', but found: {foundNames}." + ); + } + return instance; // Document is totally empty; return empty object + } + + // THROW: Ambiguity check + if (matches.Count > 1) + { throw new KuddleSerializationException( - $"Expected exactly 1 root node, found {doc.Nodes.Count}." + $"Found {matches.Count} nodes matching '{mapping.NodeName}', but only 1 was expected. " + + "To deserialize a list of nodes, use KdlSerializer.DeserializeMany()." ); + } - var rootNode = doc.Nodes[0]; - ValidateNodeName(rootNode, metadata.NodeName); - - var instance = new T(); - MapNodeToObject(rootNode, instance, metadata); - return instance; + worker.MapNodeToObject(matches[0], instance, mapping); } else { - // Document Mode: Root properties and children - var instance = new T(); - MapChildNodes(doc.Nodes, instance, metadata); - MapDictionaries(doc.Nodes, null, instance, metadata); - return instance; + // Mode B: Document Mode or Intrinsic Dictionary at root + if (mapping.IsDictionary) + { + worker.PopulateDictionary( + (IDictionary)instance, + doc.Nodes, + mapping.DictionaryKeyProperty!.PropertyType, + mapping.DictionaryValueProperty!.PropertyType + ); + } + else + { + worker.MapChildren(doc.Nodes, instance, mapping); + } } + + return instance; } internal static T DeserializeNode(KdlNode node, KdlSerializerOptions? options) where T : new() { var worker = new ObjectDeserializer(options); - var metadata = KdlTypeInfo.For(); + var metadata = KdlTypeMapping.For(); ValidateNodeName(node, metadata.NodeName); var instance = new T(); @@ -3889,266 +3860,208 @@ internal class ObjectDeserializer return instance; } - private object DeserializeObject(KdlNode node, Type type) - { - var instance = - Activator.CreateInstance(type) - ?? throw new KuddleSerializationException( - $"Failed to create instance of '{type.Name}'." - ); - - var metadata = KdlTypeInfo.For(type); - MapNodeToObject(node, instance, metadata); - return instance; - } - /// /// Maps a KDL node's entries and children to an object instance. /// - private void MapNodeToObject(KdlNode node, object instance, KdlTypeInfo metadata) + private void MapNodeToObject(KdlNode node, object instance, KdlTypeMapping mapping) { - foreach (var mapping in metadata.ArgumentAttributes) + foreach (var map in mapping.Arguments) { - var argValue = - node.Arg(mapping.ArgumentIndex) - ?? throw new KuddleSerializationException( - $"Missing required argument at index {mapping.ArgumentIndex}." + var kdlValue = node.Arg(map.ArgumentIndex); + if (kdlValue != null) + { + var val = KdlValueConverter.FromKdlOrThrow( + kdlValue, + map.Property.PropertyType, + map.KdlName, + map.TypeAnnotation ); - SetPropertyValue(mapping.Property, instance, argValue, mapping.TypeAnnotation); + map.SetValue(instance, val); + } } - foreach (var mapping in metadata.Properties) + foreach (var map in mapping.Properties) { - var kdlValue = node.Prop(mapping.Name); + var kdlValue = node.Prop(map.KdlName); if (kdlValue != null) { - SetPropertyValue(mapping.Property, instance, kdlValue, mapping.TypeAnnotation); + var val = KdlValueConverter.FromKdlOrThrow( + kdlValue, + map.Property.PropertyType, + map.KdlName, + map.TypeAnnotation + ); + map.SetValue(instance, val); } } - - MapChildNodes(node.Children?.Nodes, instance, metadata); - - MapDictionaries(node.Children?.Nodes, node, instance, metadata); - - MapWrappedCollections(node.Children?.Nodes, instance, metadata); - - if (metadata.IsDictionary && metadata.DictionaryDef != null) - { - var (keyType, valueType) = metadata.DictionaryDef; - PopulateNodeDictionary(node.Children?.Nodes, (IDictionary)instance, keyType, valueType); - } - } - - private void MapWrappedCollections(List? nodes, object instance, KdlTypeInfo metadata) - { - if (nodes is null || nodes.Count == 0) - return; - - foreach (var mapping in metadata.Collections) + if (node.Children != null) { - var container = nodes.LastOrDefault(n => - n.Name.Value.Equals(mapping.Name, NodeNameComparison) - ); - - if (container?.Children?.Nodes is null) - continue; - - var itemType = mapping.Property.PropertyType.GetCollectionElementType(); - var itemInfo = KdlTypeInfo.For(itemType); - - var targetName = mapping.CollectionElementName ?? itemInfo.NodeName; - - var items = container - .Children.Nodes.Where(n => n.Name.Value.Equals(targetName, NodeNameComparison)) - .ToList(); - - SetCollectionProperty(mapping.Property, instance, items); + // Mode B: Document Mode or Intrinsic Dictionary at root + if (mapping.IsDictionary) + { + PopulateDictionary( + (IDictionary)instance, + node.Children.Nodes, + mapping.DictionaryKeyProperty!.PropertyType, + mapping.DictionaryValueProperty!.PropertyType + ); + } + else + { + MapChildren(node.Children.Nodes, instance, mapping); + } } } /// /// Maps child KDL nodes to properties marked with [KdlNode]. /// - private void MapChildNodes(IReadOnlyList? nodes, object instance, KdlTypeInfo metadata) + private void MapChildren(List? nodes, object instance, KdlTypeMapping mapping) { if (nodes is null || nodes.Count == 0) return; - foreach (var mapping in metadata.Children) + foreach (var map in mapping.Children) { - var matches = nodes - .Where(n => n.Name.Value.Equals(mapping.Name, NodeNameComparison)) + List matches = nodes + .Where(n => n.Name.Value.Equals(map.KdlName, NodeNameComparison)) .ToList(); - if (matches.Count == 0) + if (matches is null || matches.Count == 0) continue; - var propType = mapping.Property.PropertyType; - var propMeta = KdlTypeInfo.For(propType); - - if (propMeta.IsIEnumerable) + if (map.IsDictionary) { - SetCollectionProperty(mapping.Property, instance, matches); + var container = matches.Last(); + if (container.Children != null) + { + var dict = EnsureInstance(instance, map) as IDictionary; + PopulateDictionary( + dict!, + container.Children.Nodes, + map.DictionaryKeyProperty!.PropertyType, + map.DictionaryValueProperty!.PropertyType + ); + } } - else + else if (map.IsCollection) { - if (matches.Count > 1) - throw new KuddleSerializationException( - $"Expected single node '{mapping.Name}', found {matches.Count}." - ); + KdlNode container = matches.Last(); - var node = matches[0]; - object value; + List nodesToProcess = container.HasChildren + ? container.Children?.Nodes! + : matches; - if (propMeta.IsComplexType) + PopulateCollection(instance, nodesToProcess, map); + } + else + { + var last = matches.Last(); + object? value; + + if (map.Property.PropertyType.IsKdlScalar) // Use your extension { - value = DeserializeObject(node, propType); + var arg = last.Arg(0); + value = + arg != null + ? KdlValueConverter.FromKdlOrThrow( + arg, + map.Property.PropertyType, + last.Name.Value + ) + : null; } else { - var arg = node.Arg(0); - if (arg is null) - continue; - value = KdlValueConverter.FromKdlOrThrow( - arg, - propType, - mapping.Name, - mapping.TypeAnnotation - ); + value = DeserializeObject(last, map.Property.PropertyType); } - mapping.Property.SetValue(instance, value); + map.SetValue(instance, value); } } } - private void MapDictionaries( - List? nodes, - KdlNode? parentNode, - object instance, - KdlTypeInfo metadata + private void PopulateCollection( + object parentInstance, + IEnumerable nodes, + KdlMemberMap map ) { - if (metadata.Dictionaries.Count == 0) - return; + var list = CreateList(map.ElementType!); + var elementMapping = KdlTypeMapping.For(map.ElementType!); - foreach (var member in metadata.Dictionaries) + foreach (var node in nodes) { - var propMeta = KdlTypeInfo.For(member.Property.PropertyType); - if (!propMeta.IsDictionary || propMeta.DictionaryDef is null) - continue; - - var (keyType, valueType) = propMeta.DictionaryDef; - var dict = GetOrCreateDictionary(instance, member); - if (dict is null) - continue; - - if (member.IsNodeDictionary && nodes != null) + // If the element name matches (or we are in a wrapped block), deserialize it + object? item; + if (map.ElementType!.IsKdlScalar) { - var container = nodes.LastOrDefault(n => - n.Name.Value.Equals(member.Name, NodeNameComparison) - ); - if (container?.Children?.Nodes != null) - { - PopulateNodeDictionary(container.Children.Nodes, dict, keyType, valueType); - } + var kdlVal = node.Arg(0); + item = + kdlVal != null + ? KdlValueConverter.FromKdlOrThrow( + kdlVal, + map.ElementType!, + node.Name.Value + ) + : null; } - } - static IDictionary? GetOrCreateDictionary(object instance, KdlMemberInfo member) - { - var dict = (IDictionary?)member.Property.GetValue(instance); - if (dict is null) + else { - dict = (IDictionary?)Activator.CreateInstance(member.Property.PropertyType); - member.Property.SetValue(instance, dict); + item = DeserializeObject(node, map.ElementType!); } - return dict; + if (item != null) + list.Add(item); } + + map.SetValue( + parentInstance, + ConvertCollection(list, map.Property.PropertyType, map.ElementType!) + ); } - private void PopulateNodeDictionary( - IEnumerable? children, + private void PopulateDictionary( IDictionary dict, + IEnumerable nodes, Type keyType, Type valueType ) { - if (children is null) - return; - - foreach (var child in children) + foreach (var node in nodes) { - object key = Convert.ChangeType(child.Name.Value, keyType); - object value; + object key = Convert.ChangeType(node.Name.Value, keyType); + object? value; - if (!valueType.IsComplexType) + if (valueType.IsKdlScalar) { - // Scalar: Arg 0 - var arg = child.Arg(0); - if (arg is null) - continue; - value = KdlValueConverter.FromKdlOrThrow(arg, valueType, $"Dictionary Value {key}"); + var arg = node.Arg(0); + value = + arg != null + ? KdlValueConverter.FromKdlOrThrow(arg, valueType, key.ToString()!) + : null; } else { - value = DeserializeObject(child, valueType); + value = DeserializeObject(node, valueType); } - if (dict.Contains(key)) - throw new KuddleSerializationException( - $"Duplicate dictionary key detected: '{key}'" - ); - - dict.Add(key, value); + if (value != null) + dict[key] = value; } } - private void SetPropertyValue( - PropertyInfo property, - object instance, - KdlValue kdlValue, - string? expectedTypeAnnotation = null - ) - { - var result = KdlValueConverter.FromKdlOrThrow( - kdlValue, - property.PropertyType, - $"Property: {property.DeclaringType?.Name}.{property.Name}", - expectedTypeAnnotation - ); - - property.SetValue(instance, result); - } - - private void SetCollectionProperty(PropertyInfo property, object instance, List nodes) + private object DeserializeObject(KdlNode node, Type type) { - var elementType = property.PropertyType.GetCollectionElementType(); - - var listType = typeof(List<>).MakeGenericType(elementType); - var list = (IList)Activator.CreateInstance(listType)!; - - var elementMetadata = KdlTypeInfo.For(elementType); - - foreach (var node in nodes) - { - object element; - if (elementMetadata.IsComplexType) - { - element = DeserializeObject(node, elementType); - } - else - { - var arg = node.Arg(0); - if (arg is null) - continue; - element = KdlValueConverter.FromKdlOrThrow(arg, elementType, "List Element"); - } - list.Add(element); - } + if (type.IsKdlScalar) { } + var instance = + Activator.CreateInstance(type) + ?? throw new KuddleSerializationException( + $"Failed to create instance of '{type.Name}'." + ); - var finalValue = ConvertToTargetCollectionType(property.PropertyType, elementType, list); - property.SetValue(instance, finalValue); + MapNodeToObject(node, instance, KdlTypeMapping.For(type)); + return instance; } private static void ValidateNodeName(KdlNode node, string nodeName) @@ -4161,11 +4074,24 @@ internal class ObjectDeserializer } } - private static object ConvertToTargetCollectionType( - Type targetType, - Type elementType, - IList list - ) + private object EnsureInstance(object parent, KdlMemberMap map) + { + var current = map.GetValue(parent); + if (current != null) + return current; + + var newInstance = Activator.CreateInstance(map.Property.PropertyType)!; + map.SetValue(parent, newInstance); + return newInstance; + } + + private IList CreateList(Type elementType) + { + var listType = typeof(List<>).MakeGenericType(elementType); + return (IList)Activator.CreateInstance(listType)!; + } + + private object ConvertCollection(IList list, Type targetType, Type elementType) { if (targetType.IsArray) { @@ -4173,13 +4099,7 @@ internal class ObjectDeserializer list.CopyTo(array, 0); return array; } - - if (targetType.IsAssignableFrom(list.GetType())) - { - return list; - } - - return list; + return list; // Assuming List is compatible with target (IEnumerable/IReadOnlyList) } } @@ -4188,6 +4108,7 @@ internal class ObjectDeserializer using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; using Kuddle.AST; namespace Kuddle.Serialization; @@ -4207,276 +4128,137 @@ internal class ObjectSerializer var type = typeof(T); var worker = new ObjectSerializer(options); - var metadata = KdlTypeInfo.For(); - if (!metadata.IsComplexType && !metadata.IsDictionary) + if (type.IsKdlScalar) { throw new KuddleSerializationException( $"Cannot serialize primitive type '{type.Name}'. Only complex types are supported." ); } + var doc = new KdlDocument(); - if (metadata.IsIEnumerable) + // If the root object itself is a collection, we treat every item as a top-level node. + if (typeof(T).IsIEnumerable && instance is IEnumerable enumerable) { - var nodes = worker.SerializeCollection((IEnumerable)instance); - doc.Nodes.AddRange(nodes); + foreach (var item in enumerable) + { + if (item != null) + doc.Nodes.Add(worker.SerializeObject(item)); + } } else { - var node = worker.SerializeToNode(instance); - doc.Nodes.Add(node); + doc.Nodes.Add(worker.SerializeObject(instance)); } return doc; } - public static KdlNode SerializeNode(object instance, KdlSerializerOptions? options) - { - var worker = new ObjectSerializer(options); - return worker.SerializeToNode(instance); - } - - private IEnumerable SerializeCollection( - IEnumerable collection, - string? overrideNodeName = null - ) - { - foreach (var item in collection) - { - if (item is null) - continue; - - yield return SerializeToNode(item, overrideNodeName); - } - } - - private KdlNode SerializeToNode(object instance, string? overrideNodeName = null) + private KdlNode SerializeObject(object instance, string? overrideNodeName = null) { - var metadata = KdlTypeInfo.For(instance.GetType()); - var nodeName = overrideNodeName ?? metadata.NodeName; - - var entries = new List(); - - // Serialize arguments (in order) - foreach (var mapping in metadata.ArgumentAttributes) - { - var value = mapping.Property.GetValue(instance); - var kdlValue = KdlValueConverter.ToKdlOrThrow( - value, - $"Argument property: {mapping.Property.Name}", - mapping.TypeAnnotation - ); - - entries.Add(new KdlArgument(kdlValue)); - } + var mapping = KdlTypeMapping.For(instance.GetType()); + var node = new KdlNode(KdlValue.From(overrideNodeName ?? mapping.NodeName)); - // Serialize properties - foreach (var mapping in metadata.Properties) + foreach (var map in mapping.Arguments) { - var value = mapping.Property.GetValue(instance); - var typeAnnotation = mapping.TypeAnnotation; - - if (!KdlValueConverter.TryToKdl(value, out var kdlValue, typeAnnotation)) - { - continue; // Skip properties that can't be converted - } - - entries.Add(new KdlProperty(KdlValue.From(mapping.Name), kdlValue)); + var val = KdlValueConverter.ToKdlOrThrow(map.GetValue(instance), map.TypeAnnotation); + node.Entries.Add(new KdlArgument(val)); } - foreach (var dictMap in metadata.Dictionaries.Where(m => m.IsPropertyDictionary)) + foreach (var map in mapping.Properties) { - var dict = (IDictionary?)dictMap.Property.GetValue(instance); - if (dict is null) + var raw = map.GetValue(instance); + if (raw == null && _options.IgnoreNullValues) continue; - foreach (DictionaryEntry entry in dict) - { - var keyStr = entry.Key.ToString()!; - if (entry.Value != null && KdlValueConverter.TryToKdl(entry.Value, out var val)) - { - entries.Add(new KdlProperty(KdlValue.From(keyStr), val)); - } - } - } - // Serialize children - KdlBlock? childBlock = null; - - void AddChild(KdlNode child) - { - childBlock ??= new KdlBlock(); - childBlock.Nodes.Add(child); + var val = KdlValueConverter.ToKdlOrThrow(raw, map.TypeAnnotation); + node.Entries.Add(new KdlProperty(KdlValue.From(map.KdlName), val)); } - foreach (var mapping in metadata.Children) + var childNodes = new List(); + foreach (var map in mapping.Children) { - var propValue = mapping.Property.GetValue(instance); - - if (propValue is null) + var childData = map.GetValue(instance); + if (childData is null) continue; - var propType = mapping.Property.PropertyType; - var childMeta = KdlTypeInfo.For(propType); - - if (childMeta.IsIEnumerable) + if (map.IsDictionary && childData is IEnumerable mapDict) { - var childNodes = SerializeCollection((IEnumerable)propValue, mapping.Name); - foreach (var child in childNodes) - { - AddChild(child); - } + var container = new KdlNode(KdlValue.From(map.KdlName)); + var items = SerializeDictionary( + mapDict, + map.DictionaryKeyProperty, + map.DictionaryValueProperty + ); + container = container with { Children = new KdlBlock { Nodes = items.ToList() } }; + childNodes.Add(container); } - else if (childMeta.IsComplexType) + else if (map.IsCollection && childData is IEnumerable childCol) { - AddChild(SerializeToNode(propValue, mapping.Name)); + childNodes.AddRange(SerializeCollection(childCol, map)); } else { - var kdlValue = KdlValueConverter.ToKdlOrThrow( - propValue, - $"Child scalar property: {mapping.Property.Name}", - mapping.TypeAnnotation - ); - - var scalarNode = new KdlNode(KdlValue.From(mapping.Name)) - { - Entries = [new KdlArgument(kdlValue)], - }; - AddChild(scalarNode); + childNodes.Add(SerializeObject(childData, map.KdlName)); } } - foreach (var dictMap in metadata.Dictionaries) + if (mapping.IsDictionary && instance is IEnumerable enumerable) { - if (dictMap.IsPropertyDictionary) - continue; // Handled above - - var dict = (IDictionary?)dictMap.Property.GetValue(instance); - if (dict is null || dict.Count == 0) - continue; - - var (_, valueType) = KdlTypeInfo.For(dictMap.Property.PropertyType).DictionaryDef!; - - if (dictMap.IsNodeDictionary) - { - // Strategy: Create a container node, put entries inside - // themes { dark { ... } } - var container = new KdlNode(KdlValue.From(dictMap.Name)); - var nodes = new List(); - SerializeDictionaryEntries(nodes, dict, valueType); + var items = SerializeDictionary( + enumerable, + mapping.DictionaryKeyProperty, + mapping.DictionaryValueProperty + ); + childNodes.AddRange(items); + } - if (nodes.Count > 0) - { - container = container with { Children = new KdlBlock() { Nodes = nodes } }; - AddChild(container); - } - } - else if (dictMap.IsKeyedNodeCollection) - { - // Strategy: Flatten entries as children - // db "A" { ... } - // db "B" { ... } - foreach (var value in dict.Values) - { - if (value is null) - continue; - // For KeyedNodes, the Object contains the Key, so we just serialize the Object - // with the forced Node Name from the attribute. - AddChild(SerializeToNode(value, dictMap.Name)); - } - } + if (childNodes.Count > 0) + { + node = node with { Children = new KdlBlock { Nodes = childNodes } }; } + return node; + } - foreach (var mapping in metadata.Collections) + private IEnumerable SerializeCollection(IEnumerable enumerable, KdlMemberMap map) + { + foreach (var item in enumerable) { - var val = mapping.Property.GetValue(instance); - if (val is null) + if (item is null) continue; - var collection = (IEnumerable)val; - - // 1. Create the Container Node - var container = new KdlNode(KdlValue.From(mapping.Name)); - var nodes = new List(); - // 2. Determine Item Name - var itemType = mapping.Property.PropertyType.GetCollectionElementType(); - var itemMeta = KdlTypeInfo.For(itemType); - var targetName = mapping.CollectionElementName ?? itemMeta.NodeName; - - // 3. Serialize Items as children of the Container - foreach (var item in collection) - { - if (item is null) - continue; - // Recursively serialize, forcing the name to "event" - nodes.Add(SerializeToNode(item, targetName)); - } - - // 4. Attach - if (nodes.Count > 0) - { - container = container with { Children = new KdlBlock() { Nodes = nodes } }; - AddChild(container); - } + yield return MapToNode(item, map.KdlName, map.TypeAnnotation); } + } - if ( - metadata.IsDictionary - && metadata.DictionaryDef != null - && instance is IDictionary selfDict - ) + private KdlNode MapToNode(object item, string kdlName, string? typeAnnotation) + { + if (item.GetType().IsKdlScalar) { - childBlock ??= new KdlBlock(); - - SerializeDictionaryEntries( - childBlock.Nodes, - selfDict, - metadata.DictionaryDef.ValueType - ); + var val = KdlValueConverter.ToKdlOrThrow(item, typeAnnotation); + return new KdlNode(KdlValue.From(kdlName)) { Entries = [new KdlArgument(val)] }; } - return new KdlNode(KdlValue.From(nodeName)) + else { - Entries = entries, - Children = childBlock?.Nodes.Count > 0 ? childBlock : null, - }; + return SerializeObject(item, kdlName); + } } - /// - /// Helper to convert Dictionary entries into KDL Nodes. - /// Used by [KdlNodeDictionary] and Implicit Dictionary logic. - /// - private void SerializeDictionaryEntries( - List targetList, - IDictionary dict, - Type valueType + private IEnumerable SerializeDictionary( + IEnumerable dict, + PropertyInfo? keyProp, + PropertyInfo? valProp, + string? typeAnno = null ) { - var isScalar = valueType.IsKdlScalar; - - foreach (DictionaryEntry entry in dict) + foreach (var item in dict) { - var keyStr = entry.Key.ToString(); - if (string.IsNullOrEmpty(keyStr) || entry.Value is null) + var key = keyProp?.GetValue(item); + var val = valProp?.GetValue(item); + if (key == null || val == null) continue; - if (isScalar) - { - // Scalar: NodeName=Key, Arg0=Value - // e.g. timeout 5000 - if (KdlValueConverter.TryToKdl(entry.Value, out var kdlVal, null)) - { - targetList.Add( - new KdlNode(KdlValue.From(keyStr)) { Entries = [new KdlArgument(kdlVal)] } - ); - } - } - else - { - // Complex: NodeName=Key, Body=Object - // e.g. dark-mode { ... } - // We recurse SerializeToNode, but we override the name to be the Key - targetList.Add(SerializeToNode(entry.Value, overrideNodeName: keyStr)); - } + yield return MapToNode(val, key.ToString()!, typeAnno); } } } @@ -4910,4813 +4692,3 @@ public class SerializerBenchmarks } ``` -// File: src\Kuddle.Net.Tests\AST\KdlNumberTests.cs`$langusing Kuddle.AST; - -namespace Kuddle.Tests.AST; - -public class KdlNumberTests -{ - #region Successful Conversions - All Number Bases - - [Test] - [Arguments("42", 42)] - [Arguments("0x2A", 42)] - [Arguments("0o52", 42)] - [Arguments("0b101010", 42)] - [Arguments("-42", -42)] - [Arguments("-0x2A", -42)] - [Arguments("-0o52", -42)] - [Arguments("-0b101010", -42)] - public async Task ToInt32_ConvertsAllBases(string input, int expected) - { - var sut = new KdlNumber(input); - int result = sut.ToInt32(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("42", (uint)42)] - [Arguments("0x2A", (uint)42)] - [Arguments("0o52", (uint)42)] - [Arguments("0b101010", (uint)42)] - public async Task ToUInt32_ConvertsAllBases(string input, uint expected) - { - var sut = new KdlNumber(input); - uint result = sut.ToUInt32(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("42", (long)42)] - [Arguments("0x2A", (long)42)] - [Arguments("0o52", (long)42)] - [Arguments("0b101010", (long)42)] - [Arguments("-42", (long)-42)] - [Arguments("-0x2A", (long)-42)] - [Arguments("-0o52", (long)-42)] - [Arguments("-0b101010", (long)-42)] - public async Task ToInt64_ConvertsAllBases(string input, long expected) - { - var sut = new KdlNumber(input); - var result = sut.ToInt64(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("42", (ulong)42)] - [Arguments("0x2A", (ulong)42)] - [Arguments("0o52", (ulong)42)] - [Arguments("0b101010", (ulong)42)] - public async Task ToUInt64_ConvertsAllBases(string input, ulong expected) - { - var sut = new KdlNumber(input); - ulong result = sut.ToUInt64(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("42", (short)42)] - [Arguments("0x2A", (short)42)] - [Arguments("0o52", (short)42)] - [Arguments("0b101010", (short)42)] - [Arguments("-42", (short)-42)] - [Arguments("-0x2A", (short)-42)] - [Arguments("-0o52", (short)-42)] - [Arguments("-0b101010", (short)-42)] - public async Task ToInt16_ConvertsAllBases(string input, short expected) - { - var sut = new KdlNumber(input); - short result = sut.ToInt16(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("42", (ushort)42)] - [Arguments("0x2A", (ushort)42)] - [Arguments("0o52", (ushort)42)] - [Arguments("0b101010", (ushort)42)] - public async Task ToUInt16_ConvertsAllBases(string input, ushort expected) - { - var sut = new KdlNumber(input); - ushort result = sut.ToUInt16(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("42", (byte)42)] - [Arguments("0x2A", (byte)42)] - [Arguments("0o52", (byte)42)] - [Arguments("0b101010", (byte)42)] - public async Task ToByte_ConvertsAllBases(string input, byte expected) - { - var sut = new KdlNumber(input); - byte result = sut.ToByte(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("42", (sbyte)42)] - [Arguments("0x2A", (sbyte)42)] - [Arguments("0o52", (sbyte)42)] - [Arguments("0b101010", (sbyte)42)] - [Arguments("-42", (sbyte)-42)] - [Arguments("-0x2A", (sbyte)-42)] - [Arguments("-0o52", (sbyte)-42)] - [Arguments("-0b101010", (sbyte)-42)] - public async Task ToSByte_ConvertsAllBases(string input, sbyte expected) - { - var sut = new KdlNumber(input); - sbyte result = sut.ToSByte(); - await Assert.That(result).IsEqualTo(expected); - } - - #endregion - - #region Overflow and Invalid Conversion Tests - - [Test] - [Arguments("256")] - [Arguments("-1")] - public async Task ToByte_ThrowsOnInvalidValues(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToByte()).Throws(); - } - - [Test] - [Arguments("65536")] - [Arguments("-1")] - public async Task ToUInt16_ThrowsOnInvalidValues(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToUInt16()).Throws(); - } - - [Test] - [Arguments("50000")] - [Arguments("-50000")] - [Arguments("32768")] - [Arguments("-32769")] - public async Task ToInt16_ThrowsOnInvalidValues(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToInt16()).Throws(); - } - - [Test] - [Arguments("-42")] - [Arguments("4294967296")] - [Arguments("-1")] - public async Task ToUInt32_ThrowsOnInvalidValues(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToUInt32()).Throws(); - } - - [Test] - [Arguments("-42")] - [Arguments("18446744073709551616")] - [Arguments("-1")] - public async Task ToUInt64_ThrowsOnInvalidValues(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToUInt64()).Throws(); - } - - [Test] - [Arguments("2147483648")] - [Arguments("-2147483649")] - public async Task ToInt32_ThrowsOnInvalidValues(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToInt32()).Throws(); - } - - [Test] - [Arguments("9_223_372_036_854_775_808")] - [Arguments("-9_223_372_036_854_775_809")] - public async Task ToInt64_ThrowsOnInvalidValues(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToInt64()).Throws(); - } - - #endregion - - #region Special Number Handling Tests - - [Test] - [Arguments("#inf")] - [Arguments("#-inf")] - [Arguments("#nan")] - public async Task ToInt32_ThrowsOnSpecialNumbers(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToInt32()).Throws(); - } - - [Test] - [Arguments("#inf")] - [Arguments("#-inf")] - [Arguments("#nan")] - public async Task ToUInt32_ThrowsOnSpecialNumbers(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToUInt32()).Throws(); - } - - [Test] - [Arguments("#inf")] - [Arguments("#-inf")] - [Arguments("#nan")] - public async Task ToInt64_ThrowsOnSpecialNumbers(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToInt64()).Throws(); - } - - [Test] - [Arguments("#inf")] - [Arguments("#-inf")] - [Arguments("#nan")] - public async Task ToUInt64_ThrowsOnSpecialNumbers(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToUInt64()).Throws(); - } - - [Test] - [Arguments("#inf")] - [Arguments("#-inf")] - [Arguments("#nan")] - public async Task ToInt16_ThrowsOnSpecialNumbers(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToInt16()).Throws(); - } - - [Test] - [Arguments("#inf")] - [Arguments("#-inf")] - [Arguments("#nan")] - public async Task ToUInt16_ThrowsOnSpecialNumbers(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToUInt16()).Throws(); - } - - [Test] - [Arguments("#inf")] - [Arguments("#-inf")] - [Arguments("#nan")] - public async Task ToByte_ThrowsOnSpecialNumbers(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToByte()).Throws(); - } - - [Test] - [Arguments("#inf")] - [Arguments("#-inf")] - [Arguments("#nan")] - public async Task ToSByte_ThrowsOnSpecialNumbers(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToSByte()).Throws(); - } - - [Test] - public async Task ToDouble_HandlesInfinity() - { - var posInf = new KdlNumber("#inf"); - var negInf = new KdlNumber("#-inf"); - var nan = new KdlNumber("#nan"); - - await Assert.That(posInf.ToDouble()).IsEqualTo(double.PositiveInfinity); - await Assert.That(negInf.ToDouble()).IsEqualTo(double.NegativeInfinity); - await Assert.That(double.IsNaN(nan.ToDouble())).IsTrue(); - } - - [Test] - public async Task ToFloat_HandlesInfinity() - { - var posInf = new KdlNumber("#inf"); - var negInf = new KdlNumber("#-inf"); - var nan = new KdlNumber("#nan"); - - await Assert.That(posInf.ToFloat()).IsEqualTo(float.PositiveInfinity); - await Assert.That(negInf.ToFloat()).IsEqualTo(float.NegativeInfinity); - await Assert.That(float.IsNaN(nan.ToFloat())).IsTrue(); - } - - #endregion - - #region Edge Case Tests - - [Test] - [Arguments("0")] - [Arguments("0x0")] - [Arguments("0o0")] - [Arguments("0b0")] - public async Task ToInt32_HandlesZero(string input) - { - var sut = new KdlNumber(input); - var result = sut.ToInt32(); - await Assert.That(result).IsEqualTo(0); - } - - [Test] - [Arguments("2147483647")] - [Arguments("-2147483648")] - public async Task ToInt32_HandlesBoundaries(string input) - { - var sut = new KdlNumber(input); - var result = sut.ToInt32(); - await Assert.That(result).IsEqualTo(int.Parse(input)); - } - - [Test] - [Arguments("4294967295")] - public async Task ToUInt32_HandlesMaxBoundary(string input) - { - var sut = new KdlNumber(input); - var result = sut.ToUInt32(); - await Assert.That(result).IsEqualTo(uint.MaxValue); - } - - [Test] - [Arguments("9223372036854775807")] - [Arguments("-9223372036854775808")] - public async Task ToInt64_HandlesBoundaries(string input) - { - var sut = new KdlNumber(input); - var result = sut.ToInt64(); - await Assert.That(result).IsEqualTo(long.Parse(input)); - } - - [Test] - [Arguments("18446744073709551615")] - public async Task ToUInt64_HandlesMaxBoundary(string input) - { - var sut = new KdlNumber(input); - var result = sut.ToUInt64(); - await Assert.That(result).IsEqualTo(ulong.MaxValue); - } - - [Test] - [Arguments("32767")] - [Arguments("-32768")] - public async Task ToInt16_HandlesBoundaries(string input) - { - var sut = new KdlNumber(input); - var result = sut.ToInt16(); - await Assert.That(result).IsEqualTo(short.Parse(input)); - } - - [Test] - [Arguments("65535")] - public async Task ToUInt16_HandlesMaxBoundary(string input) - { - var sut = new KdlNumber(input); - var result = sut.ToUInt16(); - await Assert.That(result).IsEqualTo(ushort.MaxValue); - } - - [Test] - [Arguments("127")] - [Arguments("-128")] - public async Task ToSByte_HandlesBoundaries(string input) - { - var sut = new KdlNumber(input); - var result = sut.ToSByte(); - await Assert.That(result).IsEqualTo(sbyte.Parse(input)); - } - - [Test] - [Arguments("255")] - public async Task ToByte_HandlesMaxBoundary(string input) - { - var sut = new KdlNumber(input); - var result = sut.ToByte(); - await Assert.That(result).IsEqualTo(byte.MaxValue); - } - - #endregion - - #region Underscore Separator Tests - - [Test] - [Arguments("1_000_000", 1000000)] - [Arguments("0xFF_FF", 65535)] - [Arguments("0o777_777", 262143)] - [Arguments("0b1111_0000_1010_0101", 61605)] - public async Task ToInt32_HandlesUnderscores(string input, int expected) - { - var sut = new KdlNumber(input); - var result = sut.ToInt32(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("1_000_000_000_000", 1000000000000)] - [Arguments("0xFFFF_FFFF_FFFF", 281474976710655)] - public async Task ToInt64_HandlesUnderscores(string input, long expected) - { - var sut = new KdlNumber(input); - var result = sut.ToInt64(); - await Assert.That(result).IsEqualTo(expected); - } - - #endregion - - #region Floating-Point Number Tests - - [Test] - [Arguments("3.14159", 3.14159)] - [Arguments("1.23e-4", 0.000123)] - [Arguments("6.02E23", 6.02e23)] - [Arguments("-2.5", -2.5)] - [Arguments("0.0", 0.0)] - public async Task ToDouble_ConvertsDecimalNumbers(string input, double expected) - { - var sut = new KdlNumber(input); - double result = sut.ToDouble(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("3.14159", 3.14159f)] - [Arguments("1.23e-4", 0.000123f)] - [Arguments("6.02E23", 6.02e23f)] - [Arguments("-2.5", -2.5f)] - [Arguments("0.0", 0.0f)] - public async Task ToFloat_ConvertsDecimalNumbers(string input, float expected) - { - var sut = new KdlNumber(input); - float result = sut.ToFloat(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("3.14159", 3.14159)] - [Arguments("1.23e-4", 0.000123)] - [Arguments("6.02E23", 6.02e23)] - [Arguments("-2.5", -2.5)] - [Arguments("0.0", 0.0)] - public async Task ToDecimal_ConvertsDecimalNumbers(string input, decimal expected) - { - var sut = new KdlNumber(input); - decimal result = sut.ToDecimal(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("42", (double)42)] - [Arguments("0x2A", (double)42)] - [Arguments("0o52", (double)42)] - [Arguments("0b101010", (double)42)] - [Arguments("-42", (double)-42)] - [Arguments("-0x2A", (double)-42)] - [Arguments("-0o52", (double)-42)] - [Arguments("-0b101010", (double)-42)] - public async Task ToDouble_ConvertsAllBases(string input, double expected) - { - var sut = new KdlNumber(input); - double result = sut.ToDouble(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("42", (float)42)] - [Arguments("0x2A", (float)42)] - [Arguments("0o52", (float)42)] - [Arguments("0b101010", (float)42)] - [Arguments("-42", (float)-42)] - [Arguments("-0x2A", (float)-42)] - [Arguments("-0o52", (float)-42)] - [Arguments("-0b101010", (float)-42)] - public async Task ToFloat_ConvertsAllBases(string input, float expected) - { - var sut = new KdlNumber(input); - double result = sut.ToFloat(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("42", 42)] - [Arguments("0x2A", 42)] - [Arguments("0o52", 42)] - [Arguments("0b101010", 42)] - [Arguments("-42", -42)] - [Arguments("-0x2A", -42)] - [Arguments("-0o52", -42)] - [Arguments("-0b101010", -42)] - public async Task ToDecimal_ConvertsAllBases(string input, decimal expected) - { - var sut = new KdlNumber(input); - decimal result = sut.ToDecimal(); - await Assert.That(result).IsEqualTo(expected); - } - - #endregion - - #region Float to Integer Conversion Tests - - [Test] - [Arguments("3.14159")] - [Arguments("1.23e-4")] - [Arguments("6.02E23")] - [Arguments("-2.5")] - public async Task ToInt32_ThrowsOnFloatNumbers(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToInt32()).Throws(); - } - - [Test] - [Arguments("3.14159")] - [Arguments("1.23e-4")] - [Arguments("6.02E23")] - [Arguments("-2.5")] - public async Task ToInt64_ThrowsOnFloatNumbers(string input) - { - var sut = new KdlNumber(input); - await Assert.That(() => sut.ToInt64()).Throws(); - } - - #endregion - - #region Additional KdlNumber Parsing Logic Tests - - [Test] - [Arguments("123", NumberBase.Decimal)] - [Arguments("-100", NumberBase.Decimal)] - [Arguments("10.5", NumberBase.Decimal)] - [Arguments("0xFF", NumberBase.Hex)] - [Arguments("0x1A", NumberBase.Hex)] - [Arguments("0o77", NumberBase.Octal)] - [Arguments("0b1010", NumberBase.Binary)] - public async Task GetBase_ReturnsCorrectBase(string input, NumberBase expected) - { - var sut = new KdlNumber(input); - var result = sut.GetBase(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("0xFF", 255)] - [Arguments("0x1A", 26)] - [Arguments("0o77", 63)] - [Arguments("0b1010", 10)] - public async Task Given_HexString_When_ToInt32_Then_ReturnsCorrectValue( - string raw, - int expected - ) - { - var number = new KdlNumber(raw); - var result = number.ToInt32(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - [Arguments("1_000", 1000)] - [Arguments("0xFF_FF", 65535)] - [Arguments("0o777_777", 262143)] - [Arguments("0b1111_0000", 240)] - public async Task Given_UnderscoreFormatted_When_ToInt32_Then_UnderscoresIgnored( - string raw, - int expected - ) - { - var number = new KdlNumber(raw); - var result = number.ToInt32(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - public async Task Given_LargeNumber_When_ToInt32_Then_ThrowsOverflow() - { - var number = new KdlNumber("9999999999"); - await Assert.That(() => number.ToInt32()).Throws(); - } - - [Test] - [Arguments("10.5")] - [Arguments("1.23e4")] - public async Task Given_FloatString_When_ToInt64_Then_ThrowsFormatException(string raw) - { - var number = new KdlNumber(raw); - await Assert.That(() => number.ToInt64()).Throws(); - } - - #endregion -} - -``` -// File: src\Kuddle.Net.Tests\AST\KdlStringTests.cs`$langusing Kuddle.AST; - -namespace Kuddle.Tests.AST; - -public class KdlStringTests -{ - #region Constructor and Value Property Tests - - [Test] - [Arguments("world", StringKind.Quoted)] - [Arguments("", StringKind.Raw)] - [Arguments("string\nwith\nnewlines", StringKind.Quoted)] - public async Task Constructor_SetsValueAndTypeCorrectly(string input, StringKind type) - { - var sut = new KdlString(input, type); - await Assert.That(sut.Value).IsEqualTo(input); - await Assert.That(sut.Kind).IsEqualTo(type); - } - - #endregion - - #region Equality Tests - - [Test] - public async Task Equals_ReturnsTrueForSameValueAndType() - { - var sut1 = new KdlString("test", StringKind.Quoted); - var sut2 = new KdlString("test", StringKind.Quoted); - await Assert.That(sut1).IsEqualTo(sut2); - } - - [Test] - public async Task Equals_ReturnsFalseForDifferentValue() - { - var sut1 = new KdlString("test1", StringKind.Quoted); - var sut2 = new KdlString("test2", StringKind.Quoted); - await Assert.That(sut1).IsNotEqualTo(sut2); - } - - [Test] - public async Task Equals_ReturnsFalseForDifferentType() - { - var sut1 = new KdlString("test", StringKind.Quoted); - var sut2 = new KdlString("test", StringKind.Bare); - await Assert.That(sut1).IsNotEqualTo(sut2); - } - - [Test] - public async Task Equals_ReturnsFalseForNull() - { - var sut = new KdlString("test", StringKind.Quoted); - await Assert.That(sut.Equals(null)).IsFalse(); - } - - #endregion - - #region ToString Tests - - [Test] - [Arguments("hello", StringKind.Bare)] - [Arguments("world", StringKind.Quoted)] - [Arguments("", StringKind.Raw)] - public async Task ToString_ReturnsValue(string input, StringKind type) - { - var sut = new KdlString(input, type); - await Assert.That(sut.ToString()).IsEqualTo(input); - } - - #endregion - - #region TypeAnnotation Tests - - [Test] - public async Task TypeAnnotation_CanBeSet() - { - var sut = new KdlString("test", StringKind.Quoted) { TypeAnnotation = "custom" }; - await Assert.That(sut.TypeAnnotation).IsEqualTo("custom"); - } - - [Test] - public async Task TypeAnnotation_DefaultsToNull() - { - var sut = new KdlString("test", StringKind.Quoted); - await Assert.That(sut.TypeAnnotation).IsNull(); - } - - #endregion - - #region HashCode Tests - - [Test] - public async Task GetHashCode_ReturnsSameForEqualValuesAndTypes() - { - var sut1 = new KdlString("test", StringKind.Quoted); - var sut2 = new KdlString("test", StringKind.Quoted); - await Assert.That(sut1.GetHashCode()).IsEqualTo(sut2.GetHashCode()); - } - - [Test] - public async Task GetHashCode_ReturnsDifferentForDifferentValues() - { - var sut1 = new KdlString("test1", StringKind.Quoted); - var sut2 = new KdlString("test2", StringKind.Quoted); - await Assert.That(sut1.GetHashCode()).IsNotEqualTo(sut2.GetHashCode()); - } - - [Test] - public async Task GetHashCode_ReturnsDifferentForDifferentTypes() - { - var sut1 = new KdlString("test", StringKind.Quoted); - var sut2 = new KdlString("test", StringKind.Bare); - await Assert.That(sut1.GetHashCode()).IsNotEqualTo(sut2.GetHashCode()); - } - - #endregion - - #region Edge Case Tests - - [Test] - public async Task EmptyString_HandlesCorrectly() - { - var sut = new KdlString("", StringKind.Quoted); - await Assert.That(sut.Value).IsEqualTo(""); - await Assert.That(sut.ToString()).IsEqualTo(""); - await Assert.That(sut.Kind).IsEqualTo(StringKind.Quoted); - } - - [Test] - public async Task StringWithSpecialCharacters_HandlesCorrectly() - { - var input = "special\tchars\n\"quotes\""; - var sut = new KdlString(input, StringKind.Raw); - await Assert.That(sut.Value).IsEqualTo(input); - await Assert.That(sut.Kind).IsEqualTo(StringKind.Raw); - } - - #endregion -} - -``` -// File: src\Kuddle.Net.Tests\Errors\ErrorHandlingTests.cs`$langusing Kuddle.Exceptions; -using Kuddle.Serialization; - -namespace Kuddle.Tests.Errors; - -#pragma warning disable CS8602 // Dereference of a possibly null reference. - -public class ErrorHandlingTests -{ - private static async Task AssertParseFails( - string kdl, - string expectedMessagePart, - int? expectedLine = null - ) - { - var ex = await Assert.ThrowsAsync(async () => KdlReader.Read(kdl)); - - await Assert - .That(ex.Message) - .Contains(expectedMessagePart, StringComparison.OrdinalIgnoreCase); - - if (expectedLine.HasValue) - { - await Assert.That(ex.Line).IsEqualTo(expectedLine.Value); - } - } - - #region Reserved Keywords (Your Custom Logic) - - [Test] - public async Task ReservedKeyword_AsNodeName_ThrowsSpecificError() - { - const string input = "true \"value\""; - - await AssertParseFails(input, "keyword 'true' cannot be used"); - } - - [Test] - public async Task ReservedKeyword_AsPropKey_ThrowsSpecificError() - { - const string input = "node null=10"; - - await AssertParseFails(input, "keyword 'null' cannot be used"); - } - - [Test] - public async Task ReservedKeyword_AsTypeAnnotation_ThrowsSpecificError() - { - const string input = "(false)node"; - - await AssertParseFails(input, "keyword 'false' cannot be used"); - } - - #endregion - - #region Structure & Blocks - - [Test] - public async Task Block_Unclosed_Throws() - { - const string input = - @" -node { - child -"; - - await AssertParseFails(input, "expected", expectedLine: 3); - } - - [Test] - public async Task Block_MissingSpaceBefore_IsAllowed_But_MissingSemiColon_Throws() - { - const string input = "node { child"; - - await AssertParseFails(input, "expected"); - } - - [Test] - public async Task Property_MissingValue_Throws() - { - const string input = "node key="; - - await AssertParseFails(input, "expected"); - } - - [Test] - public async Task TypeAnnotation_Unclosed_Throws() - { - const string input = "node (u8 value"; - - await AssertParseFails(input, "expected"); - } - - #endregion - - #region String Literals - - [Test] - public async Task String_UnclosedQuote_Throws() - { - const string input = "node \"oops"; - - await AssertParseFails(input, "expected"); - } - - [Test] - public async Task String_InvalidEscape_Throws() - { - const string input = "node \"hello \\q world\""; - - await AssertParseFails(input, "expected"); - } - - [Test] - public async Task RawString_MismatchHashes_Throws() - { - const string input = "node ##\"content\"#"; - - await AssertParseFails(input, "expected"); - } - - #endregion -} -#pragma warning restore CS8602 // Dereference of a possibly null reference. - -``` -// File: src\Kuddle.Net.Tests\Extensions\DxTests.cs`$langusing Kuddle.AST; -using Kuddle.Extensions; -using Kuddle.Parser; -using Kuddle.Serialization; - -namespace Kuddle.Tests.Extensions; - -public class DxTests -{ - [Test] - public async Task TryGet_Int_Success() - { - var doc = KdlReader.Read("node 123"); - var val = doc.Nodes[0].Arg(0)!; - - bool success = val.TryGetInt(out int result); - - await Assert.That(success).IsTrue(); - await Assert.That(result).IsEqualTo(123); - } - - [Test] - public async Task TryGet_Int_Failure_WrongType() - { - var doc = KdlReader.Read("node \"hello\""); - var val = doc.Nodes[0].Arg(0)!; - - bool success = val.TryGetInt(out int result); - - await Assert.That(success).IsFalse(); - await Assert.That(result).IsEqualTo(0); // Default - } - - [Test] - public async Task TryGet_Int_Failure_Overflow() - { - // Value larger than Int32 - var doc = KdlReader.Read("node 9999999999"); - var val = doc.Nodes[0].Arg(0)!; - - bool success = val.TryGetInt(out int result); - - await Assert.That(success).IsFalse(); - } - - [Test] - public async Task TryGet_Prop_Navigation_Success() - { - var doc = KdlReader.Read("server port=8080"); - var node = doc.Nodes[0]; - - // Combine finding the prop and converting it - var propVal = node.Prop("port")!; - - if (propVal.TryGetInt(out int port)) - { - await Assert.That(port).IsEqualTo(8080); - } - else - { - Assert.Fail("Failed to get port"); - } - } - - [Test] - public async Task TryGet_Prop_Navigation_Missing() - { - var doc = KdlReader.Read("server host=\"localhost\""); - var node = doc.Nodes[0]; - - var propVal = node.Prop("port")!; // Returns KdlNull - - bool success = propVal.TryGetInt(out _); - await Assert.That(success).IsFalse(); - } - - [Test] - public async Task TryGetUuid_ValidGuidString_ReturnsTrue() - { - var expected = Guid.NewGuid(); - var kdl = $"node \"{expected}\""; // e.g. "f81d4fae-7dec-11d0-a765-00a0c91e6bf6" - var doc = KdlReader.Read(kdl); - var val = doc.Nodes[0].Arg(0)!; - - bool success = val.TryGetUuid(out var result); - - await Assert.That(success).IsTrue(); - await Assert.That(result).IsEqualTo(expected); - } - - [Test] - public async Task TryGetUuid_InvalidString_ReturnsFalse() - { - var doc = KdlReader.Read("node \"not-a-guid\""); - var val = doc.Nodes[0].Arg(0)!; - - bool success = val.TryGetUuid(out var result); - - await Assert.That(success).IsFalse(); - await Assert.That(result).IsEqualTo(Guid.Empty); - } - - [Test] - public async Task TryGetUuid_FromAnnotatedValue_ReturnsTrue() - { - var expected = Guid.NewGuid(); - var kdl = $"node (uuid)\"{expected}\""; - var doc = KdlReader.Read(kdl); - var val = doc.Nodes[0].Arg(0)!; - - bool success = val.TryGetUuid(out var result); - - await Assert.That(success).IsTrue(); - await Assert.That(result).IsEqualTo(expected); - - await Assert.That(val.TypeAnnotation).IsEqualTo("uuid"); - } - - [Test] - public async Task Factory_FromGuid_CreatesAnnotatedString() - { - var guid = Guid.NewGuid(); - var val = KdlValue.From(guid)!; - - await Assert.That(val).IsOfType(typeof(KdlString)); - - await Assert.That(val.Value).IsEqualTo(guid.ToString()); - - await Assert.That(val.TypeAnnotation).IsEqualTo("uuid"); - } - - [Test] - public async Task TryGetDateTime_ValidIso8601_ReturnsTrue() - { - var now = DateTimeOffset.UtcNow; - var kdl = $"node \"{now:O}\""; - var doc = KdlReader.Read(kdl); - var val = doc.Nodes[0].Arg(0)!; - - bool success = val.TryGetDateTime(out var result); - - await Assert.That(success).IsTrue(); - await Assert.That(result).IsEqualTo(now); - } - - [Test] - public async Task TryGetDateTime_DateOnly_ReturnsTrue() - { - // YYYY-MM-DD - var kdl = "node \"2023-10-25\""; - var doc = KdlReader.Read(kdl); - var val = doc.Nodes[0].Arg(0)!; - - bool success = val.TryGetDateTime(out var result); - - await Assert.That(success).IsTrue(); - await Assert.That(result.Year).IsEqualTo(2023); - await Assert.That(result.Month).IsEqualTo(10); - await Assert.That(result.Day).IsEqualTo(25); - } - - [Test] - public async Task TryGetDateTime_InvalidString_ReturnsFalse() - { - var doc = KdlReader.Read("node \"tomorrow\""); - var val = doc.Nodes[0].Arg(0)!; - - bool success = val.TryGetDateTime(out var result); - - await Assert.That(success).IsFalse(); - await Assert.That(result).IsEqualTo(default(DateTimeOffset)); - } - - [Test] - public async Task Factory_FromDateTime_CreatesAnnotatedString() - { - var date = new DateTimeOffset(2023, 12, 25, 10, 0, 0, TimeSpan.Zero); - var val = KdlValue.From(date)!; - - // 1. Check Runtime Type - await Assert.That(val).IsOfType(typeof(KdlString)); - - // 2. Check Content (ISO 8601) - await Assert.That(val.Value).IsEqualTo(date.ToString("O")); - - // 3. Check Type Annotation - await Assert.That(val.TypeAnnotation).IsEqualTo("date-time"); - } -} - -``` -// File: src\Kuddle.Net.Tests\Grammar\CommentParserTests.cs`$langusing Kuddle.Parser; - -namespace Kuddle.Tests.Grammar; - -public class CommentParserTests -{ - [Test] - public async Task CanParseSimpleSingleLineComment() - { - var sut = KdlGrammar.SingleLineComment; - - var comment = """// I am a single line comment"""; - - bool success = sut.TryParse(comment, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(comment); - } - - [Test] - public async Task CanParseSimpleMultiLineComment() - { - var sut = KdlGrammar.MultiLineComment; - - var comment = """ -/* -some -comments -*/ -"""; - - bool success = sut.TryParse(comment, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(comment); - } - - [Test] - public async Task CanParseNestedMultiLineComment() - { - var sut = KdlGrammar.MultiLineComment; - - var comment = """ -/* - i am the first comment - /* - i am the nested comment - */ -*/ -"""; - - bool success = sut.TryParse(comment, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(comment); - } -} - -``` -// File: src\Kuddle.Net.Tests\Grammar\NodeParserTests.cs`$langusing Kuddle.AST; -using Kuddle.Parser; - -namespace Kuddle.Tests.Grammar; - -#pragma warning disable CS8602 // Dereference of a possibly null reference. - -public class NodeParsersTests -{ - [Test] - public async Task Type_ParsesSimpleType() - { - var sut = KdlGrammar.Type; - var input = "(string)"; - - bool success = sut.TryParse(input, out var result); - - await Assert.That(success).IsTrue(); - await Assert.That(result.Value).IsEqualTo("string"); - } - - [Test] - public async Task Prop_ParsesSimpleProperty() - { - var sut = KdlGrammar.Node; - var input = "node key=value"; - - bool success = sut.TryParse(input, out var node); - - await Assert.That(success).IsTrue(); - await Assert.That(node.Entries).Count().IsEqualTo(1); - - var prop = node.Entries[0] as KdlProperty; - await Assert.That(prop).IsNotNull(); - await Assert.That(prop!.Key.Value).IsEqualTo("key"); - await Assert.That(((KdlString)prop.Value).Value).IsEqualTo("value"); - } - - [Test] - public async Task Node_ParsesComplexLine() - { - var sut = KdlGrammar.Node; - var input = "(my-type)node 123 key=\"value\";"; - - bool success = sut.TryParse(input, out var node); - - await Assert.That(success).IsTrue(); - - await Assert.That(node.Name.Value).IsEqualTo("node"); - await Assert.That(node.TypeAnnotation).IsEqualTo("my-type"); - await Assert.That(node.TerminatedBySemicolon).IsTrue(); - - await Assert.That(node.Entries).Count().IsEqualTo(2); - - var arg = node.Entries[0] as KdlArgument; - await Assert.That(arg).IsNotNull(); - await Assert.That(((KdlNumber)arg!.Value).ToInt32()).IsEqualTo(123); - - var prop = node.Entries[1] as KdlProperty; - await Assert.That(prop).IsNotNull(); - await Assert.That(prop!.Key.Value).IsEqualTo("key"); - } - - [Test] - public async Task Node_ParsesChildren() - { - var sut = KdlGrammar.Node; - var input = "parent { child; }"; - - bool success = sut.TryParse(input, out var node, out var error); - - await Assert.That(success).IsTrue(); - await Assert.That(node.Name.Value).IsEqualTo("parent"); - await Assert.That(node.Children).IsNotNull(); - await Assert.That(node.Children!.Nodes).Count().IsEqualTo(1); - await Assert.That(node.Children.Nodes[0].Name.Value).IsEqualTo("child"); - } - - [Test] - public async Task Node_ParsesMixedContent() - { - var sut = KdlGrammar.Node; - var input = "(type)node 10 prop=#true { child; }"; - - bool success = sut.TryParse(input, out var node); - - await Assert.That(success).IsTrue(); - - // Metadata - await Assert.That(node.Name.Value).IsEqualTo("node"); - await Assert.That(node.TypeAnnotation).IsEqualTo("type"); - - // Entries - await Assert.That(node.Entries).Count().IsEqualTo(2); - await Assert - .That(((KdlNumber)((KdlArgument)node.Entries[0]).Value).ToInt32()) - .IsEqualTo(10); - await Assert.That(((KdlBool)((KdlProperty)node.Entries[1]).Value).Value).IsTrue(); - - // Children - await Assert.That(node.Children).IsNotNull(); - await Assert.That(node.Children!.Nodes).Count().IsEqualTo(1); - } - - [Test] - public async Task Node_SlashDash_SkipsNode() - { - // This tests the logic in 'Nodes' (plural) parser usually - var sut = KdlGrammar.Document; - var input = "node1; /- node2; node3;"; - - bool success = sut.TryParse(input, out var doc); - - await Assert.That(success).IsTrue(); - await Assert.That(doc.Nodes).Count().IsEqualTo(2); - await Assert.That(doc.Nodes[0].Name.Value).IsEqualTo("node1"); - await Assert.That(doc.Nodes[1].Name.Value).IsEqualTo("node3"); - } - - [Test] - public async Task Node_SlashDash_SkipsArg() - { - var sut = KdlGrammar.Node; - var input = "node 1 /- 2 3"; - - bool success = sut.TryParse(input, out var node); - - await Assert.That(success).IsTrue(); - await Assert.That(node.Entries).Count().IsEqualTo(2); - - // Entry 0 should be 1 - var arg1 = node.Entries[0] as KdlArgument; - await Assert.That(((KdlNumber)arg1!.Value).ToInt32()).IsEqualTo(1); - - // Entry 1 should be 3 (2 was skipped) - var arg2 = node.Entries[1] as KdlArgument; - await Assert.That(((KdlNumber)arg2!.Value).ToInt32()).IsEqualTo(3); - } - - [Test] - public async Task SlashDash_SkipsProperty() - { - var sut = KdlGrammar.Node; - var input = "node key=1 /- skipped=2 valid=3"; - - bool success = sut.TryParse(input, out var node); - - await Assert.That(success).IsTrue(); - await Assert.That(node.Entries).Count().IsEqualTo(2); - - var p1 = node.Entries[0] as KdlProperty; - await Assert.That(p1!.Key.Value).IsEqualTo("key"); - - var p2 = node.Entries[1] as KdlProperty; - await Assert.That(p2!.Key.Value).IsEqualTo("valid"); - } - - [Test] - public async Task Node_SlashDash_SkipsChildrenBlock() - { - var sut = KdlGrammar.Node; - // Parsing a node that has a slash-dashed children block - var input = "node /- { child; }"; - - bool success = sut.TryParse(input, out var node); - - await Assert.That(success).IsTrue(); - await Assert.That(node.Name.Value).IsEqualTo("node"); - // The children block should be null because it was skipped - await Assert.That(node.Children).IsNull(); - } - - [Test] - public async Task Nodes_ParsesNodesWithWhitespace() - { - var sut = KdlGrammar.Nodes; - var input = - @" - node1; - - node2; - "; - - bool success = sut.TryParse(input, out var nodes); - - await Assert.That(success).IsTrue(); - await Assert.That(nodes).Count().IsEqualTo(2); - } -} -#pragma warning restore CS8602 // Dereference of a possibly null reference. - -``` -// File: src\Kuddle.Net.Tests\Grammar\NumberParsersTests.cs`$langusing Kuddle.Parser; - -namespace Kuddle.Tests.Grammar; - -public class NumberParsersTests -{ - [Test] - public async Task Decimal_ParsesPositiveInteger() - { - var sut = KdlGrammar.Decimal; - - var input = "42"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Decimal_ParsesNegativeInteger() - { - var sut = KdlGrammar.Decimal; - - var input = "-42"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Decimal_ParsesFractionalNumber() - { - var sut = KdlGrammar.Decimal; - - var input = "3.14159"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Decimal_ParsesScientificNotation() - { - var sut = KdlGrammar.Decimal; - - var input = "1.23e-4"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Decimal_ParsesScientificNotationUppercase() - { - var sut = KdlGrammar.Decimal; - - var input = "6.02E23"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Decimal_ParsesWithUnderscoreSeparators() - { - var sut = KdlGrammar.Decimal; - - var input = "1_000_000"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Decimal_ParsesFractionalWithUnderscores() - { - var sut = KdlGrammar.Decimal; - - var input = "12_34.56_78"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Hex_ParsesHexNumbers() - { - var sut = KdlGrammar.Hex; - - var input = "0xFF"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Hex_ParsesHexWithUnderscores() - { - var sut = KdlGrammar.Hex; - - var input = "0x123_ABC"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Hex_ParsesNegativeHex() - { - var sut = KdlGrammar.Hex; - - var input = "-0x42"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Octal_ParsesOctalNumbers() - { - var sut = KdlGrammar.Octal; - - var input = "0o777"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Octal_ParsesOctalWithUnderscores() - { - var sut = KdlGrammar.Octal; - - var input = "0o123_456"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Octal_ParsesNegativeOctal() - { - var sut = KdlGrammar.Octal; - - var input = "-0o42"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Binary_ParsesBinaryNumbers() - { - var sut = KdlGrammar.Binary; - - var input = "0b1010"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Binary_ParsesBinaryWithUnderscores() - { - var sut = KdlGrammar.Binary; - - var input = "0b1111_0000"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Binary_ParsesNegativeBinary() - { - var sut = KdlGrammar.Binary; - - var input = "-0b101"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task KeywordNumber_ParsesInfinity() - { - var sut = KdlGrammar.KeywordNumber; - - var input = "#inf"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task KeywordNumber_ParsesNegativeInfinity() - { - var sut = KdlGrammar.KeywordNumber; - - var input = "#-inf"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task KeywordNumber_ParsesNaN() - { - var sut = KdlGrammar.KeywordNumber; - - var input = "#nan"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Number_ParsesDecimal() - { - var sut = KdlGrammar.Decimal; - - var input = "42"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Number_ParsesHex() - { - var sut = KdlGrammar.Hex; - - var input = "0xFF"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Number_ParsesKeywordNumber() - { - var sut = KdlGrammar.KeywordNumber; - - var input = "#inf"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task Decimal_RejectsDoubleDots() - { - var sut = KdlGrammar.Decimal.Eof(); - - var input = "12.34.56"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsFalse(); - } -} - -``` -// File: src\Kuddle.Net.Tests\Grammar\StringParserTests.cs`$langusing System.Diagnostics; -using Kuddle.AST; -using Kuddle.Parser; - -namespace Kuddle.Tests.Grammar; - -#pragma warning disable CS8602 // Dereference of a possibly null reference. - -public class StringParserTests -{ - [Test] - [Arguments("+positive")] - [Arguments("-negative")] - public async Task SignedIdent_ParsesSignedIdentifier(string input) - { - var sut = KdlGrammar.SignedIdent; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(input); - } - - [Test] - [Arguments(".one")] - [Arguments(".two")] - [Arguments(".three")] - public async Task DottedIdent_ParsesDottedIdentifier(string input) - { - var sut = KdlGrammar.DottedIdent; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(input); - } - - [Test] - [Arguments(".12")] - [Arguments(".01")] - public async Task DottedIdent_DoesNotParseNumberDottedNumber(string input) - { - var sut = KdlGrammar.DottedIdent; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsFalse(); - } - - [Test] - [Arguments("one")] - [Arguments("two")] - [Arguments("three")] - [Arguments("world123")] - [Arguments("test_case")] - public async Task UnambiguousIdent_ParsesUnambiguousIdentifier(string input) - { - var sut = KdlGrammar.UnambiguousIdent; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(input); - } - - [Test] - [Arguments("+positive")] - [Arguments("-negative")] - [Arguments(".one")] - public async Task UnambiguousIdent_DoesNotParseInvalidIdentifier(string input) - { - var sut = KdlGrammar.UnambiguousIdent; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsFalse(); - } - - [Test] - [Arguments("true")] - [Arguments("false")] - [Arguments("null")] - [Arguments("inf")] - [Arguments("-inf")] - [Arguments("nan")] - public async Task UnambiguousIdent_DoesNotParseDisallowedKeywordString(string input) - { - var sut = KdlGrammar.UnambiguousIdent; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsFalse(); - } - - [Test] - [Arguments("one")] - [Arguments("two")] - [Arguments("three")] - [Arguments("world123")] - [Arguments("test_case")] - [Arguments(".one")] - [Arguments(".two")] - [Arguments(".three")] - [Arguments("+positive")] - [Arguments("-negative")] - public async Task IdentifierString_ParsesIdentifierString(string input) - { - var sut = KdlGrammar.IdentifierString; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(input); - } - - [Test] - [Arguments("\"\\ \"")] - public async Task WsEscape_ParsesWhiteSpace(string input) - { - var sut = KdlGrammar.QuotedString; - - bool success = sut.TryParse(input, out var value, out var error); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(""); - } - - [Test] - public async Task QuotedString_ParsesSingleLineString() - { - var sut = KdlGrammar.QuotedString; - - const string input = """ -"hello world" -"""; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo("hello world"); - } - - [Test] - public async Task QuotedString_ParsesEmptyString() - { - var sut = KdlGrammar.QuotedString; - - const string input = """ -"" -"""; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(""); - } - - [Test] - public async Task QuotedString_ParsesEmptyMultilineString() - { - var sut = KdlGrammar.QuotedString; - - const string input = """" -""" -""" -""""; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(""); - } - - [Test] - [Arguments( - """" -""" -i -am -""" -"""", - "i\nam" - )] - [Arguments( - """" -""" -so am - I! -""" -"""", - "so am\n I!" - )] - [Arguments( - """" -""" - foo - This is the base indentation - bar - """ -"""", - " foo\nThis is the base indentation\n bar " - )] - public async Task MultiLineStringBody_HandlesVarious(string input, string expected) - { - var sut = KdlGrammar.MultiLineQuoted; - - bool success = sut.TryParse(input, out var value); - Debug.WriteLine(input); - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(expected); - } - - [Test] - [Arguments( - """" -""" -lorem ipsum -""" -"""", - "lorem ipsum" - )] - [Arguments( - """" -""" -Lorem ipsum -canis canem edit -""" -"""", - "Lorem ipsum\ncanis canem edit" - )] - public async Task MultiLineQuotedString_CanParseMultiLine(string input, string expected) - { - var sut = KdlGrammar.MultiLineQuoted; - bool success = sut.TryParse(input, out var value, out var error); - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(expected); - } - - [Test] - [Arguments( - """ -"\u{1F600}" -""", - "😀" - )] - public async Task QuotedString_HandlesUnicodeEscapes(string input, string expected) - { - var sut = KdlGrammar.QuotedString; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(expected); - } - - [Test] - [Arguments( - """ -"Hello\nWorld" -""" - )] - [Arguments( - """ -"Hello\n\ - World" -""" - )] - public async Task QuotedString_HandlesWhitespaceEscapes(string input) - { - var sut = KdlGrammar.QuotedString; - - bool success = sut.TryParse(input, out var value, out var error); - - var expected = "Hello\nWorld"; - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(expected); - } - - [Test] - public async Task RawString_ParsesSimpleRawString() - { - var sut = KdlGrammar.RawString; - - var input = """ -#"\n will be literal"# -"""; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(@"\n will be literal"); - } - - [Test] - public async Task RawString_ParsesRawStringWithQuotes() - { - var sut = KdlGrammar.RawString; - - var input = "#\"content with \"quotes\"\"#"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo("content with \"quotes\""); - } - - [Test] - public async Task RawString_HandlesMultipleHashDelimiters() - { - var sut = KdlGrammar.RawString; - - var input = """ -##"hello\n\r\asd"#world"## -"""; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert - .That(value.ToString()) - .IsEqualTo( - """ -hello\n\r\asd"#world -""" - ); - } - - [Test] - public async Task RawString_ParsesMultiLineRawString() - { - var sut = KdlGrammar.RawString; - - var input = """"" -#""" - Here's a """ - multiline string - """ - without escapes. - """# -"""""; - bool success = sut.TryParse(input, out var value); - string expected = """" -Here's a """ - multiline string - """ -without escapes. -"""".Replace("\r\n", "\n"); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(expected); - } - - [Test] - public async Task IdentifierString_SetsStyleToBare() - { - var sut = KdlGrammar.IdentifierString; - var input = "bare_identifier"; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Value).IsEqualTo("bare_identifier"); - await Assert.That(value.Kind).IsEqualTo(StringKind.Bare); - } - - [Test] - public async Task QuotedString_SetsStyleToQuoted() - { - var sut = KdlGrammar.QuotedString; - var input = "\"quoted value\""; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Value).IsEqualTo("quoted value"); - // Should be Quoted only - await Assert.That(value.Kind).IsEqualTo(StringKind.Quoted); - } - - [Test] - public async Task MultiLineString_SetsStyleToMultiline() - { - var sut = KdlGrammar.MultiLineQuoted; - var input = """" -""" -content -""" -""""; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Value).IsEqualTo("content"); - // Should be MultiLine only (not Raw) - await Assert.That(value.Kind).IsEqualTo(StringKind.MultiLine); - } - - [Test] - public async Task RawString_SingleLine_SetsStyleToRawAndQuoted() - { - var sut = KdlGrammar.RawString; - var input = "#\"\"raw content\"\"#"; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Value).IsEqualTo("\"raw content\""); - - // Use HasFlag to verify bitwise combination - await Assert.That(value.Kind.HasFlag(StringKind.Raw)).IsTrue(); - await Assert.That(value.Kind.HasFlag(StringKind.Quoted)).IsTrue(); - await Assert.That(value.Kind.HasFlag(StringKind.MultiLine)).IsFalse(); - } - - [Test] - public async Task RawString_MultiLine_SetsStyleToRawAndMultiline() - { - var sut = KdlGrammar.RawString; - var input = """" -#""" -multi -line -"""# -""""; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Value).IsEqualTo("multi\nline"); - - // Should be Raw AND MultiLine - await Assert.That(value.Kind.HasFlag(StringKind.Raw)).IsTrue(); - await Assert.That(value.Kind.HasFlag(StringKind.MultiLine)).IsTrue(); - await Assert.That(value.Kind.HasFlag(StringKind.Quoted)).IsFalse(); - } - - [Test] - public async Task String_UnifiedParser_DetectsBare() - { - var sut = KdlGrammar.String; - var input = "node_name"; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Kind).IsEqualTo(StringKind.Bare); - } - - [Test] - public async Task String_UnifiedParser_DetectsQuoted() - { - var sut = KdlGrammar.String; - var input = "\"node name\""; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Kind).IsEqualTo(StringKind.Quoted); - } - - [Test] - public async Task String_UnifiedParser_DetectsRaw() - { - var sut = KdlGrammar.String; - var input = @"#""node name""#"; - - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Kind.HasFlag(StringKind.Raw)).IsTrue(); - } - - [Test] - public async Task String_ParsesIdentifierString() - { - var sut = KdlGrammar.String; - - var input = "hello"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(input); - } -} -#pragma warning restore CS8602 // Dereference of a possibly null reference. - -``` -// File: src\Kuddle.Net.Tests\Grammar\WhiteSpaceParsersTests.cs`$langusing Kuddle.Parser; - -namespace Kuddle.Tests.Grammar; - -public class WhiteSpaceParsersTests -{ - [Test] - [Arguments('\u0009')] - [Arguments('\u0020')] - [Arguments('\u00A0')] - [Arguments('\u1680')] - [Arguments('\u2000')] - [Arguments('\u2001')] - [Arguments('\u2002')] - [Arguments('\u2003')] - [Arguments('\u2004')] - [Arguments('\u2005')] - [Arguments('\u2006')] - [Arguments('\u2007')] - [Arguments('\u2008')] - [Arguments('\u2009')] - [Arguments('\u200A')] - [Arguments('\u202F')] - [Arguments('\u205F')] - [Arguments('\u3000')] - public async Task Ws_ParsesUnicodeSpace(char input) - { - var sut = KdlGrammar.Ws; - - bool success = sut.TryParse(input.ToString(), out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(input.ToString()); - } - - [Test] - public async Task Ws_ParsesMultiLineComment() - { - var sut = KdlGrammar.Ws; - - var input = "/* comment */"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(input); - } - - [Test] - public async Task EscLine_ParsesBackslashContinuation() - { - var sut = KdlGrammar.EscLine; - - var input = - @"\ -"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - } - - [Test] - public async Task EscLine_ParsesBackslashWithComment() - { - var sut = KdlGrammar.EscLine; - - var input = @"\ // comment"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.ToString()).IsEqualTo(input); - } - - [Test] - public async Task LineSpace_ParsesWhitespace() - { - var sut = KdlGrammar.LineSpace; - - var input = " "; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task LineSpace_ParsesNewLine() - { - var sut = KdlGrammar.LineSpace; - - var input = "\n"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task LineSpace_ParsesComment() - { - var sut = KdlGrammar.LineSpace; - - var input = "// comment"; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } - - [Test] - public async Task NodeSpace_ParsesSimpleWhitespace() - { - var sut = KdlGrammar.NodeSpace; - - var input = " "; - bool success = sut.TryParse(input, out var value); - - await Assert.That(success).IsTrue(); - await Assert.That(value.Span.ToString()).IsEqualTo(input); - } -} - -``` -// File: src\Kuddle.Net.Tests\Serialization\Models\AppSettings.cs`$langusing Kuddle.Serialization; - -namespace Kuddle.Tests.Serialization.Models; - -public class AppSettings -{ - [KdlNodeDictionary("themes")] - public Dictionary Themes { get; set; } = new(); - - [KdlNodeDictionary("layouts")] - public Dictionary Layouts { get; set; } = new(); -} - -public class Theme : Dictionary { } - -public class LayoutDefinition -{ - [KdlProperty("section")] - public string Section { get; set; } = default!; - - [KdlProperty("size")] - public int Ratio { get; set; } = 1; - - [KdlProperty("split")] - public string SplitDirection { get; set; } = "columns"; - - [KdlNode("child")] - public List Children { get; set; } = []; -} - -public class ElementStyle -{ - [KdlNode("border")] - public BorderStyleSettings? BorderStyle { get; set; } - - [KdlNode("header")] - public PanelHeaderSettings? PanelHeader { get; set; } - - [KdlNode("align")] - public AlignmentSettings? Alignment { get; set; } - - [KdlIgnore] - public bool WrapInPanel { get; internal set; } = true; -} - -public class BorderStyleSettings -{ - [KdlProperty("color")] - public string? ForegroundColor { get; set; } - - [KdlProperty("style")] - public string? Decoration { get; set; } -} - -public class PanelHeaderSettings -{ - [KdlProperty("text")] - public string? Text { get; set; } -} - -public class AlignmentSettings -{ - [KdlProperty("v")] - public VerticalAlignment Vertical { get; set; } - - [KdlProperty("h")] - public HorizontalAlignment Horizontal { get; set; } -} - -public enum VerticalAlignment -{ - Top, - Middle, - Bottom, -} - -public enum HorizontalAlignment -{ - Left, - Center, - Right, -} - -``` -// File: src\Kuddle.Net.Tests\Serialization\Models\TelemetrySnapshot.cs`$langusing Kuddle.Serialization; - -namespace Kuddle.Tests.Serialization.Models; - -public class TelemetrySnapshot -{ - // [KdlNode("id")] - public Guid SnapshotId { get; set; } - public DateTimeOffset CapturedAt { get; set; } - - // Dictionary with complex values - // [KdlNodeDictionary("services")] - public Dictionary Services { get; set; } = new(); - - // Dictionary of dictionaries - // [KdlNodeDictionary("tags")] - public Dictionary> GlobalTags { get; set; } = new(); - - // [KdlNode("env")] - public EnvironmentInfo Environment { get; set; } = new(); - - [KdlNodeCollection("events", "event")] - public List Events { get; set; } = new(); - - // Arbitrary metadata bucket - public Dictionary Metadata { get; set; } = new(); -} - -// [KdlType("info")] -public class ServiceInfo -{ - // [KdlNode] - public string Name { get; set; } = string.Empty; - - // [KdlNode] - public ServiceStatus Status { get; set; } - - // [KdlNode] - public VersionInfo Version { get; set; } = new(); - - // Dictionary with primitive values - public Dictionary Metrics { get; set; } = new(); - - // Dictionary with list values - public Dictionary> Dependencies { get; set; } = new(); - - public List Endpoints { get; set; } = new(); -} - -public class VersionInfo -{ - public string VersionString { get; set; } = string.Empty; - public int Major { get; set; } - public int Minor { get; set; } - public int Patch { get; set; } - - public DateTime? BuildDate { get; set; } -} - -public class DependencyInfo -{ - public string DependencyName { get; set; } = string.Empty; - public DependencyType Type { get; set; } - - // Nullable to test optional fields - public TimeSpan? Latency { get; set; } - - public Dictionary Properties { get; set; } = new(); -} - -public class EndpointInfo -{ - public string Route { get; set; } = string.Empty; - public HttpMethod Method { get; set; } - - public bool RequiresAuth { get; set; } - - // Dictionary keyed by status code - public Dictionary ResponseProfiles { get; set; } = new(); -} - -public class ResponseProfile -{ - public int StatusCode { get; set; } - public string Description { get; set; } = string.Empty; - - public Dictionary Headers { get; set; } = new(); - - // Nested complex object - public PayloadSchema? Payload { get; set; } -} - -public class PayloadSchema -{ - public string ContentType { get; set; } = string.Empty; - - // Dictionary representing schema-like data - public Dictionary Fields { get; set; } = new(); -} - -public class FieldDefinition -{ - public string Type { get; set; } = string.Empty; - public bool Required { get; set; } - - // Recursive-ish structure - public Dictionary? SubFields { get; set; } -} - -public class EventRecord -{ - // [KdlProperty] - public Guid EventId { get; set; } - - // [KdlProperty] - public DateTimeOffset Timestamp { get; set; } - - // [KdlProperty] - public EventSeverity Severity { get; set; } - - // [KdlProperty] - public string Message { get; set; } = string.Empty; - - // Heterogeneous dictionary - // [KdlNode] - public Dictionary Context { get; set; } = new(); -} - -[KdlType("env-info")] -public class EnvironmentInfo -{ - // [KdlProperty] - public string Name { get; set; } = string.Empty; - - // [KdlProperty] - public string Region { get; set; } = string.Empty; - - // Dictionary keyed by machine name - // [KdlNode] - public Dictionary Machines { get; set; } = new(); -} - -public class MachineInfo -{ - public string Os { get; set; } = string.Empty; - public int CpuCores { get; set; } - public long MemoryBytes { get; set; } - - public Dictionary Labels { get; set; } = new(); -} - -public enum ServiceStatus -{ - Unknown, - Healthy, - Degraded, - Unavailable, -} - -public enum DependencyType -{ - Database, - HttpService, - MessageQueue, - Cache, -} - -public enum EventSeverity -{ - Trace, - Info, - Warning, - Error, - Critical, -} - -public enum HttpMethod -{ - Get, - Post, - Put, - Delete, - Patch, -} - -``` -// File: src\Kuddle.Net.Tests\Serialization\DocumentToObjectTests.cs`$langusing Kuddle.Exceptions; -using Kuddle.Serialization; - -namespace Kuddle.Tests.Serialization; - -public class DocumentToObjectTests -{ - class AppConfig - { - [KdlNode("plugin")] - public List Plugins { get; set; } = new(); - - [KdlNode("logging")] - public LogSettings? Logging { get; set; } - - [KdlNode("experiments")] - public Experiments? Experiments { get; set; } - } - - class Plugin - { - [KdlArgument(0)] - public string Name { get; set; } = ""; - } - - class LogSettings - { - [KdlNode("level")] - public string LogLevel { get; set; } = "info"; - } - - class Experiments - { - [KdlProperty("enabled")] - public bool Enabled { get; set; } - } - - // --- Tests --- - - [Test] - public async Task Deserialize_Document_MapsListsAndSingleObjects() - { - var kdl = """ - plugin "Analytics" - plugin "Authentication" - - logging { - level "debug" - } - """; - - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Plugins).Count().IsEqualTo(2); - await Assert.That(result.Plugins[0].Name).IsEqualTo("Analytics"); - await Assert.That(result.Plugins[1].Name).IsEqualTo("Authentication"); - - await Assert.That(result.Logging).IsNotNull(); - - await Assert.That(result.Logging!.LogLevel).IsEqualTo("debug"); - } - - [Test] - public async Task Deserialize_EmptyDocument_ReturnsInitializedObject() - { - var kdl = ""; - - var result = KdlSerializer.Deserialize(kdl); - - await Assert.That(result).IsNotNull(); - await Assert.That(result.Plugins).IsEmpty(); - await Assert.That(result.Logging).IsNull(); - } - - [Test] - public async Task Deserialize_PartialMatch_IgnoresUnmappedNodes() - { - var kdl = """ - plugin "Core" - - // This node is not in AppConfig - garbage_data { - ignore me - } - """; - - var result = KdlSerializer.Deserialize(kdl); - - await Assert.That(result.Plugins).Count().IsEqualTo(1); - await Assert.That(result.Plugins[0].Name).IsEqualTo("Core"); - - await Assert.That(result.Logging).IsNull(); - } - - [Test] - public async Task Deserialize_NestedStructure_WithProperties() - { - var kdl = - @" - experiments enabled=#true - "; - - var result = KdlSerializer.Deserialize(kdl); - - await Assert.That(result.Experiments).IsNotNull(); - await Assert.That(result.Experiments!.Enabled).IsTrue(); - } - - [Test] - public async Task Deserialize_AmbiguousSingleNode_ThrowsOrHandles() - { - var kdl = - @" - logging { level ""info"" } - logging { level ""error"" } - "; - - await Assert.ThrowsAsync(async () => - { - KdlSerializer.Deserialize(kdl); - }); - } -} - -``` -// File: src\Kuddle.Net.Tests\Serialization\KdlTypeInfoTests.cs`$langusing Kuddle.Exceptions; -using Kuddle.Serialization; - -namespace Kuddle.Tests.Serialization; - -public class KdlTypeInfoTests -{ - [Test] - public async Task ArgumentContinuity_MissingIndex_Throws() - { - var ex = Assert.Throws(() => - KdlTypeInfo.For() - ); - await Assert.That(ex).IsNotNull(); - } - - [Test] - public async Task PropertyAndNode_Mapping_Works() - { - var info = KdlTypeInfo.For(); - await Assert.That(info.Properties.Count).IsEqualTo(1); - await Assert.That(info.Children.Count).IsEqualTo(1); - } - - [Test] - public async Task NodeDictionary_Property_IsDetected() - { - var info = KdlTypeInfo.For(); - await Assert.That(info.Dictionaries.Count).IsEqualTo(1); - - var dictInfo = KdlTypeInfo.For>(); - await Assert.That(dictInfo.IsDictionary).IsTrue(); - await Assert.That(dictInfo.DictionaryDef).IsNotNull(); - await Assert.That(dictInfo.DictionaryDef!.ValueType).IsEqualTo(typeof(string)); - } - - [Test] - public async Task CollectionDetection_Works_And_Dictionaries_Are_Not_Collections() - { - var arrInfo = KdlTypeInfo.For(); - await Assert.That(arrInfo.CollectionElementType).IsEqualTo(typeof(int)); - await Assert.That(arrInfo.IsIEnumerable).IsTrue(); - - var dictInfo = KdlTypeInfo.For>(); - await Assert.That(dictInfo.IsIEnumerable).IsFalse(); - } - - [Test] - public async Task KdlTypeAttribute_Overrides_NodeName() - { - var info = KdlTypeInfo.For(); - await Assert.That(info.NodeName).IsEqualTo("my-node"); - } - - [Test] - public async Task IsStrictNode_Behaves_Correctly() - { - var plain = KdlTypeInfo.For(); - await Assert.That(plain.IsStrictNode).IsFalse(); - - var withType = KdlTypeInfo.For(); - await Assert.That(withType.IsStrictNode).IsTrue(); - - var withProp = KdlTypeInfo.For(); - await Assert.That(withProp.IsStrictNode).IsTrue(); - } - - [Test] - public async Task For_Is_Cached() - { - var a = KdlTypeInfo.For(); - var b = KdlTypeInfo.For(); - await Assert.That(object.ReferenceEquals(a, b)).IsTrue(); - } - - [Test] - public async Task KdlTypeInfo_Throws_ForEveryAttributeCombination() - { - var typesToTest = new[] - { - typeof(Conflict_Property_Node), - typeof(Conflict_Property_Argument), - typeof(Conflict_Node_NodeDict), - }; - - foreach (var t in typesToTest) - { - var exception = Assert.Throws(() => KdlTypeInfo.For(t)); - await Assert.That(exception).IsNotNull(); - } - } - - // Test types - - private class Conflict_Property_Node - { - [KdlProperty("k")] - [KdlNode("n")] - public string? Prop { get; set; } - } - - private class Conflict_Property_Argument - { - [KdlProperty("k")] - [KdlArgument(0)] - public string? Prop { get; set; } - } - - private class Conflict_Node_NodeDict - { - [KdlNode("n")] - [KdlNodeDictionary("d")] - public string? Prop { get; set; } - } - - private class ArgContinuityBad - { - [KdlArgument(0)] - public string? A { get; set; } - - [KdlArgument(2)] - public string? B { get; set; } - } - - private class PropertyNodeMap - { - [KdlProperty("k")] - public string? Prop { get; set; } - - [KdlNode("n")] - public string? Child { get; set; } - } - - private class NodeDictHolder - { - [KdlNodeDictionary("d")] - public Dictionary? Dict { get; set; } - } - - [KdlType("my-node")] - private class CustomName { } - - private class PlainDocument { } -} - -``` -// File: src\Kuddle.Net.Tests\Serialization\KuddleParsingTests.cs`$langusing Kuddle.Serialization; - -namespace Kuddle.Tests.Serialization; - -public class KuddleParsingTests -{ - readonly KdlWriterOptions _options = new KdlWriterOptions() with { RoundTrip = false }; - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.BlockCommentTestData) - )] - public async Task TestBlockComment(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.EsclineTestData) - )] - public async Task TestEscline(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.MultilineRawStringTestData) - )] - public async Task TestMultilineRawString(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.MultilineStringTestData) - )] - public async Task TestMultilineString(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.ArgTestData) - )] - public async Task TestArg(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); // Assumes KdlDocument has ToString() for serialization - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.BareIdentTestData) - )] - public async Task TestBareIdent(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.BinaryTestData) - )] - public async Task TestBinary(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.BlankTestData) - )] - public async Task TestBlank(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.BooleanTestData) - )] - public async Task TestBoolean(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.BomTestData) - )] - public async Task TestBom(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.CommentTestData) - )] - public async Task TestComment(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.CommentedTestData) - )] - public async Task TestCommented(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.EmptyTestData) - )] - public async Task TestEmpty(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.EscTestData) - )] - public async Task TestEsc(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.FalseTestData) - )] - public async Task TestFalse(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.IllegalTestData) - )] - public async Task TestIllegal(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.BareEmojiTestData) - )] - public async Task TestBareEmoji(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.AllTestData) - )] - public async Task TestAll(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.AsteriskTestData) - )] - public async Task TestAsterisk(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.BracesTestData) - )] - public async Task TestBraces(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.ChevronsTestData) - )] - public async Task TestChevrons(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.CommaTestData) - )] - public async Task TestComma(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.CrlfTestData) - )] - public async Task TestCrlf(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.DashTestData) - )] - public async Task TestDash(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.DotTestData) - )] - public async Task TestDot(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.EmojiTestData) - )] - public async Task TestEmoji(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.EofTestData) - )] - public async Task TestEof(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.ErrTestData) - )] - public async Task TestErr(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.EscapedTestData) - )] - public async Task TestEscaped(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.HexTestData) - )] - public async Task TestHex(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.InitialTestData) - )] - public async Task TestInitial(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.IntTestData) - )] - public async Task TestInt(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } - - [Test] - [MethodDataSource( - typeof(KuddleParsingTestDataSources), - nameof(KuddleParsingTestDataSources.JustTestData) - )] - public async Task TestJust(ParsingTestData testData) - { - if (File.Exists(testData.ExpectedFile)) - { - var inputKdl = await File.ReadAllTextAsync(testData.InputFile); - var doc = KdlReader.Read(inputKdl); - var expected = await File.ReadAllTextAsync(testData.ExpectedFile); - expected = expected.Replace("\r\n", "\n"); - var serialized = doc.ToString(_options); - await Assert.That(serialized).IsEqualTo(expected); - } - else - { - Assert.Throws(() => KdlReader.Read(testData.InputFile)); - } - } -} - -public record ParsingTestData(string InputFile, string ExpectedFile); - -public static class KuddleParsingTestDataSources -{ - public static IEnumerable> ArgTestData() => GetTestData("arg"); - - public static IEnumerable> BlockCommentTestData() => - GetTestData("block_comment"); - - public static IEnumerable> EsclineTestData() => GetTestData("escline_"); - - public static IEnumerable> MultilineRawStringTestData() => - GetTestData("multiline_raw_string"); - - public static IEnumerable> MultilineStringTestData() => - GetTestData("multiline_string"); - - public static IEnumerable> BareIdentTestData() => - GetTestData("bare_ident"); - - public static IEnumerable> BinaryTestData() => GetTestData("binary"); - - public static IEnumerable> BlankTestData() => GetTestData("blank"); - - public static IEnumerable> BooleanTestData() => GetTestData("boolean"); - - public static IEnumerable> BomTestData() => GetTestData("bom"); - - public static IEnumerable> CommentTestData() => GetTestData("comment"); - - public static IEnumerable> CommentedTestData() => - GetTestData("commented"); - - public static IEnumerable> EmptyTestData() => GetTestData("empty"); - - public static IEnumerable> EscTestData() => GetTestData("esc_"); - - public static IEnumerable> FalseTestData() => GetTestData("false"); - - public static IEnumerable> IllegalTestData() => GetTestData("illegal"); - - public static IEnumerable> BareEmojiTestData() => - GetTestData("bare_emoji"); - - public static IEnumerable> AllTestData() => GetTestData("all"); - - public static IEnumerable> AsteriskTestData() => GetTestData("asterisk"); - - public static IEnumerable> BracesTestData() => GetTestData("braces"); - - public static IEnumerable> ChevronsTestData() => GetTestData("chevrons"); - - public static IEnumerable> CommaTestData() => GetTestData("comma"); - - public static IEnumerable> CrlfTestData() => GetTestData("crlf"); - - public static IEnumerable> DashTestData() => GetTestData("dash"); - - public static IEnumerable> DotTestData() => GetTestData("dot"); - - public static IEnumerable> EmojiTestData() => GetTestData("emoji"); - - public static IEnumerable> EofTestData() => GetTestData("eof"); - - public static IEnumerable> ErrTestData() => GetTestData("err"); - - public static IEnumerable> EscapedTestData() => GetTestData("escaped"); - - public static IEnumerable> HexTestData() => GetTestData("hex"); - - public static IEnumerable> InitialTestData() => GetTestData("initial"); - - public static IEnumerable> IntTestData() => GetTestData("int"); - - public static IEnumerable> JustTestData() => GetTestData("just"); - - private static IEnumerable> GetTestData(string prefix) - { - var inputDir = "test_cases/input"; - var expectedDir = "test_cases/expected_kdl"; - var inputFiles = Directory.GetFiles(inputDir, $"{prefix}*.kdl"); - inputFiles = inputFiles.Where(x => !x.EndsWith("_fail.kdl")).ToArray(); - foreach (var inputFile in inputFiles) - { - var fileName = Path.GetFileName(inputFile); - var expectedFile = Path.Combine(expectedDir, fileName); - yield return () => new ParsingTestData(inputFile, expectedFile); - } - } -} - -``` -// File: src\Kuddle.Net.Tests\Serialization\KuddleWriterTests.cs`$langusing Kuddle.AST; -using Kuddle.Parser; -using Kuddle.Serialization; - -namespace Kuddle.Tests.Serialization; - -public class KuddleWriterTests -{ - [Test] - public async Task Write_SimpleNode_FormatsCorrectly() - { - var kdl = "node 1 2 key=\"val\""; - var doc = KdlReader.Read(kdl); - - var output = KdlWriter.Write(doc, KdlWriterOptions.Default); - - await Assert.That(output.Trim()).IsEqualTo("node 1 2 key=\"val\""); - } - - [Test] - public async Task Write_NestedStructure_IndentsCorrectly() - { - var kdl = "parent { child; }"; - var doc = KdlReader.Read(kdl); - - var output = KdlWriter.Write(doc); - - var expected = @"parent { - child; -} -".Replace("\r\n", "\n"); - await Assert.That(output).IsEqualTo(expected); - } - - [Test] - public async Task Write_ComplexString_EscapesCorrectly() - { - var kdl = "node \"line1\\nline2\""; - var doc = KdlReader.Read(kdl); - - var output = KdlWriter.Write(doc); - - await Assert.That(output.Trim()).IsEqualTo("node \"line1\\nline2\""); - } - - [Test] - public async Task Write_BareIdentifier_QuotesIfInvalid() - { - var doc = new KdlDocument - { - Nodes = [new KdlNode(new KdlString("node name", StringKind.Quoted))], - }; - - var output = KdlWriter.Write(doc); - - await Assert.That(output.Trim()).IsEqualTo("\"node name\""); - } -} - -``` -// File: src\Kuddle.Net.Tests\Serialization\NodeToObjectTests.cs`$langusing Kuddle.Exceptions; -using Kuddle.Serialization; - -namespace Kuddle.Tests.Serialization; - -public class NodeToObjectTests -{ - [KdlType("database")] - class DbConfig - { - [KdlArgument(0)] - public string Name { get; set; } = ""; - - [KdlProperty("port")] - public int Port { get; set; } - - [KdlProperty("enabled")] - public bool Enabled { get; set; } = true; - } - - public class Server - { - [KdlArgument(0)] - public string Host { get; set; } = ""; - } - - [Test] - public async Task Deserialize_ExplicitName_MapsArgumentsAndProperties() - { - var kdl = "database production port=5432 enabled=#false"; - - var result = KdlSerializer.Deserialize(kdl); - - await Assert.That(result).IsNotNull(); - await Assert.That(result.Name).IsEqualTo("production"); - await Assert.That(result.Port).IsEqualTo(5432); - await Assert.That(result.Enabled).IsFalse(); - } - - [Test] - public async Task Deserialize_ImplicitName_UsesClassName() - { - var kdl = "Server \"localhost\""; - - var result = KdlSerializer.Deserialize(kdl); - - await Assert.That(result).IsNotNull(); - await Assert.That(result.Host).IsEqualTo("localhost"); - } - - [Test] - public async Task Deserialize_MissingProperty_UsesDefault() - { - var kdl = "database \"local\" port=3000"; - - var result = KdlSerializer.Deserialize(kdl); - - await Assert.That(result.Name).IsEqualTo("local"); - await Assert.That(result.Enabled).IsTrue(); - } - - [Test] - public async Task Deserialize_MismatchNodeName_Throws() - { - var kdl = "table \"production\" port=5432"; - - await Assert.ThrowsAsync(async () => - { - KdlSerializer.Deserialize(kdl); - }); - } - - [Test] - public async Task Deserialize_MultipleRootNodes_Throws() - { - var kdl = - @" - database ""primary"" port=5432 - database ""replica"" port=5433 - "; - - await Assert.ThrowsAsync(async () => - { - KdlSerializer.Deserialize(kdl); - }); - } - - [Test] - public async Task Deserialize_TypeMismatch_Throws() - { - var kdl = "database \"db\" port=\"not-a-number\""; - - await Assert.ThrowsAsync(async () => - { - KdlSerializer.Deserialize(kdl); - }); - } - - [Test] - public async Task Deserialize_DocumentRoot_RejectsProperties() - { - var kdl = "database \"db\" port=5432 unknown_prop=123"; - - var result = KdlSerializer.Deserialize(kdl); - await Assert.That(result.Port).IsEqualTo(5432); - } -} - -``` -// File: src\Kuddle.Net.Tests\Serialization\ObjectMapperTests.cs`$langusing Kuddle.AST; -using Kuddle.Serialization; -using Kuddle.Tests.Serialization.Models; - -namespace Kuddle.Tests.Serialization; - -/// -/// Tests for KdlSerializer.Deserialize() and KdlSerializer.Serialize() -/// which map between KDL documents and strongly-typed C# objects. -/// -public class ObjectMapperTests -{ - #region Basic Mapping Tests - - [Test] - public async Task DeserializeSimpleObject_WithArgument_MapsCorrectly() - { - // Arrange - var kdl = """ - package "my-lib" - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Name).IsEqualTo("my-lib"); - } - - [Test] - public async Task DeserializeSimpleObject_WithProperties_MapsCorrectly() - { - // Arrange - var kdl = """ - package "my-lib" version="1.0.0" description="A cool library" - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Name).IsEqualTo("my-lib"); - await Assert.That(result.Version).IsEqualTo("1.0.0"); - await Assert.That(result.Description).IsEqualTo("A cool library"); - } - - [Test] - public async Task DeserializeObject_WithMissingOptionalProperty_UsesDefault() - { - // Arrange - var kdl = """ - package "my-lib" version="1.0.0" - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Description).IsNull(); - await Assert.That(result.Name).IsEqualTo("my-lib"); - await Assert.That(result.Version).IsEqualTo("1.0.0"); - } - - #endregion - - #region Nested Children Tests - - [Test] - public async Task DeserializeObject_WithChildren_MapsChildListCorrectly() - { - // Arrange - var kdl = """ - project "my-app" version="2.0.0" { - dependency "lodash" version="4.17.21" - dependency "react" version="18.0.0" - } - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Name).IsEqualTo("my-app"); - await Assert.That(result.Version).IsEqualTo("2.0.0"); - await Assert.That(result.Dependencies).Count().IsEqualTo(2); - await Assert.That(result.Dependencies[0].Package).IsEqualTo("lodash"); - await Assert.That(result.Dependencies[1].Package).IsEqualTo("react"); - } - - [Test] - public async Task DeserializeObject_WithMultipleChildTypes_MapsEachTypeToCorrectList() - { - // Arrange - var kdl = """ - project "my-app" { - dependency "lodash" version="4.0" - devDependency "jest" version="27.0" - dependency "react" version="18.0" - devDependency "typescript" version="4.0" - } - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Dependencies).Count().IsEqualTo(2); - await Assert.That(result.DevDependencies).Count().IsEqualTo(2); - await Assert.That(result.Dependencies[0].Package).IsEqualTo("lodash"); - await Assert.That(result.DevDependencies[0].Package).IsEqualTo("jest"); - } - - [Test] - public async Task DeserializeObject_WithNoChildren_InitializesEmptyLists() - { - // Arrange - var kdl = """ - project "standalone" - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Dependencies).IsEmpty(); - await Assert.That(result.DevDependencies).IsEmpty(); - } - - #endregion - - #region Numeric Type Tests - - [Test] - public async Task DeserializeObject_WithIntProperty_MapsCorrectly() - { - // Arrange - var kdl = """ - settings timeout=5000 - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Timeout).IsEqualTo(5000); - } - - [Test] - public async Task DeserializeObject_WithLongProperty_MapsCorrectly() - { - // Arrange - var kdl = """ - settings retries=9223372036854775807 - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Retries).IsEqualTo(long.MaxValue); - } - - [Test] - public async Task DeserializeObject_WithDoubleProperty_MapsCorrectly() - { - // Arrange - var kdl = """ - settings ratio=3.14159 - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Ratio).IsEqualTo(3.14159).Within(0.00001); - } - - [Test] - public async Task DeserializeObject_WithBoolProperty_MapsCorrectly() - { - // Arrange - var kdl = """ - settings enabled=#true - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Enabled).IsTrue(); - } - - [Test] - public async Task DeserializeObject_WithAllNumericTypes_MapsAllCorrectly() - { - // Arrange - var kdl = """ - settings timeout=500 retries=10 ratio=0.95 enabled=#true - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Timeout).IsEqualTo(500); - await Assert.That(result.Retries).IsEqualTo(10); - await Assert.That(result.Ratio).IsEqualTo(0.95).Within(0.0001); - await Assert.That(result.Enabled).IsTrue(); - } - - #endregion - - #region Type Annotation Tests - - [Test] - public async Task DeserializeObject_WithGuidProperty_ParsesUuidTypeAnnotation() - { - // Arrange - var id = Guid.NewGuid(); - var kdl = $""" - user "alice" id=(uuid)"{id:D}" - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Username).IsEqualTo("alice"); - await Assert.That(result.Id).IsEqualTo(id); - } - - [Test] - public async Task DeserializeObject_WithDateTimeProperty_ParsesDateTimeTypeAnnotation() - { - // Arrange - var now = DateTimeOffset.UtcNow; - var kdl = $""" - user "bob" createdAt=(date-time)"{now:O}" - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.CreatedAt).IsEqualTo(now); - } - - #endregion - - #region Serialization Tests - - [Test] - public async Task SerializeObject_WithSimpleProperties_GeneratesValidKdl() - { - // Arrange - var obj = new Package - { - Name = "my-lib", - Version = "1.0.0", - Description = "A library", - }; - - // Act - var kdl = KdlSerializer.Serialize(obj); - - // Assert - await Assert.That(kdl).Contains("package"); - await Assert.That(kdl).Contains("my-lib"); - await Assert.That(kdl).Contains("version"); - await Assert.That(kdl).Contains("1.0.0"); - } - - [Test] - public async Task SerializeObject_WithNestedChildren_GeneratesValidKdl() - { - // Arrange - var obj = new Project - { - Name = "my-app", - Version = "1.0.0", - Dependencies = - [ - new Dependency { Package = "lodash", Version = "4.17" }, - new Dependency { Package = "react", Version = "18.0" }, - ], - }; - - // Act - var kdl = KdlSerializer.Serialize(obj); - - // Assert - await Assert.That(kdl).Contains("project"); - await Assert.That(kdl).Contains("my-app"); - await Assert.That(kdl).Contains("dependency"); - await Assert.That(kdl).Contains("lodash"); - await Assert.That(kdl).Contains("react"); - } - - [Test] - public async Task RoundTrip_SimpleObject_PreservesData() - { - // Arrange - var original = new Package - { - Name = "test-pkg", - Version = "2.5.0", - Description = "Test", - }; - - // Act - var kdl = KdlSerializer.Serialize(original); - var deserialized = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(deserialized.Name).IsEqualTo(original.Name); - await Assert.That(deserialized.Version).IsEqualTo(original.Version); - await Assert.That(deserialized.Description).IsEqualTo(original.Description); - } - - [Test] - public async Task RoundTrip_NestedObject_PreservesData() - { - // Arrange - var original = new Project - { - Name = "my-app", - Version = "1.2.3", - Dependencies = - [ - new Dependency - { - Package = "dep1", - Version = "1.0", - Optional = false, - }, - new Dependency - { - Package = "dep2", - Version = "2.0", - Optional = true, - }, - ], - }; - - // Act - var kdl = KdlSerializer.Serialize(original); - var deserialized = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(deserialized.Name).IsEqualTo(original.Name); - await Assert.That(deserialized.Dependencies).Count().IsEqualTo(2); - await Assert.That(deserialized.Dependencies[0].Package).IsEqualTo("dep1"); - await Assert.That(deserialized.Dependencies[1].Optional).IsTrue(); - } - - #endregion - - #region Edge Cases - - [Test] - public async Task DeserializeObject_WithEmptyString_MapsCorrectly() - { - // Arrange - var kdl = """ - package "" version="" - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Name).IsEqualTo(""); - await Assert.That(result.Version).IsEqualTo(""); - } - - [Test] - public async Task DeserializeObject_WithSpecialCharactersInString_MapsCorrectly() - { - // Arrange - var kdl = """ - package "my-lib@1.0" description="Unicode: 你好世界 🚀" - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Name).IsEqualTo("my-lib@1.0"); - await Assert.That(result.Description).Contains("世界"); - } - - [Test] - public async Task DeserializeObject_WithNegativeNumbers_MapsCorrectly() - { - // Arrange - var kdl = """ - settings timeout=-1000 ratio=-3.14 - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Timeout).IsEqualTo(-1000); - await Assert.That(result.Ratio).IsEqualTo(-3.14).Within(0.0001); - } - - [Test] - public async Task DeserializeObject_WithHexNumbers_MapsCorrectly() - { - // Arrange - var kdl = """ - settings timeout=0xFF retries=0x10 - """; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - await Assert.That(result.Timeout).IsEqualTo(255); - await Assert.That(result.Retries).IsEqualTo(16); - } - - #endregion - - #region Error Handling Tests - - [Test] - public async Task DeserializeObject_WithWrongNodeName_ThrowsException() - { - // Arrange - var kdl = """ - application "wrong-node" - """; - - // Act & Assert - await Assert - .That(async () => KdlSerializer.Deserialize(kdl)) - .Throws(); - } - - [Test] - public async Task DeserializeToDictionary_MapsCorrectly() - { - // Arrange - var kdl = """ -// 1. The "themes" dictionary (Key = Theme Name) -themes { - // Key: "dark-mode" -> Value: Theme (which is also a Dictionary) - dark-mode { - // Key: "window" -> Value: ElementStyle - window { - align v="Top" h="Left" - border color="#FFFFFF" style="solid" - } - - // Key: "button" -> Value: ElementStyle - button { - header text="Click Me" - align v="Middle" h="Center" - } - } - - // Key: "high-contrast" - high-contrast { - window { - border color="#FFFF00" - } - } -} - -// 2. The "layouts" dictionary (Key = Layout Name) -layouts { - // Key: "dashboard" -> Value: LayoutDefinition - dashboard section="main-view" size=1 split="rows" { - - // Recursive List (Children) - child section="top-bar" size=1 - - child section="content-area" size=4 split="columns" { - child section="sidebar" size=1 - child section="grid" size=3 - } - } -} -"""; - - // Act - var result = KdlSerializer.Deserialize(kdl); - - // Assert - var dashboard = result.Layouts["dashboard"]; - await Assert.That(dashboard).IsNotNull(); - await Assert.That(dashboard.Section).IsEqualTo("main-view"); - await Assert.That(dashboard.Ratio).IsEqualTo(1); - await Assert.That(dashboard.SplitDirection).IsEqualTo("rows"); - - var contentArea = dashboard.Children.FirstOrDefault(c => c.Section == "content-area"); - await Assert.That(contentArea!.Ratio).IsEqualTo(4); - } - - [Test] - public async Task DeserializeToEnumerable_WithDifferentNodeNames_ThrowsException() - { - // Arrange - var kdl = """ - package "my-lib" version="1.0.0" - reference "my-dep1" version="2.1.0" - node "my-dep2" version="3.2.1" - """; - - // Act & Assert - await Assert - .That(async () => KdlSerializer.Deserialize>(kdl)) - .Throws(); - } - - [Test] - public async Task DeserializeObject_WithInvalidNumericValue_ThrowsException() - { - // Arrange - var kdl = """ - settings timeout="not-a-number" - """; - - // Act & Assert - await Assert - .That(async () => KdlSerializer.Deserialize(kdl)) - .Throws(); - } - - [Test] - public async Task DeserializeObject_WithMissingRequiredArgument_ThrowsException() - { - // Arrange - var kdl = """ - package - """; - - // Act & Assert - await Assert - .That(async () => KdlSerializer.Deserialize(kdl)) - .Throws(); - } - - #endregion - - #region Test Models - - /// Simple class with properties and arguments. - public class Package - { - [KdlArgument(0)] - public string Name { get; set; } = string.Empty; - - [KdlProperty("version")] - public string? Version { get; set; } - - [KdlProperty("description")] - public string? Description { get; set; } - } - - /// Class with nested children (list of child nodes). - public class Project - { - [KdlArgument(0)] - public string Name { get; set; } = string.Empty; - - [KdlProperty("version")] - public string Version { get; set; } = "1.0.0"; - - [KdlNode("dependency")] - public List Dependencies { get; set; } = []; - - [KdlNode("devDependency")] - public List DevDependencies { get; set; } = []; - } - - public class Dependency - { - [KdlArgument(0)] - public string Package { get; set; } = string.Empty; - - [KdlProperty("version")] - public string Version { get; set; } = "*"; - - [KdlProperty("optional")] - public bool Optional { get; set; } - } - - /// Class with numeric properties. - public class Settings - { - [KdlProperty("timeout")] - public int Timeout { get; set; } - - [KdlProperty("retries")] - public long Retries { get; set; } - - [KdlProperty("ratio")] - public double Ratio { get; set; } - - [KdlProperty("enabled")] - public bool Enabled { get; set; } - } - - /// Class with type annotations (uuid, date-time). - public class User - { - [KdlArgument(0)] - public string Username { get; set; } = string.Empty; - - [KdlProperty("id")] - public Guid Id { get; set; } - - [KdlProperty("createdAt")] - public DateTimeOffset CreatedAt { get; set; } - } - - /// Class demonstrating polymorphism with type discriminators. - public class Resource - { - [KdlArgument(0)] - public string Name { get; set; } = string.Empty; - - [KdlProperty("type")] - public string Type { get; set; } = string.Empty; // Used as discriminator - } - - public class FileResource : Resource - { - [KdlProperty("path")] - public string Path { get; set; } = string.Empty; - } - - public class UrlResource : Resource - { - [KdlProperty("url")] - public string Url { get; set; } = string.Empty; - } - - #endregion -} - -``` -// File: src\Kuddle.Net.Tests\Serialization\TelemetrySnapshotTests.cs`$langusing System.Diagnostics; -using Kuddle.Serialization; -using Kuddle.Tests.Serialization.Models; - -namespace Kuddle.Tests.Serialization; - -public class TelemetrySnapshotTests -{ - [Test] - public async Task RoundTrip_TelemetrySnapshot_SerializesAndDeserializes() - { - var original = new TelemetrySnapshot - { - SnapshotId = Guid.NewGuid(), - CapturedAt = DateTimeOffset.UtcNow, - Services = - { - ["svc1"] = new ServiceInfo - { - Name = "Inventory", - Status = ServiceStatus.Healthy, - Version = new VersionInfo - { - VersionString = "1.2.3", - Major = 1, - Minor = 2, - Patch = 3, - }, - Metrics = { ["cpu"] = 0.75 }, - Dependencies = - { - ["dep-type"] = new List - { - new() { DependencyName = "db", Type = DependencyType.Database }, - }, - }, - Endpoints = new List - { - new() - { - Route = "/items", - Method = Models.HttpMethod.Get, - RequiresAuth = true, - }, - }, - }, - }, - GlobalTags = - { - ["global"] = new Dictionary - { - ["region"] = "uk-south", - ["timezone"] = "gmt", - }, - }, - Environment = new EnvironmentInfo - { - Name = "prod", - Region = "uk-west", - Machines = - { - ["host1"] = new MachineInfo - { - Os = "linux", - CpuCores = 4, - MemoryBytes = 8L * 1024 * 1024 * 1024, - }, - }, - }, - Events = new List - { - new EventRecord - { - EventId = Guid.NewGuid(), - Timestamp = DateTimeOffset.UtcNow, - Severity = EventSeverity.Info, - Message = "Started", - }, - new EventRecord - { - EventId = Guid.NewGuid(), - Timestamp = DateTimeOffset.UtcNow.AddSeconds(10), - Severity = EventSeverity.Warning, - Message = "Slow start", - }, - }, - }; - - var kdl = KdlSerializer.Serialize(original); - - Debug.WriteLine(kdl); - - var deserialized = KdlSerializer.Deserialize(kdl); - - await Assert.That(deserialized).IsNotNull(); - await Assert.That(deserialized.SnapshotId).IsEqualTo(original.SnapshotId); - await Assert.That(deserialized.CapturedAt).IsEqualTo(original.CapturedAt); - await Assert.That(deserialized.Services).ContainsKey("svc1"); - await Assert.That(deserialized.Services["svc1"].Name).IsEqualTo("Inventory"); - await Assert.That(deserialized.Services["svc1"].Metrics["cpu"]).IsEqualTo(0.75); - await Assert.That(deserialized.GlobalTags["global"]["region"]).IsEqualTo("uk-south"); - await Assert.That(deserialized.GlobalTags["global"]["timezone"]).IsEqualTo("gmt"); - await Assert.That(deserialized.Environment.Name).IsEqualTo("prod"); - await Assert.That(deserialized.Environment.Region).IsEqualTo("uk-west"); - await Assert.That(deserialized.Environment.Machines).ContainsKey("host1"); - await Assert.That(deserialized.Events).Count().IsEqualTo(2); - } -} - -``` -// File: src\Kuddle.Net.Tests\Serialization\TypeAnnotationTests.cs`$lang// using System; -// using Kuddle.Serialization; - -// namespace Kuddle.Tests.Serialization; - -// public class TypeAnnotationTests -// { -// #region Test Models - -// public class PersonWithAnnotations -// { -// [KdlArgument(0, "uuid")] -// public Guid Id { get; set; } - -// [KdlProperty("name")] -// public string Name { get; set; } = ""; - -// [KdlProperty("created", "date-time")] -// public DateTimeOffset CreatedAt { get; set; } - -// [KdlProperty("age", "i32")] -// public int Age { get; set; } -// } - -// public class ProductWithTypeAnnotations -// { -// [KdlArgument(0)] -// public string Name { get; set; } = ""; - -// [KdlProperty("sku", "uuid")] -// public Guid Sku { get; set; } - -// [KdlProperty("price", "decimal64")] -// public decimal Price { get; set; } - -// [KdlProperty("stock", "u32")] -// public uint StockCount { get; set; } -// } - -// public class EventWithChildAnnotations -// { -// [KdlArgument(0)] -// public string Name { get; set; } = ""; - -// [KdlNode("timestamp", "date-time")] -// public DateTimeOffset Timestamp { get; set; } - -// [KdlNode("correlation-id", "uuid")] -// public Guid CorrelationId { get; set; } -// } - -// #endregion - -// [Test] -// public async Task Serialize_WithUuidTypeAnnotation_WritesAnnotation() -// { -// // Arrange -// var person = new PersonWithAnnotations -// { -// Id = Guid.Parse("123e4567-e89b-12d3-a456-426614174000"), -// Name = "Alice", -// CreatedAt = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), -// Age = 30, -// }; - -// // Act -// var kdl = KdlSerializer.Serialize(person); - -// // Assert -// await Assert.That(kdl).Contains("(uuid)\"123e4567-e89b-12d3-a456-426614174000\""); -// await Assert.That(kdl).Contains("(date-time)\"2024-01-01T00:00:00.0000000+00:00\""); -// await Assert.That(kdl).Contains("(i32)30"); -// } - -// [Test] -// public async Task Deserialize_WithUuidTypeAnnotation_ParsesCorrectly() -// { -// // Arrange -// var kdl = """ -// personwithannotations (uuid)"123e4567-e89b-12d3-a456-426614174000" name="Alice" created=(date-time)"2024-01-01T00:00:00.0000000+00:00" age=(i32)30 -// """; - -// // Act -// var person = KdlSerializer.Deserialize(kdl); - -// // Assert -// await Assert.That(person.Id).IsEqualTo(Guid.Parse("123e4567-e89b-12d3-a456-426614174000")); -// await Assert.That(person.Name).IsEqualTo("Alice"); -// await Assert -// .That(person.CreatedAt) -// .IsEqualTo(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); -// await Assert.That(person.Age).IsEqualTo(30); -// } - -// [Test] -// public async Task Serialize_WithMixedTypeAnnotations_WritesAllAnnotations() -// { -// // Arrange -// var product = new ProductWithTypeAnnotations -// { -// Name = "Widget", -// Sku = Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), -// Price = 99.99m, -// StockCount = 42, -// }; - -// // Act -// var kdl = KdlSerializer.Serialize(product); - -// // Assert -// await Assert.That(kdl).Contains("Widget"); -// await Assert.That(kdl).Contains("(uuid)\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\""); -// await Assert.That(kdl).Contains("(decimal64)99.99"); -// await Assert.That(kdl).Contains("(u32)42"); -// } - -// [Test] -// public async Task Deserialize_WithMixedTypeAnnotations_ParsesAllValues() -// { -// // Arrange -// var kdl = """ -// productwithtypeannotations "Widget" sku=(uuid)"a1b2c3d4-e5f6-7890-abcd-ef1234567890" price=(decimal64)99.99 stock=(u32)42 -// """; - -// // Act -// var product = KdlSerializer.Deserialize(kdl); - -// // Assert -// await Assert.That(product.Name).IsEqualTo("Widget"); -// await Assert -// .That(product.Sku) -// .IsEqualTo(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); -// await Assert.That(product.Price).IsEqualTo(99.99m); -// await Assert.That(product.StockCount).IsEqualTo(42u); -// } - -// [Test] -// public async Task Serialize_WithChildNodeAnnotations_WritesAnnotationsOnChildren() -// { -// // Arrange -// var evt = new EventWithChildAnnotations -// { -// Name = "UserLoggedIn", -// Timestamp = new DateTimeOffset(2024, 12, 17, 10, 30, 0, TimeSpan.Zero), -// CorrelationId = Guid.Parse("11111111-2222-3333-4444-555555555555"), -// }; - -// // Act -// var kdl = KdlSerializer.Serialize(evt); - -// // Assert -// await Assert -// .That(kdl) -// .Contains("timestamp (date-time)\"2024-12-17T10:30:00.0000000+00:00\""); -// await Assert -// .That(kdl) -// .Contains("correlation-id (uuid)\"11111111-2222-3333-4444-555555555555\""); -// } - -// [Test] -// public async Task Deserialize_WithChildNodeAnnotations_ParsesChildValues() -// { -// // Arrange -// var kdl = """ -// eventwithchildannotations "UserLoggedIn" { -// timestamp (date-time)"2024-12-17T10:30:00.0000000+00:00" -// correlation-id (uuid)"11111111-2222-3333-4444-555555555555" -// } -// """; - -// // Act -// var evt = KdlSerializer.Deserialize(kdl); - -// // Assert -// await Assert.That(evt.Name).IsEqualTo("UserLoggedIn"); -// await Assert -// .That(evt.Timestamp) -// .IsEqualTo(new DateTimeOffset(2024, 12, 17, 10, 30, 0, TimeSpan.Zero)); -// await Assert -// .That(evt.CorrelationId) -// .IsEqualTo(Guid.Parse("11111111-2222-3333-4444-555555555555")); -// } - -// [Test] -// public async Task RoundTrip_WithTypeAnnotations_PreservesValues() -// { -// // Arrange -// var original = new PersonWithAnnotations -// { -// Id = Guid.NewGuid(), -// Name = "Bob", -// CreatedAt = DateTimeOffset.UtcNow, -// Age = 25, -// }; - -// // Act -// var kdl = KdlSerializer.Serialize(original); -// var deserialized = KdlSerializer.Deserialize(kdl); - -// // Assert -// await Assert.That(deserialized.Id).IsEqualTo(original.Id); -// await Assert.That(deserialized.Name).IsEqualTo(original.Name); -// await Assert.That(deserialized.CreatedAt).IsEqualTo(original.CreatedAt); -// await Assert.That(deserialized.Age).IsEqualTo(original.Age); -// } - -// [Test] -// public async Task Deserialize_WithoutTypeAnnotationInKdl_StillWorks() -// { -// // Arrange - KDL without type annotation, but C# model has [KdlTypeAnnotation] -// // The deserializer should still work because Guid/DateTimeOffset have their own parsing logic -// var kdl = """ -// personwithannotations "123e4567-e89b-12d3-a456-426614174000" name="Alice" created="2024-01-01T00:00:00.0000000+00:00" age=30 -// """; - -// // Act -// var person = KdlSerializer.Deserialize(kdl); - -// // Assert - should parse without type annotations in the KDL -// await Assert.That(person.Id).IsEqualTo(Guid.Parse("123e4567-e89b-12d3-a456-426614174000")); -// await Assert.That(person.Name).IsEqualTo("Alice"); -// await Assert -// .That(person.CreatedAt) -// .IsEqualTo(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); -// await Assert.That(person.Age).IsEqualTo(30); -// } -// } - -``` -// File: src\Kuddle.Net.Tests\Validation\ReservedTypeValidatorTests.cs`$langusing Kuddle.AST; -using Kuddle.Exceptions; -using Kuddle.Serialization; -using Kuddle.Validation; - -namespace Kuddle.Tests.Validation; - -#pragma warning disable CS8602 // Dereference of a possibly null reference. - -public class ReservedTypeValidatorTests -{ - [Test] - public async Task Given_ValidU8_When_Validated_Then_NoExceptionThrown() - { - // Arrange - var doc = Parse("node 255"); - - // Act & Assert - await Assert.That(() => KdlReservedTypeValidator.Validate(doc)).ThrowsNothing(); - } - - [Test] - public async Task Given_OverflowingU8_When_Validated_Then_ThrowsValidationException() - { - // Arrange - var doc = Parse("node (u8)256"); - - // Act - var exception = await Assert - .That(() => KdlReservedTypeValidator.Validate(doc)) - .Throws(); - - // Assert - await Assert.That(exception.Errors).IsNotEmpty(); - await Assert.That(exception.Errors.First().Message).Contains("not a valid 'u8'"); - } - - [Test] - public async Task Given_StringForIntType_When_Validated_Then_ThrowsMismatchError() - { - // Arrange - var doc = Parse("node (u8)\"not a number\""); - - // Act - var exception = Assert.Throws(() => - KdlReservedTypeValidator.Validate(doc) - ); - - // Assert - await Assert.That(exception.Errors.First().Message).Contains("Expected a Number"); - } - - [Test] - public async Task Given_InvalidUuid_When_Validated_Then_ThrowsError() - { - // Arrange - var doc = Parse("node (uuid)\"im-not-a-uuid\""); - - // Act - var exception = Assert.Throws(() => - KdlReservedTypeValidator.Validate(doc) - ); - - // Assert - await Assert.That(exception.Errors.First().Message).Contains("not a valid 'uuid'"); - } - - [Test] - public async Task Given_NodeNameWithReservedType_When_Validated_Then_IgnoresIt() - { - // Arrange - var doc = Parse("node (u8)123"); - - // Act & Assert - await Assert.That(() => KdlReservedTypeValidator.Validate(doc)).ThrowsNothing(); - } - - private static KdlDocument Parse(string input) - { - return KdlReader.Read( - input, - KdlReaderOptions.Default with - { - ValidateReservedTypes = false, - } - ); - } -} -#pragma warning restore CS8602 // Dereference of a possibly null reference. - -``` diff --git a/.github/codebase.ps1 b/.github/codebase.ps1 index ee3f948..5ec40a3 100644 --- a/.github/codebase.ps1 +++ b/.github/codebase.ps1 @@ -20,7 +20,9 @@ $outputPath = Join-Path $outputDir 'codebase.txt' Write-Host "Output path: $outputPath" # Build directory tree -$directoryTree = Get-ChildItem -Directory -Path $sourceDirectory -Recurse -Exclude obj, bin | ForEach-Object { +$directoryTree = Get-ChildItem -Directory -Path $sourceDirectory -Recurse -Exclude obj, bin | Where-Object { + (-not $_.FullName.Contains('Tests')) +} | ForEach-Object { $indent = ' ' * ($_.FullName.Split('\').Length - $sourceDirectory.Split('\').Length) "$indent- $($_.Name)" } | Out-String @@ -43,7 +45,9 @@ $languageMap = @{ } # Grab all files except bin/obj -$allFiles = Get-ChildItem -Path $sourceDirectory -Recurse -File -Include *.cs, *.ps1, *.json, *.xml, *.yml, *.yaml, *.md, *.sh, *.ts, *.js +$allFiles = Get-ChildItem -Path $sourceDirectory -Recurse -File -Include *.cs, *.ps1, *.json, *.xml, *.yml, *.yaml, *.md, *.sh, *.ts, *.js | Where-Object { + -not $_.DirectoryName.ToLower().Contains('tests') +} foreach ($file in $allFiles) { $relativePath = $file.FullName.Substring($PWD.Path.Length + 1) @@ -64,4 +68,4 @@ foreach ($file in $allFiles) { $fileContent = Get-Content -Path $file.FullName | Out-String $formattedContent = $filePathHeader + $codeBlockStart + $fileContent + $codeBlockEnd Add-Content -Path $outputPath -Value $formattedContent -} \ No newline at end of file +} diff --git a/src/Kuddle.Net.Tests/Serialization/DocumentToObjectTests.cs b/src/Kuddle.Net.Tests/Serialization/DocumentToObjectTests.cs index b2555d2..cd77ece 100644 --- a/src/Kuddle.Net.Tests/Serialization/DocumentToObjectTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/DocumentToObjectTests.cs @@ -115,10 +115,8 @@ public async Task Deserialize_AmbiguousSingleNode_ThrowsOrHandles() logging { level ""info"" } logging { level ""error"" } "; + var config = KdlSerializer.Deserialize(kdl); - await Assert.ThrowsAsync(async () => - { - KdlSerializer.Deserialize(kdl); - }); + await Assert.That(config.Logging!.LogLevel).IsEqualTo("error"); } } diff --git a/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs index 51f1bf9..3c54465 100644 --- a/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs +++ b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs @@ -19,7 +19,6 @@ public class TelemetrySnapshot // [KdlNode("env")] public EnvironmentInfo Environment { get; set; } = new(); - [KdlNodeCollection("events", "event")] public List Events { get; set; } = new(); // Arbitrary metadata bucket diff --git a/src/Kuddle.Net.Tests/Serialization/NodeToObjectTests.cs b/src/Kuddle.Net.Tests/Serialization/NodeToObjectTests.cs index 62933b1..65a1747 100644 --- a/src/Kuddle.Net.Tests/Serialization/NodeToObjectTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/NodeToObjectTests.cs @@ -60,10 +60,12 @@ public async Task Deserialize_MissingProperty_UsesDefault() } [Test] - public async Task Deserialize_MismatchNodeName_Throws() + public async Task Deserialize_MismatchNodeName_ReturnsNull() { var kdl = "table \"production\" port=5432"; + // var result = KdlSerializer.Deserialize(kdl); + // await Assert.That(result).IsNull(); await Assert.ThrowsAsync(async () => { KdlSerializer.Deserialize(kdl); @@ -78,7 +80,6 @@ public async Task Deserialize_MultipleRootNodes_Throws() database ""primary"" port=5432 database ""replica"" port=5433 "; - await Assert.ThrowsAsync(async () => { KdlSerializer.Deserialize(kdl); diff --git a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs index dea33db..81c1539 100644 --- a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs @@ -495,8 +495,8 @@ public async Task DeserializeToDictionary_MapsCorrectly() await Assert.That(dashboard.Ratio).IsEqualTo(1); await Assert.That(dashboard.SplitDirection).IsEqualTo("rows"); - var contentArea = dashboard.Children.FirstOrDefault(c => c.Section == "content-area"); - await Assert.That(contentArea!.Ratio).IsEqualTo(4); + var contentArea = dashboard.Children.FirstOrDefault(c => c.Section == "grid"); + await Assert.That(contentArea!.Ratio).IsEqualTo(3); } [Test] @@ -529,19 +529,19 @@ await Assert .Throws(); } - [Test] - public async Task DeserializeObject_WithMissingRequiredArgument_ThrowsException() - { - // Arrange - var kdl = """ - package - """; + // [Test] + // public async Task DeserializeObject_WithMissingRequiredArgument_ThrowsException() + // { + // // Arrange + // var kdl = """ + // package + // """; - // Act & Assert - await Assert - .That(async () => KdlSerializer.Deserialize(kdl)) - .Throws(); - } + // // Act & Assert + // await Assert + // .That(async () => KdlSerializer.Deserialize(kdl)) + // .Throws(); + // } #endregion diff --git a/src/Kuddle.Net/Extensions/TypeExtensions.cs b/src/Kuddle.Net/Extensions/TypeExtensions.cs index dcdc6ce..fee36f1 100644 --- a/src/Kuddle.Net/Extensions/TypeExtensions.cs +++ b/src/Kuddle.Net/Extensions/TypeExtensions.cs @@ -32,7 +32,10 @@ internal static class TypeExtensions || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(DateTimeOffset) - || type == typeof(Guid); + || type == typeof(Guid) + || type == typeof(TimeSpan) + || type == typeof(DateOnly) + || type == typeof(TimeOnly); public Type? GetCollectionElementType() { diff --git a/src/Kuddle.Net/Serialization/KdlMemberInfo.cs b/src/Kuddle.Net/Serialization/KdlMemberInfo.cs deleted file mode 100644 index 7a4fc04..0000000 --- a/src/Kuddle.Net/Serialization/KdlMemberInfo.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Reflection; -using System.Security.Cryptography.X509Certificates; - -namespace Kuddle.Serialization; - -/// -/// Represents a mapping from a C# property to a KDL entry (argument, property, or child node). -/// -internal sealed record KdlMemberInfo(PropertyInfo Property, Attribute? Attribute) -{ - public bool IsArgument => Attribute is KdlArgumentAttribute; - public bool IsProperty => Attribute is KdlPropertyAttribute; - public bool IsNode => Attribute is KdlNodeAttribute; - public bool IsWrappedCollection => Attribute is KdlNodeCollectionAttribute; - public string? CollectionElementName => (Attribute as KdlNodeCollectionAttribute)?.ElementName; - public bool IsNodeDictionary => Attribute is KdlNodeDictionaryAttribute; - - //TODO: Add support for property dictionaries - public bool IsPropertyDictionary => false; - - //TODO: Add support for keyed node collections - public bool IsKeyedNodeCollection => false; - - public int ArgumentIndex => Attribute is KdlArgumentAttribute arg ? arg.Index : -1; - - public string Name => - Attribute switch - { - KdlPropertyAttribute p => p.Key ?? Property.Name.ToLowerInvariant(), - KdlNodeAttribute n => n.Name ?? Property.Name.ToLowerInvariant(), - KdlNodeDictionaryAttribute nd => nd.Name ?? Property.Name.ToLowerInvariant(), - KdlNodeCollectionAttribute nc => nc.NodeName ?? Property.Name.ToLowerInvariant(), - _ => Property.Name.ToLowerInvariant(), - }; - - public string? TypeAnnotation => null; -} - -internal enum KdlMemberKind -{ - Argument, - Property, - ChildNode, - TypeAnnotation, -} diff --git a/src/Kuddle.Net/Serialization/KdlMemberKind.cs b/src/Kuddle.Net/Serialization/KdlMemberKind.cs new file mode 100644 index 0000000..ea92ad2 --- /dev/null +++ b/src/Kuddle.Net/Serialization/KdlMemberKind.cs @@ -0,0 +1,9 @@ +namespace Kuddle.Serialization; + +internal enum KdlMemberKind +{ + Argument, + Property, + ChildNode, + TypeAnnotation, +} diff --git a/src/Kuddle.Net/Serialization/KdlMemberMap.cs b/src/Kuddle.Net/Serialization/KdlMemberMap.cs index 854a2b8..9652f61 100644 --- a/src/Kuddle.Net/Serialization/KdlMemberMap.cs +++ b/src/Kuddle.Net/Serialization/KdlMemberMap.cs @@ -19,13 +19,14 @@ public KdlMemberMap( ArgumentIndex = argumentIndex; TypeAnnotation = typeAnnotation; IsDictionary = property.PropertyType.IsDictionary; - ElementType = property.PropertyType.GetCollectionElementType(); - IsCollection = ElementType != null; - if (IsDictionary && ElementType != null) + var elementType = property.PropertyType.GetCollectionElementType(); + IsCollection = elementType != null; + if (IsDictionary && elementType != null) { - DictionaryKeyProperty = ElementType.GetProperty("Key"); - DictionaryValueProperty = ElementType.GetProperty("Value"); + DictionaryKeyProperty = elementType.GetProperty("Key"); + DictionaryValueProperty = elementType.GetProperty("Value"); } + ElementType = elementType; } public PropertyInfo Property { get; } @@ -36,7 +37,6 @@ public KdlMemberMap( public bool IsCollection { get; } public bool IsDictionary { get; } public string? TypeAnnotation { get; } - public PropertyInfo? DictionaryKeyProperty { get; } public PropertyInfo? DictionaryValueProperty { get; } diff --git a/src/Kuddle.Net/Serialization/KdlTypeInfo.cs b/src/Kuddle.Net/Serialization/KdlTypeInfo.cs deleted file mode 100644 index e69de29..0000000 diff --git a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs index f6fb060..318ba7b 100644 --- a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs +++ b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs @@ -49,9 +49,17 @@ private KdlTypeMapping(Type type) } } ValidateMapping(); - HasMembers = Arguments.Count + Properties.Count + Children.Count > 1; } + public Type Type { get; init; } + public string NodeName { get; init; } + public bool IsDictionary { get; } + public List Properties { get; } = []; + public List Children { get; } = []; + internal List Arguments { get; } = []; + public PropertyInfo? DictionaryKeyProperty { get; } + public PropertyInfo? DictionaryValueProperty { get; } + private void ValidateMapping() { // Sort arguments and check for continuity @@ -77,8 +85,6 @@ private KdlMemberMap CreateMemberMap(PropertyInfo prop) } var typeAnnotation = attr.TypeAnnotation; - // -- Explicit Mapping via Attributes -- - return attr switch { KdlArgumentAttribute arg => new KdlMemberMap( @@ -137,19 +143,6 @@ private static KdlEntryAttribute InferAttribute(PropertyInfo prop) => _ => new KdlNodeAttribute(prop.Name.ToKebabCase()), }; - public Type Type { get; init; } - public string NodeName { get; init; } - public bool IsDictionary { get; } - public List Properties { get; } = []; - public List Children { get; } = []; - - internal List Arguments { get; } = []; - public Type? KeyType { get; } - public Type? ValueType { get; } - public PropertyInfo? DictionaryKeyProperty { get; } - public PropertyInfo? DictionaryValueProperty { get; } - public bool HasMembers { get; } - /// /// Gets or creates cached metadata for a type. /// diff --git a/src/Kuddle.Net/Serialization/KdlValueConverter.cs b/src/Kuddle.Net/Serialization/KdlValueConverter.cs index 1aede5c..8654a3a 100644 --- a/src/Kuddle.Net/Serialization/KdlValueConverter.cs +++ b/src/Kuddle.Net/Serialization/KdlValueConverter.cs @@ -205,26 +205,6 @@ public static object FromKdlOrThrow( return result ?? throw new Exception(); } - /// - /// Converts a CLR value to a KDL value, throwing on failure. - /// - [Obsolete] - public static KdlValue ToKdlOrThrow( - object? input, - string context, - string? typeAnnotation = null - ) - { - if (!TryToKdl(input, out var kdlValue, typeAnnotation)) - { - var typeName = input?.GetType().Name ?? "null"; - throw new KuddleSerializationException( - $"Cannot convert CLR value of type '{typeName}' to KDL. {context}" - ); - } - return kdlValue; - } - /// /// Converts a CLR value to a KDL value, throwing on failure. /// diff --git a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs index aaa40ec..c4690ba 100644 --- a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs @@ -30,18 +30,33 @@ internal static T DeserializeDocument(KdlDocument doc, KdlSerializerOptions? if (mapping.Arguments.Count > 0 || mapping.Properties.Count > 0) { - if (doc.Nodes.Count == 0) - return instance; - - var rootNode = - doc.Nodes.FirstOrDefault(n => - n.Name.Value.Equals(mapping.NodeName, NodeNameComparison) - ) - ?? throw new KuddleSerializationException( - $"Root node '{mapping.NodeName}' not found in document." + var matches = doc + .Nodes.Where(n => n.Name.Value.Equals(mapping.NodeName, NodeNameComparison)) + .ToList(); + + if (matches.Count == 0) + { + // If the document has content, but none of it is the node we want, it's an error. + if (doc.Nodes.Count > 0) + { + var foundNames = string.Join(", ", doc.Nodes.Select(n => $"'{n.Name.Value}'")); + throw new KuddleSerializationException( + $"Expected root node '{mapping.NodeName}', but found: {foundNames}." + ); + } + return instance; // Document is totally empty; return empty object + } + + // THROW: Ambiguity check + if (matches.Count > 1) + { + throw new KuddleSerializationException( + $"Found {matches.Count} nodes matching '{mapping.NodeName}', but only 1 was expected. " + + "To deserialize a list of nodes, use KdlSerializer.DeserializeMany()." ); + } - worker.MapNodeToObject(rootNode, instance, mapping); + worker.MapNodeToObject(matches[0], instance, mapping); } else { @@ -172,8 +187,27 @@ private void MapChildren(List? nodes, object instance, KdlTypeMapping m } else { - var childInstance = DeserializeObject(matches.Last(), map.Property.PropertyType); - map.SetValue(instance, childInstance); + var last = matches.Last(); + object? value; + + if (map.Property.PropertyType.IsKdlScalar) // Use your extension + { + var arg = last.Arg(0); + value = + arg != null + ? KdlValueConverter.FromKdlOrThrow( + arg, + map.Property.PropertyType, + last.Name.Value + ) + : null; + } + else + { + value = DeserializeObject(last, map.Property.PropertyType); + } + + map.SetValue(instance, value); } } } @@ -250,6 +284,7 @@ Type valueType private object DeserializeObject(KdlNode node, Type type) { + if (type.IsKdlScalar) { } var instance = Activator.CreateInstance(type) ?? throw new KuddleSerializationException( diff --git a/src/Kuddle.Net/Serialization/ObjectSerializer.cs b/src/Kuddle.Net/Serialization/ObjectSerializer.cs index 2aac5ac..35be36d 100644 --- a/src/Kuddle.Net/Serialization/ObjectSerializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectSerializer.cs @@ -1,10 +1,8 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Reflection; -using System.Threading.Tasks.Dataflow; using Kuddle.AST; namespace Kuddle.Serialization; @@ -51,90 +49,69 @@ internal static KdlDocument SerializeDocument(T? instance, KdlSerializerOptio return doc; } - // public static KdlNode SerializeNode(object instance, KdlSerializerOptions? options) - // { - // var worker = new ObjectSerializer(options); - // return worker.SerializeObject(instance); - // } - private KdlNode SerializeObject(object instance, string? overrideNodeName = null) { var mapping = KdlTypeMapping.For(instance.GetType()); var node = new KdlNode(KdlValue.From(overrideNodeName ?? mapping.NodeName)); - // Debug.WriteLine(node); - if (mapping.IsDictionary && !mapping.HasMembers) + + foreach (var map in mapping.Arguments) { - var items = SerializeDictionaryContent( - (IEnumerable)instance, - mapping.DictionaryKeyProperty, - mapping.DictionaryValueProperty - ); - return node with { Children = new KdlBlock { Nodes = items.ToList() } }; + var val = KdlValueConverter.ToKdlOrThrow(map.GetValue(instance), map.TypeAnnotation); + node.Entries.Add(new KdlArgument(val)); } - else + + foreach (var map in mapping.Properties) + { + var raw = map.GetValue(instance); + if (raw == null && _options.IgnoreNullValues) + continue; + + var val = KdlValueConverter.ToKdlOrThrow(raw, map.TypeAnnotation); + node.Entries.Add(new KdlProperty(KdlValue.From(map.KdlName), val)); + } + + var childNodes = new List(); + foreach (var map in mapping.Children) { - foreach (var map in mapping.Arguments) + var childData = map.GetValue(instance); + if (childData is null) + continue; + + if (map.IsDictionary && childData is IEnumerable mapDict) { - var val = KdlValueConverter.ToKdlOrThrow( - map.GetValue(instance), - map.TypeAnnotation + var container = new KdlNode(KdlValue.From(map.KdlName)); + var items = SerializeDictionary( + mapDict, + map.DictionaryKeyProperty, + map.DictionaryValueProperty ); - node.Entries.Add(new KdlArgument(val)); + container = container with { Children = new KdlBlock { Nodes = items.ToList() } }; + childNodes.Add(container); } - - foreach (var map in mapping.Properties) + else if (map.IsCollection && childData is IEnumerable childCol) { - var raw = map.GetValue(instance); - if (raw == null && _options.IgnoreNullValues) - continue; - - var val = KdlValueConverter.ToKdlOrThrow(raw, map.TypeAnnotation); - node.Entries.Add(new KdlProperty(KdlValue.From(map.KdlName), val)); + childNodes.AddRange(SerializeCollection(childCol, map)); } - - var childNodes = new List(); - if (mapping.Children.Count > 0) + else { - foreach (var map in mapping.Children) - { - var childData = map.GetValue(instance); - if (childData is null) - continue; - - if (map.IsDictionary) - { - // Debug.WriteLine($"Dictionary KdlMemberMap for {map.Property.PropertyType}:"); - - // For dictionaries, we treat the Dictionary Keys as the Node Names - var container = new KdlNode(KdlValue.From(map.KdlName)); - var items = SerializeDictionary((IEnumerable)childData, map); - container = container with - { - Children = new KdlBlock { Nodes = items.ToList() }, - }; - // Debug.WriteLine(container); - childNodes.Add(container); - // childNodes.AddRange(SerializeDictionary((IDictionary)childData, map)); - } - else if (map.IsCollection) - { - // For collections, we serialize each item as a child node - childNodes.AddRange(SerializeCollection((IEnumerable)childData, map)); - } - else - { - // Single complex object - childNodes.Add(SerializeObject(childData, map.KdlName)); - } - } + childNodes.Add(SerializeObject(childData, map.KdlName)); } + } + if (mapping.IsDictionary && instance is IEnumerable enumerable) + { + var items = SerializeDictionary( + enumerable, + mapping.DictionaryKeyProperty, + mapping.DictionaryValueProperty + ); + childNodes.AddRange(items); + } - if (childNodes.Count > 0) - { - node = node with { Children = new KdlBlock { Nodes = childNodes } }; - } - return node; + if (childNodes.Count > 0) + { + node = node with { Children = new KdlBlock { Nodes = childNodes } }; } + return node; } private IEnumerable SerializeCollection(IEnumerable enumerable, KdlMemberMap map) @@ -161,20 +138,7 @@ private KdlNode MapToNode(object item, string kdlName, string? typeAnnotation) } } - private IEnumerable SerializeDictionary(IEnumerable dict, KdlMemberMap map) - { - foreach (var item in dict) - { - var key = map.DictionaryKeyProperty!.GetValue(item); - var val = map.DictionaryValueProperty!.GetValue(item); - if (key == null || val == null) - continue; - - yield return MapToNode(val!, key.ToString()!, map.TypeAnnotation); - } - } - - private IEnumerable SerializeDictionaryContent( + private IEnumerable SerializeDictionary( IEnumerable dict, PropertyInfo? keyProp, PropertyInfo? valProp, @@ -188,8 +152,6 @@ private IEnumerable SerializeDictionaryContent( if (key == null || val == null) continue; - // MapToNode handles the recursion: - // If 'val' is a dictionary, it calls SerializeObject, which triggers step (B) above. yield return MapToNode(val, key.ToString()!, typeAnno); } } From 011a16b7a1ef745e33abf11dd226f144a63568e1 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:04:00 +0000 Subject: [PATCH 13/19] wip --- .../Serialization/ObjectMapperTests.cs | 26 +++++++++++++++++++ src/Kuddle.Net/Extensions/TypeExtensions.cs | 1 + .../Serialization/KdlTypeMapping.cs | 1 + .../Serialization/ObjectDeserializer.cs | 16 +++++++----- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs index 81c1539..58350d3 100644 --- a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs @@ -543,9 +543,35 @@ await Assert // .Throws(); // } + [Test] + public async Task RoundTrip_HybridType_PreservesPropertiesAndDictionary() + { + var model = new HybridModel { Version = "2.5" }; + model["timeout"] = "5000"; + model["retry"] = "true"; + + var kdl = KdlSerializer.Serialize(model); + + // Should look like: + // hybridmodel version="2.5" { + // timeout "5000" + // retry "true" + // } + + var deserialized = KdlSerializer.Deserialize(kdl); + + await Assert.That(deserialized.Version).IsEqualTo("2.5"); + await Assert.That(deserialized["timeout"]).IsEqualTo("5000"); + await Assert.That(deserialized.Count).IsEqualTo(2); + } #endregion #region Test Models + public class HybridModel : Dictionary + { + [KdlProperty("version")] + public string Version { get; set; } = "1.0"; + } /// Simple class with properties and arguments. public class Package diff --git a/src/Kuddle.Net/Extensions/TypeExtensions.cs b/src/Kuddle.Net/Extensions/TypeExtensions.cs index fee36f1..aa18e86 100644 --- a/src/Kuddle.Net/Extensions/TypeExtensions.cs +++ b/src/Kuddle.Net/Extensions/TypeExtensions.cs @@ -12,6 +12,7 @@ internal static class TypeExtensions internal bool IsDictionary => type.IsGenericType && type.GetInterfaces() + .Append(type) .Any(i => i.IsGenericType && ( diff --git a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs index 318ba7b..51dc9e3 100644 --- a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs +++ b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs @@ -59,6 +59,7 @@ private KdlTypeMapping(Type type) internal List Arguments { get; } = []; public PropertyInfo? DictionaryKeyProperty { get; } public PropertyInfo? DictionaryValueProperty { get; } + public bool HasMembers => Arguments.Count > 0 || Properties.Count > 0 || Children.Count > 0; private void ValidateMapping() { diff --git a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs index c4690ba..0d51a7d 100644 --- a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs @@ -2,8 +2,6 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Reflection; -using System.Security.Cryptography.X509Certificates; using Kuddle.AST; using Kuddle.Extensions; @@ -130,17 +128,21 @@ private void MapNodeToObject(KdlNode node, object instance, KdlTypeMapping mappi // Mode B: Document Mode or Intrinsic Dictionary at root if (mapping.IsDictionary) { + var explicitChildNames = mapping + .Children.Select(c => c.KdlName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var dictionaryNodes = node.Children.Nodes.Where(n => + !explicitChildNames.Contains(n.Name.Value) + ); + PopulateDictionary( (IDictionary)instance, - node.Children.Nodes, + dictionaryNodes, mapping.DictionaryKeyProperty!.PropertyType, mapping.DictionaryValueProperty!.PropertyType ); } - else - { - MapChildren(node.Children.Nodes, instance, mapping); - } + MapChildren(node.Children.Nodes, instance, mapping); } } From 5ec39f6eb25851a02e44bdd3dd4ff35a46ea9d01 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Fri, 26 Dec 2025 19:08:32 +0000 Subject: [PATCH 14/19] support namespaced dict prop keys --- .../Serialization/ObjectMapperTests.cs | 75 +++++++++++++++++++ src/Kuddle.Net/Extensions/TypeExtensions.cs | 3 +- .../Serialization/KdlTypeMapping.cs | 19 ++++- .../Serialization/ObjectDeserializer.cs | 46 +++++++++++- .../Serialization/ObjectSerializer.cs | 26 ++++++- 5 files changed, 160 insertions(+), 9 deletions(-) diff --git a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs index 58350d3..acc4794 100644 --- a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs @@ -1,4 +1,5 @@ using Kuddle.AST; +using Kuddle.Exceptions; using Kuddle.Serialization; using Kuddle.Tests.Serialization.Models; @@ -564,6 +565,80 @@ public async Task RoundTrip_HybridType_PreservesPropertiesAndDictionary() await Assert.That(deserialized["timeout"]).IsEqualTo("5000"); await Assert.That(deserialized.Count).IsEqualTo(2); } + + public class PropertyDictModel + { + [KdlProperty] + public Dictionary Tags { get; set; } = new(); + + [KdlProperty("setting")] + public Dictionary Settings { get; set; } = new(); + } + + public class InvalidPropertyDictModel + { + [KdlProperty] + public Dictionary Items { get; set; } = new(); + } + + public class ComplexValue + { + public string Name { get; set; } + } + + [Test] + public async Task Serialize_PropertyDictionary_WritesFlatProperties() + { + // Arrange + var model = new PropertyDictModel(); + model.Tags["env"] = "production"; + model.Tags["region"] = "us-east-1"; + + // Using a prefix "setting" + model.Settings["timeout"] = 5000; + model.Settings["retries"] = 3; + + // Act + var kdl = KdlSerializer.Serialize(model); + + // Assert + // Simple dict: env="production" region="us-east-1" + await Assert.That(kdl).Contains("env=production"); + await Assert.That(kdl).Contains("region=us-east-1"); + + // Prefixed dict: setting:timeout=5000 setting:retries=3 + // (Assuming you implement the prefix: logic discussed) + await Assert.That(kdl).Contains("setting:timeout=5000"); + await Assert.That(kdl).Contains("setting:retries=3"); + + // Ensure no child block was created for these + await Assert.That(kdl).DoesNotContain("{"); + } + + [Test] + public async Task Deserialize_PropertyDictionary_MapsFlatPropertiesBack() + { + // Arrange + var kdl = "property-dict-model env=\"dev\" setting:port=8080"; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.Tags["env"]).IsEqualTo("dev"); + await Assert.That(result.Settings["port"]).IsEqualTo(8080); + } + + [Test] + public async Task Mapping_InvalidPropertyDictionary_ThrowsConfigurationException() + { + // Act & Assert + // This should fail because [KdlProperty] is applied to a dictionary + // with a complex value type (ComplexValue), which is illegal in KDL. + await Assert + .That(() => KdlTypeMapping.For()) + .Throws(); + } #endregion #region Test Models diff --git a/src/Kuddle.Net/Extensions/TypeExtensions.cs b/src/Kuddle.Net/Extensions/TypeExtensions.cs index aa18e86..734fb07 100644 --- a/src/Kuddle.Net/Extensions/TypeExtensions.cs +++ b/src/Kuddle.Net/Extensions/TypeExtensions.cs @@ -10,8 +10,7 @@ internal static class TypeExtensions extension(Type type) { internal bool IsDictionary => - type.IsGenericType - && type.GetInterfaces() + type.GetInterfaces() .Append(type) .Any(i => i.IsGenericType diff --git a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs index 51dc9e3..5b61215 100644 --- a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs +++ b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Reflection; using Kuddle.AST; @@ -75,6 +76,18 @@ private void ValidateMapping() Arguments.Clear(); Arguments.AddRange(sortedArgs); + + foreach (var prop in Properties) + { + if (prop.IsDictionary && !prop.DictionaryValueProperty!.PropertyType.IsKdlScalar) + { + throw new KdlConfigurationException( + $"Property '{prop.Property.PropertyType.Name}.{prop.Property.Name}' is marked with [KdlProperty], " + + $"but its value type '{prop.DictionaryValueProperty!.Name}' is complex. " + + "KDL properties only support scalar values. Use [KdlNode] instead." + ); + } + } } private KdlMemberMap CreateMemberMap(PropertyInfo prop) @@ -95,15 +108,15 @@ private KdlMemberMap CreateMemberMap(PropertyInfo prop) arg.Index, typeAnnotation ), - KdlPropertyAttribute p => new KdlMemberMap( prop, KdlMemberKind.Property, - p.Key ?? prop.Name.ToKebabCase(), + prop.PropertyType.IsDictionary + ? (p.Key ?? string.Empty) + : (p.Key ?? prop.Name.ToKebabCase()), -1, typeAnnotation ), - KdlNodeAttribute n => new KdlMemberMap( prop, KdlMemberKind.ChildNode, diff --git a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs index 0d51a7d..c8ca814 100644 --- a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs @@ -108,8 +108,8 @@ private void MapNodeToObject(KdlNode node, object instance, KdlTypeMapping mappi map.SetValue(instance, val); } } - - foreach (var map in mapping.Properties) + var consumedPropKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var map in mapping.Properties.Where(m => !m.IsDictionary)) { var kdlValue = node.Prop(map.KdlName); if (kdlValue != null) @@ -121,6 +121,48 @@ private void MapNodeToObject(KdlNode node, object instance, KdlTypeMapping mappi map.TypeAnnotation ); map.SetValue(instance, val); + + consumedPropKeys.Add(map.KdlName); + } + } + foreach (var map in mapping.Properties.Where(m => m.IsDictionary)) + { + var dict = EnsureInstance(instance, map) as IDictionary; + bool isNamespaced = !string.IsNullOrWhiteSpace(map.KdlName); + string prefix = isNamespaced ? $"{map.KdlName}:" : ""; + // Iterate through all actual properties in the KDL node + foreach (var kdlProp in node.Properties) + { + string key = kdlProp.Key.Value; + + if (isNamespaced) + { + // NAMESPACED logic: continue if it doesn't match our prefix + if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + string dictKey = key.Substring(prefix.Length); + dict![dictKey] = KdlValueConverter.FromKdlOrThrow( + kdlProp.Value, + map.Property.PropertyType.GetGenericArguments()[1], + key + ); + } + } + else + { + // Greedy: take anything that wasn't matched by an explicit property in Pass 1 + if (consumedPropKeys.Contains(key) || key.Contains(':')) + { + continue; + } + var valueType = map.Property.PropertyType.GetGenericArguments()[1]; + dict![key] = KdlValueConverter.FromKdlOrThrow(kdlProp.Value, valueType, key); + } } } if (node.Children != null) diff --git a/src/Kuddle.Net/Serialization/ObjectSerializer.cs b/src/Kuddle.Net/Serialization/ObjectSerializer.cs index 35be36d..61aa51d 100644 --- a/src/Kuddle.Net/Serialization/ObjectSerializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectSerializer.cs @@ -66,8 +66,30 @@ private KdlNode SerializeObject(object instance, string? overrideNodeName = null if (raw == null && _options.IgnoreNullValues) continue; - var val = KdlValueConverter.ToKdlOrThrow(raw, map.TypeAnnotation); - node.Entries.Add(new KdlProperty(KdlValue.From(map.KdlName), val)); + if (map.IsDictionary) + { + string prefix = string.IsNullOrEmpty(map.KdlName) ? "" : $"{map.KdlName}:"; + + var dict = raw as IEnumerable; + foreach (var item in dict!) + { + var k = map.DictionaryKeyProperty?.GetValue(item); + var v = map.DictionaryValueProperty?.GetValue(item); + if (k == null) + continue; + node.Entries.Add( + new KdlProperty( + KdlValue.From($"{prefix}{k}"), + KdlValueConverter.ToKdlOrThrow(v) + ) + ); + } + } + else + { + var val = KdlValueConverter.ToKdlOrThrow(raw, map.TypeAnnotation); + node.Entries.Add(new KdlProperty(KdlValue.From(map.KdlName), val)); + } } var childNodes = new List(); From d3d3a4c9680e1fe042f9af03235463bdd2cdd6e7 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Fri, 26 Dec 2025 22:14:39 +0000 Subject: [PATCH 15/19] fix benchmarks --- src/Kuddle.Net.Benchmarks/SerializerBenchmarks.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Kuddle.Net.Benchmarks/SerializerBenchmarks.cs b/src/Kuddle.Net.Benchmarks/SerializerBenchmarks.cs index 5752f38..c803ee0 100644 --- a/src/Kuddle.Net.Benchmarks/SerializerBenchmarks.cs +++ b/src/Kuddle.Net.Benchmarks/SerializerBenchmarks.cs @@ -115,7 +115,7 @@ port 8080 } ); } - _largePackageListKdl = KdlSerializer.SerializeMany(_largePackageList); + _largePackageListKdl = KdlSerializer.Serialize(_largePackageList); } [Benchmark] @@ -170,7 +170,7 @@ public string SerializeComplexProject() [Benchmark] public string SerializeLargePackageList() { - return KdlSerializer.SerializeMany(_largePackageList); + return KdlSerializer.Serialize(_largePackageList); } // Deserialization benchmarks From 070757ca4f15de929f8dffd506eb0084025f6abf Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Sat, 27 Dec 2025 03:27:00 +0000 Subject: [PATCH 16/19] feat: allow nodes to be explicitly wrapped --- .../Serialization/ObjectMapperTests.cs | 76 +++++++++++++++++-- .../Attributes/KdlNodeAttribute.cs | 1 + src/Kuddle.Net/Serialization/KdlMemberMap.cs | 5 +- .../Serialization/KdlTypeMapping.cs | 6 +- .../Serialization/ObjectSerializer.cs | 15 +++- 5 files changed, 92 insertions(+), 11 deletions(-) diff --git a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs index acc4794..1036cc4 100644 --- a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Kuddle.AST; using Kuddle.Exceptions; using Kuddle.Serialization; @@ -602,16 +603,12 @@ public async Task Serialize_PropertyDictionary_WritesFlatProperties() var kdl = KdlSerializer.Serialize(model); // Assert - // Simple dict: env="production" region="us-east-1" await Assert.That(kdl).Contains("env=production"); await Assert.That(kdl).Contains("region=us-east-1"); - // Prefixed dict: setting:timeout=5000 setting:retries=3 - // (Assuming you implement the prefix: logic discussed) await Assert.That(kdl).Contains("setting:timeout=5000"); await Assert.That(kdl).Contains("setting:retries=3"); - // Ensure no child block was created for these await Assert.That(kdl).DoesNotContain("{"); } @@ -632,13 +629,78 @@ public async Task Deserialize_PropertyDictionary_MapsFlatPropertiesBack() [Test] public async Task Mapping_InvalidPropertyDictionary_ThrowsConfigurationException() { - // Act & Assert - // This should fail because [KdlProperty] is applied to a dictionary - // with a complex value type (ComplexValue), which is illegal in KDL. await Assert .That(() => KdlTypeMapping.For()) .Throws(); } + + public class CollectionModel + { + [KdlNode("plugins")] + public List WrappedPlugins { get; set; } = []; + + [KdlNode("server", Flatten = true)] + public List FlattenedServers { get; set; } = []; + } + + public class PluginInfo + { + [KdlArgument(0)] + public string Name { get; set; } = ""; + } + + public class ServerInfo + { + [KdlProperty] + public string Host { get; set; } = ""; + } + + [Test] + public async Task Serialize_CollectionStrategies_WritesCorrectStructure() + { + // Arrange + var model = new CollectionModel + { + WrappedPlugins = [new() { Name = "Auth" }], + FlattenedServers = [new() { Host = "localhost" }, new() { Host = "127.0.0.1" }], + }; + + // Act + var kdl = KdlSerializer.Serialize(model); + Debug.WriteLine(kdl); + // Assert Wrapped: plugins { plugininfo "Auth" } + await Assert.That(kdl).Contains("plugins {"); + + // Assert Flattened: server host="localhost" + await Assert.That(kdl).Contains("server host=localhost"); + await Assert.That(kdl).Contains("server host=\"127.0.0.1\""); + + // Double check there isn't a wrapper node for servers + // (Assuming the class property was named FlattenedServers) + await Assert.That(kdl).DoesNotContain("flattenedservers"); + } + + [Test] + public async Task Deserialize_FlattenedCollection_CollectsAllMatchingNodes() + { + // Arrange: KDL with multiple 'server' nodes at the same level as 'plugins' + var kdl = """ + plugins { + plugininfo "Auth" + } + server host="localhost" + server host="remote" + """; + + // Act + var result = KdlSerializer.Deserialize(kdl); + + // Assert + await Assert.That(result.WrappedPlugins).Count().IsEqualTo(1); + await Assert.That(result.FlattenedServers).Count().IsEqualTo(2); + await Assert.That(result.FlattenedServers[1].Host).IsEqualTo("remote"); + } + #endregion #region Test Models diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs index 2efdf15..0b84ef4 100644 --- a/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs +++ b/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs @@ -6,4 +6,5 @@ namespace Kuddle.Serialization; public sealed class KdlNodeAttribute(string? name = null) : KdlEntryAttribute { public string? Name { get; } = name; + public bool Flatten { get; set; } } diff --git a/src/Kuddle.Net/Serialization/KdlMemberMap.cs b/src/Kuddle.Net/Serialization/KdlMemberMap.cs index 9652f61..7dea23c 100644 --- a/src/Kuddle.Net/Serialization/KdlMemberMap.cs +++ b/src/Kuddle.Net/Serialization/KdlMemberMap.cs @@ -10,7 +10,8 @@ public KdlMemberMap( KdlMemberKind kind, string kdlName, int argumentIndex = -1, - string? typeAnnotation = null + string? typeAnnotation = null, + bool isFlattened = false ) { Property = property; @@ -18,6 +19,7 @@ public KdlMemberMap( KdlName = kdlName; ArgumentIndex = argumentIndex; TypeAnnotation = typeAnnotation; + IsFlattened = isFlattened; IsDictionary = property.PropertyType.IsDictionary; var elementType = property.PropertyType.GetCollectionElementType(); IsCollection = elementType != null; @@ -39,6 +41,7 @@ public KdlMemberMap( public string? TypeAnnotation { get; } public PropertyInfo? DictionaryKeyProperty { get; } public PropertyInfo? DictionaryValueProperty { get; } + public bool IsFlattened { get; } public object? GetValue(object instance) => Property.GetValue(instance); diff --git a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs index 5b61215..41b49f4 100644 --- a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs +++ b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs @@ -122,14 +122,16 @@ private KdlMemberMap CreateMemberMap(PropertyInfo prop) KdlMemberKind.ChildNode, n.Name ?? prop.Name.ToKebabCase(), -1, - typeAnnotation + typeAnnotation, + n.Flatten ), KdlNodeCollectionAttribute nc => new KdlMemberMap( prop, KdlMemberKind.ChildNode, nc.NodeName, -1, - typeAnnotation + typeAnnotation, + false ), KdlNodeDictionaryAttribute nd => new KdlMemberMap( prop, diff --git a/src/Kuddle.Net/Serialization/ObjectSerializer.cs b/src/Kuddle.Net/Serialization/ObjectSerializer.cs index 61aa51d..0bbc76b 100644 --- a/src/Kuddle.Net/Serialization/ObjectSerializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectSerializer.cs @@ -112,7 +112,20 @@ private KdlNode SerializeObject(object instance, string? overrideNodeName = null } else if (map.IsCollection && childData is IEnumerable childCol) { - childNodes.AddRange(SerializeCollection(childCol, map)); + var items = SerializeCollection(childCol, map); + + if (map.IsFlattened) + { + childNodes.AddRange(items); + } + else + { + var container = new KdlNode(KdlValue.From(map.KdlName)) + { + Children = new KdlBlock { Nodes = items.ToList() }, + }; + childNodes.Add(container); + } } else { From 6b153ff9728b13c43a8ec69ac89ada7a552345dc Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Sat, 27 Dec 2025 11:29:02 +0000 Subject: [PATCH 17/19] merge KdlNodeAttribute with KdlNodeCollectionAttribute --- .../Serialization/ObjectMapperTests.cs | 4 ++-- .../Serialization/Attributes/KdlNodeAttribute.cs | 1 + .../Attributes/KdlNodeCollectionAttribute.cs | 16 ---------------- src/Kuddle.Net/Serialization/KdlMemberMap.cs | 5 ++++- src/Kuddle.Net/Serialization/KdlTypeMapping.cs | 14 ++++---------- src/Kuddle.Net/Serialization/ObjectSerializer.cs | 9 ++++++++- 6 files changed, 19 insertions(+), 30 deletions(-) delete mode 100644 src/Kuddle.Net/Serialization/Attributes/KdlNodeCollectionAttribute.cs diff --git a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs index 1036cc4..697fc41 100644 --- a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs @@ -636,7 +636,7 @@ await Assert public class CollectionModel { - [KdlNode("plugins")] + [KdlNode("plugins", Flatten = false)] public List WrappedPlugins { get; set; } = []; [KdlNode("server", Flatten = true)] @@ -683,7 +683,7 @@ public async Task Serialize_CollectionStrategies_WritesCorrectStructure() [Test] public async Task Deserialize_FlattenedCollection_CollectsAllMatchingNodes() { - // Arrange: KDL with multiple 'server' nodes at the same level as 'plugins' + // Arrange var kdl = """ plugins { plugininfo "Auth" diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs index 0b84ef4..bcf9f44 100644 --- a/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs +++ b/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs @@ -6,5 +6,6 @@ namespace Kuddle.Serialization; public sealed class KdlNodeAttribute(string? name = null) : KdlEntryAttribute { public string? Name { get; } = name; + public string? ElementName { get; init; } = null; public bool Flatten { get; set; } } diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlNodeCollectionAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlNodeCollectionAttribute.cs deleted file mode 100644 index 0d5ceca..0000000 --- a/src/Kuddle.Net/Serialization/Attributes/KdlNodeCollectionAttribute.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; - -namespace Kuddle.Serialization; - -/// -/// Maps a collection to a child node (container) that holds the items. -/// -/// The name of the wrapper/container node. -/// The node name for items inside the container. If null, uses the type's default. -[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] -public class KdlNodeCollectionAttribute(string nodeName, string? elementName = null) - : KdlEntryAttribute -{ - public string NodeName { get; } = nodeName; - public string? ElementName { get; } = elementName; -} diff --git a/src/Kuddle.Net/Serialization/KdlMemberMap.cs b/src/Kuddle.Net/Serialization/KdlMemberMap.cs index 7dea23c..2d1e24e 100644 --- a/src/Kuddle.Net/Serialization/KdlMemberMap.cs +++ b/src/Kuddle.Net/Serialization/KdlMemberMap.cs @@ -11,7 +11,8 @@ public KdlMemberMap( string kdlName, int argumentIndex = -1, string? typeAnnotation = null, - bool isFlattened = false + bool isFlattened = false, + string? collectionElementName = null ) { Property = property; @@ -20,6 +21,7 @@ public KdlMemberMap( ArgumentIndex = argumentIndex; TypeAnnotation = typeAnnotation; IsFlattened = isFlattened; + ElementName = collectionElementName; IsDictionary = property.PropertyType.IsDictionary; var elementType = property.PropertyType.GetCollectionElementType(); IsCollection = elementType != null; @@ -42,6 +44,7 @@ public KdlMemberMap( public PropertyInfo? DictionaryKeyProperty { get; } public PropertyInfo? DictionaryValueProperty { get; } public bool IsFlattened { get; } + public string? ElementName { get; } public object? GetValue(object instance) => Property.GetValue(instance); diff --git a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs index 41b49f4..b78e53f 100644 --- a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs +++ b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs @@ -123,16 +123,10 @@ private KdlMemberMap CreateMemberMap(PropertyInfo prop) n.Name ?? prop.Name.ToKebabCase(), -1, typeAnnotation, - n.Flatten - ), - KdlNodeCollectionAttribute nc => new KdlMemberMap( - prop, - KdlMemberKind.ChildNode, - nc.NodeName, - -1, - typeAnnotation, - false + n.Flatten, + collectionElementName: n.ElementName ), + KdlNodeDictionaryAttribute nd => new KdlMemberMap( prop, KdlMemberKind.ChildNode, @@ -154,7 +148,7 @@ private static KdlEntryAttribute InferAttribute(PropertyInfo prop) => prop.PropertyType switch { { IsDictionary: true } => new KdlNodeDictionaryAttribute(prop.Name.ToKebabCase()), - { IsIEnumerable: true } => new KdlNodeCollectionAttribute(prop.Name.ToKebabCase()), + { IsIEnumerable: true } => new KdlNodeAttribute(prop.Name.ToKebabCase()), { IsKdlScalar: true } => new KdlPropertyAttribute(prop.Name.ToKebabCase()), _ => new KdlNodeAttribute(prop.Name.ToKebabCase()), }; diff --git a/src/Kuddle.Net/Serialization/ObjectSerializer.cs b/src/Kuddle.Net/Serialization/ObjectSerializer.cs index 0bbc76b..32d21ae 100644 --- a/src/Kuddle.Net/Serialization/ObjectSerializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectSerializer.cs @@ -156,10 +156,17 @@ private IEnumerable SerializeCollection(IEnumerable enumerable, KdlMemb if (item is null) continue; - yield return MapToNode(item, map.KdlName, map.TypeAnnotation); + string itemName = map.IsFlattened + ? map.KdlName + : map.ElementName ?? GetDefaultNodeName(item); + + yield return MapToNode(item, itemName, map.TypeAnnotation); } } + private static string GetDefaultNodeName(object item) => + item.GetType().IsKdlScalar ? "-" : KdlTypeMapping.For(item.GetType()).NodeName; + private KdlNode MapToNode(object item, string kdlName, string? typeAnnotation) { if (item.GetType().IsKdlScalar) From 2bfc5f865cda2c0eda132170f3c973648c1d55dc Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Sat, 27 Dec 2025 20:29:06 +0000 Subject: [PATCH 18/19] refactor: implement flattened dictionary serialization --- .../Serialization/DocumentToObjectTests.cs | 2 +- .../Serialization/Models/AppSettings.cs | 4 +-- .../Serialization/ObjectMapperTests.cs | 4 +-- .../Attributes/KdlNodeDictionaryAttribute.cs | 14 ++++----- .../Serialization/KdlTypeMapping.cs | 18 +++++------ .../Serialization/ObjectDeserializer.cs | 30 ++++++++----------- .../Serialization/ObjectSerializer.cs | 15 ++++++++-- 7 files changed, 45 insertions(+), 42 deletions(-) diff --git a/src/Kuddle.Net.Tests/Serialization/DocumentToObjectTests.cs b/src/Kuddle.Net.Tests/Serialization/DocumentToObjectTests.cs index cd77ece..60fa2ab 100644 --- a/src/Kuddle.Net.Tests/Serialization/DocumentToObjectTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/DocumentToObjectTests.cs @@ -7,7 +7,7 @@ public class DocumentToObjectTests { class AppConfig { - [KdlNode("plugin")] + [KdlNode("plugin", Flatten = true)] public List Plugins { get; set; } = new(); [KdlNode("logging")] diff --git a/src/Kuddle.Net.Tests/Serialization/Models/AppSettings.cs b/src/Kuddle.Net.Tests/Serialization/Models/AppSettings.cs index 3de7d62..0b8f0aa 100644 --- a/src/Kuddle.Net.Tests/Serialization/Models/AppSettings.cs +++ b/src/Kuddle.Net.Tests/Serialization/Models/AppSettings.cs @@ -4,10 +4,10 @@ namespace Kuddle.Tests.Serialization.Models; public class AppSettings { - [KdlNodeDictionary("themes")] + [KdlNode("themes")] public Dictionary Themes { get; set; } = new(); - [KdlNodeDictionary("layouts")] + [KdlNode("layouts")] public Dictionary Layouts { get; set; } = new(); } diff --git a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs index 697fc41..67705aa 100644 --- a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs @@ -732,10 +732,10 @@ public class Project [KdlProperty("version")] public string Version { get; set; } = "1.0.0"; - [KdlNode("dependency")] + [KdlNode("dependency", Flatten = true)] public List Dependencies { get; set; } = []; - [KdlNode("devDependency")] + [KdlNode("devDependency", Flatten = true)] public List DevDependencies { get; set; } = []; } diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlNodeDictionaryAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlNodeDictionaryAttribute.cs index 321f977..9ec6c49 100644 --- a/src/Kuddle.Net/Serialization/Attributes/KdlNodeDictionaryAttribute.cs +++ b/src/Kuddle.Net/Serialization/Attributes/KdlNodeDictionaryAttribute.cs @@ -1,9 +1,9 @@ -using System; +// using System; -namespace Kuddle.Serialization; +// namespace Kuddle.Serialization; -[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] -public class KdlNodeDictionaryAttribute(string? name = null) : KdlEntryAttribute -{ - public string? Name { get; } = name; -} +// [AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +// public class KdlNodeDictionaryAttribute(string? name = null) : KdlEntryAttribute +// { +// public string? Name { get; } = name; +// } diff --git a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs index b78e53f..eaf7505 100644 --- a/src/Kuddle.Net/Serialization/KdlTypeMapping.cs +++ b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs @@ -90,7 +90,7 @@ private void ValidateMapping() } } - private KdlMemberMap CreateMemberMap(PropertyInfo prop) + private static KdlMemberMap CreateMemberMap(PropertyInfo prop) { var all = prop.GetCustomAttributes(); if (prop.GetCustomAttribute() is not KdlEntryAttribute attr) @@ -127,13 +127,13 @@ private KdlMemberMap CreateMemberMap(PropertyInfo prop) collectionElementName: n.ElementName ), - KdlNodeDictionaryAttribute nd => new KdlMemberMap( - prop, - KdlMemberKind.ChildNode, - nd.Name ?? prop.Name.ToKebabCase(), - -1, - typeAnnotation - ), + // KdlNodeDictionaryAttribute nd => new KdlMemberMap( + // prop, + // KdlMemberKind.ChildNode, + // nd.Name ?? prop.Name.ToKebabCase(), + // -1, + // typeAnnotation + // ), _ => new KdlMemberMap( prop, KdlMemberKind.ChildNode, @@ -147,7 +147,7 @@ private KdlMemberMap CreateMemberMap(PropertyInfo prop) private static KdlEntryAttribute InferAttribute(PropertyInfo prop) => prop.PropertyType switch { - { IsDictionary: true } => new KdlNodeDictionaryAttribute(prop.Name.ToKebabCase()), + { IsDictionary: true } => new KdlNodeAttribute(prop.Name.ToKebabCase()), { IsIEnumerable: true } => new KdlNodeAttribute(prop.Name.ToKebabCase()), { IsKdlScalar: true } => new KdlPropertyAttribute(prop.Name.ToKebabCase()), _ => new KdlNodeAttribute(prop.Name.ToKebabCase()), diff --git a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs index c8ca814..6dff304 100644 --- a/src/Kuddle.Net/Serialization/ObjectDeserializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs @@ -205,28 +205,22 @@ private void MapChildren(List? nodes, object instance, KdlTypeMapping m if (matches is null || matches.Count == 0) continue; + List nodesToProcess = map.IsFlattened + ? matches + : matches[^1].Children?.Nodes ?? []; + if (map.IsDictionary) { - var container = matches.Last(); - if (container.Children != null) - { - var dict = EnsureInstance(instance, map) as IDictionary; - PopulateDictionary( - dict!, - container.Children.Nodes, - map.DictionaryKeyProperty!.PropertyType, - map.DictionaryValueProperty!.PropertyType - ); - } + var dict = EnsureInstance(instance, map) as IDictionary; + PopulateDictionary( + dict!, + nodesToProcess, + map.DictionaryKeyProperty!.PropertyType, + map.DictionaryValueProperty!.PropertyType + ); } else if (map.IsCollection) { - KdlNode container = matches.Last(); - - List nodesToProcess = container.HasChildren - ? container.Children?.Nodes! - : matches; - PopulateCollection(instance, nodesToProcess, map); } else @@ -234,7 +228,7 @@ private void MapChildren(List? nodes, object instance, KdlTypeMapping m var last = matches.Last(); object? value; - if (map.Property.PropertyType.IsKdlScalar) // Use your extension + if (map.Property.PropertyType.IsKdlScalar) { var arg = last.Arg(0); value = diff --git a/src/Kuddle.Net/Serialization/ObjectSerializer.cs b/src/Kuddle.Net/Serialization/ObjectSerializer.cs index 32d21ae..7a7a557 100644 --- a/src/Kuddle.Net/Serialization/ObjectSerializer.cs +++ b/src/Kuddle.Net/Serialization/ObjectSerializer.cs @@ -101,14 +101,23 @@ private KdlNode SerializeObject(object instance, string? overrideNodeName = null if (map.IsDictionary && childData is IEnumerable mapDict) { - var container = new KdlNode(KdlValue.From(map.KdlName)); var items = SerializeDictionary( mapDict, map.DictionaryKeyProperty, map.DictionaryValueProperty ); - container = container with { Children = new KdlBlock { Nodes = items.ToList() } }; - childNodes.Add(container); + if (map.IsFlattened) + { + childNodes.AddRange(items); + } + else + { + var container = new KdlNode(KdlValue.From(map.KdlName)) + { + Children = new KdlBlock { Nodes = items.ToList() }, + }; + childNodes.Add(container); + } } else if (map.IsCollection && childData is IEnumerable childCol) { From a367d9fe4f649bad88e019f4e4b01aafd8397913 Mon Sep 17 00:00:00 2001 From: jamesshenry <79054685+jamesshenry@users.noreply.github.com> Date: Sat, 27 Dec 2025 22:54:50 +0000 Subject: [PATCH 19/19] docs: improve --- docs/low-level-api.md | 145 +++++++++++++++++++++++++++ docs/serialization-attributes.md | 167 +++++++++++++++++++++++++++---- readme.md | 80 ++++++++++++--- 3 files changed, 359 insertions(+), 33 deletions(-) create mode 100644 docs/low-level-api.md diff --git a/docs/low-level-api.md b/docs/low-level-api.md new file mode 100644 index 0000000..69a24a5 --- /dev/null +++ b/docs/low-level-api.md @@ -0,0 +1,145 @@ +# Lower-Level API: Reader, Writer, and AST + +For scenarios where the high-level `KdlSerializer` is too restrictive, Kuddle.Net provides direct access to the KDL AST (Abstract Syntax Tree) via `KdlReader` and `KdlWriter`. + +## Reading KDL (KdlReader) + +`KdlReader.Read` parses a KDL string and returns a `KdlDocument`. + +```csharp +using Kuddle.AST; +using Kuddle.Serialization; + +string kdl = "node 1 2 key=\"val\""; +KdlDocument doc = KdlReader.Read(kdl); + +foreach (KdlNode node in doc.Nodes) +{ + Console.WriteLine($"Node name: {node.Name.Value}"); +} +``` + +### Options + +`KdlReaderOptions` allows you to customize the reading process: + +```csharp +var options = new KdlReaderOptions +{ + ValidateReservedTypes = true // Validates (uuid), (date-time), etc. format +}; + +KdlDocument doc = KdlReader.Read(kdl, options); +``` + +--- + +## Writing KDL (KdlWriter) + +`KdlWriter.Write` takes a `KdlDocument` (or any `KdlObject`) and returns its KDL string representation. + +```csharp +var doc = new KdlDocument(); +// ... build doc ... + +string kdl = KdlWriter.Write(doc); +// Or use doc.ToString() which uses default options +``` + +### Options + +`KdlWriterOptions` controls the output formatting: + +```csharp +var options = new KdlWriterOptions +{ + IndentChar = "\t", + NewLine = "\r\n", + SpaceAfterProp = " ", + EscapeUnicode = true +}; + +string kdl = KdlWriter.Write(doc, options); +``` + +--- + +## The KDL AST + +The AST is composed of records representing KDL constructs. + +### `KdlDocument` + +The root of a KDL file. + +- `Nodes`: `List` + +### `KdlNode` + +A single KDL node. + +- `Name`: `KdlString` +- `Entries`: `List` (Arguments or Properties) +- `Children`: `KdlBlock?` (Nested nodes) +- `TypeAnnotation`: `string?` + +### `KdlEntry` + +Base class for entries within a node. + +- `KdlArgument`: Positional value (`KdlValue`) +- `KdlProperty`: Key-value pair (`KdlString Key`, `KdlValue Value`) + +### `KdlValue` + +Base class for all constants. + +- `KdlString`: Represents strings. Support for varieties via `StringKind`: + - `StringKind.Bare`: `bare-string` + - `StringKind.Quoted`: `"quoted string"` + - `StringKind.Raw`: `r#"raw string"#` + - `StringKind.MultiLine`: `"""multi-line string"""` +- `KdlNumber`: Represents numeric values. Stores the `RawValue` string to preserve precision and formatting (e.g., `0xFF` vs `255`). +- `KdlBool`: `#true` or `#false`. +- `KdlNull`: `#null`. + +--- + +## Serialization Options + +When using `KdlSerializer`, you can pass `KdlSerializerOptions` to control the behavior: + +```csharp +var options = new KdlSerializerOptions +{ + IgnoreNullValues = true, // Don't write properties with null values + CaseInsensitiveNames = true, // Match KDL names to C# properties case-insensitively + WriteTypeAnnotations = true // Include (uuid), (date-time) etc. in output +}; + +string kdl = KdlSerializer.Serialize(myObj, options); +``` + +--- + +## Extension Methods + +Kuddle.Net provides helpful extension methods in `Kuddle.Extensions` for working with the AST: + +```csharp +using Kuddle.Extensions; + +KdlNode node = ...; + +// Get property value +KdlValue? val = node.Prop("my-key"); + +// Get argument by index +KdlValue? arg = node.Arg(0); + +// Try to get typed values +if (node.TryGetProp("port", out int port)) +{ + // ... +} +``` diff --git a/docs/serialization-attributes.md b/docs/serialization-attributes.md index a6c13bc..7d3e254 100644 --- a/docs/serialization-attributes.md +++ b/docs/serialization-attributes.md @@ -135,15 +135,18 @@ public int MaxRetries { get; set; } Maps a property to **child nodes** within the parent's `{ }` block. -### Basic Usage — Collection of Child Nodes +### Basic Usage — Wrapped Collection + +By default, a collection is wrapped in a container node with the specified name: #### KDL ```kdl -project web-app version="1.0.0" { - dependency lodash version="4.17.21" - dependency react version="18.2.0" - devDependency jest version="29.0.0" +project { + dependencies { + dependency "lodash" version="4.17.21" + dependency "react" version="18.2.0" + } } ``` @@ -152,21 +155,60 @@ project web-app version="1.0.0" { ```csharp public class Project { - [KdlArgument(0)] - public string Name { get; set; } = ""; - - [KdlProperty("version")] - public string Version { get; set; } = "1.0.0"; - - [KdlNode("dependency")] + [KdlNode("dependencies")] public List Dependencies { get; set; } = []; - - [KdlNode("devDependency")] - public List DevDependencies { get; set; } = []; } ``` -### Single Complex Child +### Flattened Collection + +If you want the child nodes to appear directly under the parent without a wrapper, use `Flatten = true`: + +#### KDL + +```kdl +project { + dependency "lodash" version="4.17.21" + dependency "react" version="18.2.0" +} +``` + +#### C# Model + +```csharp +public class Project +{ + [KdlNode("dependency", Flatten = true)] + public List Dependencies { get; set; } = []; +} +``` + +### Customizing Element Names + +For wrapped collections, you can specify the name of the item nodes using `ElementName`: + +#### KDL + +```kdl +project { + dependencies { + pkg "lodash" + pkg "react" + } +} +``` + +#### C# Model + +```csharp +public class Project +{ + [KdlNode("dependencies", ElementName = "pkg")] + public List Packages { get; set; } = []; +} +``` + +### Scalar Child Node When the property type is a **non-collection complex type**, it maps to a single child node: @@ -263,6 +305,90 @@ public class User --- +## Dictionaries + +Dictionaries can be mapped in two ways depending on whether you want them as **Properties** or **Child Nodes**. + +### Dictionary as Properties (`[KdlProperty]`) + +Maps dictionary entries to `key=value` properties on the node. +*Note: Value types must be scalars.* + +#### C# Model + +```csharp +public class Header +{ + [KdlProperty("meta")] + public Dictionary Metadata { get; set; } = []; +} +``` + +#### KDL + +```kdl +header meta:author="Alice" meta:version="1.2.3" +``` + +### Dictionary as Child Nodes (`[KdlNode]`) + +Maps dictionary entries to individual child nodes where the **key is the node name**. + +#### C# Model + +```csharp +public class Environment +{ + [KdlNode("vars", Flatten = true)] + public Dictionary Variables { get; set; } = []; +} +``` + +#### KDL + +```kdl +environment { + PATH "/usr/bin" + HOME "/home/alice" +} +``` + +--- + +## Enums + +Enums are automatically serialized and deserialized as bare strings. + +```csharp +public enum LogLevel { Debug, Info, Warning, Error } + +public class Logger +{ + [KdlProperty] + public LogLevel Level { get; set; } +} +``` + +**KDL:** `logger level=info` (Case-insensitive by default) + +--- + +## Custom Type Annotations + +You can force a specific KDL type annotation on any argument, property, or node. + +```csharp +public class Data +{ + [KdlProperty("checksum", TypeAnnotation = "hex")] + public string Hash { get; set; } +} +``` + +**KDL:** `data checksum=(hex)"a1b2c3d4"` + +--- + ## Type Conversion ### Automatic Type Mapping @@ -426,8 +552,7 @@ public class Repository ## Limitations & Notes -1. **No dictionary support** — `Dictionary` types are not currently supported -2. **No polymorphism** — Cannot deserialize to derived types based on discriminator -3. **Case sensitivity** — Node/property name matching is case-insensitive by default -4. **Argument gaps** — Missing argument indices will throw; ensure contiguous indices -5. **Round-trip fidelity** — Comments, formatting, and slashdash elements are not preserved +1. **No polymorphism** — Cannot deserialize to derived types based on discriminator +2. **Case sensitivity** — Node/property name matching is case-insensitive by default +3. **Argument gaps** — Missing argument indices will throw; ensure contiguous indices +4. **No Round-trip fidelity** — Comments, formatting, and slashdash elements are not preserved diff --git a/readme.md b/readme.md index 98a21f3..07ebe5f 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,7 @@ # Kuddle.Net Kuddle.Net is a .NET implementation of a [KDL](https://kdl.dev) parser/serializer targeting [v2](https://kdl.dev/spec/) of the spec. -KDL is concise, human-readable language built for configuration and data exchange. Head to for more specifics on the KDL document language itself. +KDL is a concise, human-readable language built for configuration and data exchange. Head to for more specifics on the KDL document language itself. ## Installation @@ -9,21 +9,77 @@ KDL is concise, human-readable language built for configuration and data exchang dotnet add package Kuddle.Net ``` -## Usage +## Quick Start: Serialization & Deserialization -There are a few ways of using the library, the lower level `KdlReader` and `KdlWriter` classes, and the utility `KdlSerializer` class. For most use cases `KdlSerializer.Serialize`/`KdlSerializer.Deserialize` will be sufficient. +For most use cases, `KdlSerializer` provides the easiest way to work with KDL data by mapping it directly to C# classes. -```cs -var dbKdl = """ -database main port=5432 -"""; +### Deserializing KDL to Objects -var dbConfig = KdlSerializer.Deserialize(dbKdl); +```csharp +using Kuddle.Serialization; + +var kdl = """ + server "production" { + host "10.0.0.1" + port 8080 + } + """; + +// Deserialize a single root node +var config = KdlSerializer.Deserialize(kdl); +``` + +### Serializing Objects to KDL + +```csharp +var myConfig = new ServerConfig { Host = "localhost", Port = 3000 }; +string kdl = KdlSerializer.Serialize(myConfig); +``` + +### Document-Level Deserialization + +If your KDL file contains multiple top-level nodes of the same type, use `DeserializeMany`: + +```csharp +var kdl = """ + user "alice" role="admin" + user "bob" role="user" + """; + +var users = KdlSerializer.DeserializeMany(kdl); +``` + +--- + +## Mapping with Attributes + +To control how C# properties map to KDL arguments, properties, and child nodes, use the provided attributes. + +| Attribute | Target | Purpose | +| ---------------------- | -------- | --------------------------------------------- | +| `[KdlArgument(index)]` | Property | Maps to a positional argument | +| `[KdlProperty(key)]` | Property | Maps to a `key="value"` property | +| `[KdlNode(name)]` | Property | Maps to a child node (or collection of nodes) | +| `[KdlType(name)]` | Class | Overrides the default node name | + +**[Detailed Attribute Documentation](docs/serialization-attributes.md)** + +--- + +## Advanced Usage + +### Lower-Level AST Access + +If you need full control over the KDL structure, you can use `KdlReader` to get a `KdlDocument` AST. + +```csharp +KdlDocument doc = KdlReader.Read(kdlString); ``` -KDL differs from other configuration languages like -yaml or toml in that it is node-based. The top-level document can consist of a collection of nodes, not args (e.g. `#true`, `#false`, `0xAF`) or properties (`key=#false`). To adhere to this, the serialization API depends on the use of several provided attributes to facilitate mapping `KDL` constructs to user defined types: +**[Lower-Level API Documentation](docs/low-level-api.md)** + +--- -### Attributes +## License -[how to use Kdl attributes](docs/serialization-attributes.md) +Kuddle.Net is licensed under the MIT License.