diff --git a/.ai/outputs/codebase.txt b/.ai/outputs/codebase.txt new file mode 100644 index 0000000..a8c2ccb --- /dev/null +++ b/.ai/outputs/codebase.txt @@ -0,0 +1,4694 @@ + - Kuddle.Net + - AST + - Exceptions + - Extensions + - Parser + - Serialization + - Attributes + - Validation + - Kuddle.Net.Benchmarks + +# --- 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; + } + } + } + } + + public bool HasChildren => Children != null && Children.Nodes!.Count > 0; +} + +``` +// 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, bool enableDebug = false) + { +#if DEBUG + if (enableDebug) + return new DebugParser(parser, name); + else + return parser; +#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; + +namespace Kuddle.Serialization; + +internal static class TypeExtensions +{ + extension(Type type) + { + 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 bool IsKdlScalar => + type.IsPrimitive + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(Guid); + + public Type? GetCollectionElementType() + { + if (type == typeof(string)) + return null; + // Array + if (type.IsArray) + return type.GetElementType(); + + // IEnumerable + var enumerable = type.GetInterfaces() + .Append(type) + .FirstOrDefault(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) + ); + + if (enumerable != null) + return enumerable.GetGenericArguments()[0]; + + // Not a collection + return null; + } + } +} + +``` +// 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\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; + +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; + var elementType = property.PropertyType.GetCollectionElementType(); + IsCollection = elementType != null; + if (IsDictionary && elementType != null) + { + DictionaryKeyProperty = elementType.GetProperty("Key"); + DictionaryValueProperty = elementType.GetProperty("Value"); + } + ElementType = elementType; + } + + 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); +} + +``` +// 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 = KdlTypeMapping.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 +} + +``` +// 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\KdlTypeMapping.cs`$langusing 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(); + } + + 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 + 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; + + 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()), + }; + + /// + /// 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; + } +} + +``` +// 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. + /// + [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. + /// + 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; +} + +``` +// 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 System.Security.Cryptography.X509Certificates; +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); + + var mapping = KdlTypeMapping.For(); + var instance = new T(); + + if (mapping.Arguments.Count > 0 || mapping.Properties.Count > 0) + { + 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(matches[0], instance, mapping); + } + else + { + // 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 = KdlTypeMapping.For(); + ValidateNodeName(node, metadata.NodeName); + + var instance = new T(); + worker.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, KdlTypeMapping mapping) + { + foreach (var map in mapping.Arguments) + { + var kdlValue = node.Arg(map.ArgumentIndex); + if (kdlValue != null) + { + var val = KdlValueConverter.FromKdlOrThrow( + kdlValue, + map.Property.PropertyType, + map.KdlName, + map.TypeAnnotation + ); + map.SetValue(instance, val); + } + } + + foreach (var map in mapping.Properties) + { + var kdlValue = node.Prop(map.KdlName); + if (kdlValue != null) + { + var val = KdlValueConverter.FromKdlOrThrow( + kdlValue, + map.Property.PropertyType, + map.KdlName, + map.TypeAnnotation + ); + map.SetValue(instance, val); + } + } + if (node.Children != null) + { + // 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 MapChildren(List? nodes, object instance, KdlTypeMapping mapping) + { + if (nodes is null || nodes.Count == 0) + return; + + foreach (var map in mapping.Children) + { + List matches = nodes + .Where(n => n.Name.Value.Equals(map.KdlName, NodeNameComparison)) + .ToList(); + + if (matches is null || matches.Count == 0) + continue; + + 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 + ); + } + } + else if (map.IsCollection) + { + KdlNode container = matches.Last(); + + List nodesToProcess = container.HasChildren + ? container.Children?.Nodes! + : matches; + + PopulateCollection(instance, nodesToProcess, map); + } + else + { + 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); + } + } + } + + private void PopulateCollection( + object parentInstance, + IEnumerable nodes, + KdlMemberMap map + ) + { + var list = CreateList(map.ElementType!); + var elementMapping = KdlTypeMapping.For(map.ElementType!); + + foreach (var node in nodes) + { + // If the element name matches (or we are in a wrapped block), deserialize it + object? item; + if (map.ElementType!.IsKdlScalar) + { + var kdlVal = node.Arg(0); + item = + kdlVal != null + ? KdlValueConverter.FromKdlOrThrow( + kdlVal, + map.ElementType!, + node.Name.Value + ) + : null; + } + else + { + item = DeserializeObject(node, map.ElementType!); + } + + if (item != null) + list.Add(item); + } + + map.SetValue( + parentInstance, + ConvertCollection(list, map.Property.PropertyType, map.ElementType!) + ); + } + + private void PopulateDictionary( + IDictionary dict, + IEnumerable nodes, + Type keyType, + Type valueType + ) + { + foreach (var node in nodes) + { + object key = Convert.ChangeType(node.Name.Value, keyType); + object? value; + + if (valueType.IsKdlScalar) + { + var arg = node.Arg(0); + value = + arg != null + ? KdlValueConverter.FromKdlOrThrow(arg, valueType, key.ToString()!) + : null; + } + else + { + value = DeserializeObject(node, valueType); + } + + if (value != null) + dict[key] = value; + } + } + + private object DeserializeObject(KdlNode node, Type type) + { + if (type.IsKdlScalar) { } + var instance = + Activator.CreateInstance(type) + ?? throw new KuddleSerializationException( + $"Failed to create instance of '{type.Name}'." + ); + + MapNodeToObject(node, instance, KdlTypeMapping.For(type)); + return instance; + } + + 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 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) + { + var array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + return array; + } + return list; // Assuming List is compatible with target (IEnumerable/IReadOnlyList) + } +} + +``` +// File: src\Kuddle.Net\Serialization\ObjectSerializer.cs`$langusing System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +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); + + if (type.IsKdlScalar) + { + throw new KuddleSerializationException( + $"Cannot serialize primitive type '{type.Name}'. Only complex types are supported." + ); + } + + var doc = new KdlDocument(); + + // 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) + { + foreach (var item in enumerable) + { + if (item != null) + doc.Nodes.Add(worker.SerializeObject(item)); + } + } + else + { + doc.Nodes.Add(worker.SerializeObject(instance)); + } + + return doc; + } + + 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) + { + var val = KdlValueConverter.ToKdlOrThrow(map.GetValue(instance), map.TypeAnnotation); + node.Entries.Add(new KdlArgument(val)); + } + + 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) + { + var childData = map.GetValue(instance); + if (childData is null) + continue; + + 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); + } + else if (map.IsCollection && childData is IEnumerable childCol) + { + childNodes.AddRange(SerializeCollection(childCol, map)); + } + else + { + 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; + } + + private IEnumerable SerializeCollection(IEnumerable enumerable, KdlMemberMap map) + { + foreach (var item in enumerable) + { + if (item is null) + continue; + + yield return MapToNode(item, map.KdlName, map.TypeAnnotation); + } + } + + 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)] }; + } + else + { + return SerializeObject(item, kdlName); + } + } + + private IEnumerable SerializeDictionary( + IEnumerable dict, + PropertyInfo? keyProp, + PropertyInfo? valProp, + string? typeAnno = null + ) + { + foreach (var item in dict) + { + var key = keyProp?.GetValue(item); + var val = valProp?.GetValue(item); + if (key == null || val == null) + continue; + + yield return MapToNode(val, key.ToString()!, typeAnno); + } + } +} + +``` +// 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; } + } +} + +``` diff --git a/.github/codebase.ps1 b/.github/codebase.ps1 new file mode 100644 index 0000000..5ec40a3 --- /dev/null +++ b/.github/codebase.ps1 @@ -0,0 +1,71 @@ +# 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 | Where-Object { + (-not $_.FullName.Contains('Tests')) +} | 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 | Where-Object { + -not $_.DirectoryName.ToLower().Contains('tests') +} + +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 +} diff --git a/.github/workflows/prerelease-nuget.yml b/.github/workflows/prerelease-nuget.yml new file mode 100644 index 0000000..9510c27 --- /dev/null +++ b/.github/workflows/prerelease-nuget.yml @@ -0,0 +1,28 @@ +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 + 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 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 9b9a544..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 @@ -424,30 +550,9 @@ 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 -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. 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 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/DocumentToObjectTests.cs b/src/Kuddle.Net.Tests/Serialization/DocumentToObjectTests.cs index b2555d2..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")] @@ -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/KdlTypeInfoTests.cs b/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs new file mode 100644 index 0000000..9a7a1f9 --- /dev/null +++ b/src/Kuddle.Net.Tests/Serialization/KdlTypeInfoTests.cs @@ -0,0 +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(() => +// 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.Tests/Serialization/Models/AppSettings.cs b/src/Kuddle.Net.Tests/Serialization/Models/AppSettings.cs new file mode 100644 index 0000000..0b8f0aa --- /dev/null +++ b/src/Kuddle.Net.Tests/Serialization/Models/AppSettings.cs @@ -0,0 +1,82 @@ +using Kuddle.Serialization; + +namespace Kuddle.Tests.Serialization.Models; + +public class AppSettings +{ + [KdlNode("themes")] + public Dictionary Themes { get; set; } = new(); + + [KdlNode("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/Models/TelemetrySnapshot.cs b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs new file mode 100644 index 0000000..3c54465 --- /dev/null +++ b/src/Kuddle.Net.Tests/Serialization/Models/TelemetrySnapshot.cs @@ -0,0 +1,183 @@ +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(); + + 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; } +} + +[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, +} 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 718408c..67705aa 100644 --- a/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs +++ b/src/Kuddle.Net.Tests/Serialization/ObjectMapperTests.cs @@ -1,5 +1,8 @@ +using System.Diagnostics; using Kuddle.AST; +using Kuddle.Exceptions; using Kuddle.Serialization; +using Kuddle.Tests.Serialization.Models; namespace Kuddle.Tests.Serialization; @@ -354,44 +357,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,36 +442,63 @@ await Assert } [Test] - public async Task DeserializeToScalar_WithMultipleRootNodes_ThrowsException() + public async Task DeserializeToDictionary_MapsCorrectly() { // Arrange var kdl = """ - package "my-lib" version="1.0.0" - package "my-dep1" version="2.1.0" - package "my-dep2" version="3.2.1" - """; +// 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" + } + } +} - // Act & Assert - await Assert - .That(async () => KdlSerializer.Deserialize(kdl)) - .Throws(); +// 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 + } } +} +"""; - [Test] - public async Task DeserializeToDictionary_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 + var result = KdlSerializer.Deserialize(kdl); - // Act & Assert - await Assert - .That(() => KdlSerializer.Deserialize>(kdl)) - .Throws() - .WithMessageContaining("not supported"); + // 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 == "grid"); + await Assert.That(contentArea!.Ratio).IsEqualTo(3); } [Test] @@ -539,22 +531,8 @@ await Assert .Throws(); } - [Test] - public async Task DeserializeObject_WithMissingRequiredArgument_ThrowsException() - { - // Arrange - var kdl = """ - package - """; - - // Act & Assert - await Assert - .That(async () => KdlSerializer.Deserialize(kdl)) - .Throws(); - } - // [Test] - // public async Task DeserializeObject_WhenTargetIsSimpleValue_ThrowsException() + // public async Task DeserializeObject_WithMissingRequiredArgument_ThrowsException() // { // // Arrange // var kdl = """ @@ -563,13 +541,174 @@ await Assert // // Act & Assert // await Assert - // .That(async () => KdlSerializer.Deserialize(kdl)) - // .Throws() - // .WithMessageContaining("Cannot deserialize type"); + // .That(async () => KdlSerializer.Deserialize(kdl)) + // .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); + } + + 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 + await Assert.That(kdl).Contains("env=production"); + await Assert.That(kdl).Contains("region=us-east-1"); + + await Assert.That(kdl).Contains("setting:timeout=5000"); + await Assert.That(kdl).Contains("setting:retries=3"); + + 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() + { + await Assert + .That(() => KdlTypeMapping.For()) + .Throws(); + } + + public class CollectionModel + { + [KdlNode("plugins", Flatten = false)] + 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 + 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 + public class HybridModel : Dictionary + { + [KdlProperty("version")] + public string Version { get; set; } = "1.0"; + } /// Simple class with properties and arguments. public class Package @@ -593,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.Tests/Serialization/TelemetrySnapshotTests.cs b/src/Kuddle.Net.Tests/Serialization/TelemetrySnapshotTests.cs new file mode 100644 index 0000000..3eef997 --- /dev/null +++ b/src/Kuddle.Net.Tests/Serialization/TelemetrySnapshotTests.cs @@ -0,0 +1,109 @@ +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", + }, + }, + Metadata = { ["a"] = "one", ["b"] = "two" }, + }; + + 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.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/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/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/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/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 5aa58a6..734fb07 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,23 +9,9 @@ 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() + type.GetInterfaces() + .Append(type) .Any(i => i.IsGenericType && ( @@ -40,40 +25,38 @@ 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 bool IsKdlScalar => + type.IsPrimitive + || type.IsEnum + || type == typeof(string) + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset) + || type == typeof(Guid) + || type == typeof(TimeSpan) + || type == typeof(DateOnly) + || type == typeof(TimeOnly); + + public Type? GetCollectionElementType() + { + if (type == typeof(string)) + return null; + // Array + if (type.IsArray) + return type.GetElementType(); + + // IEnumerable + var enumerable = type.GetInterfaces() + .Append(type) + .FirstOrDefault(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) + ); - 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(); + if (enumerable != null) + return enumerable.GetGenericArguments()[0]; - public bool IsNullable() => Nullable.GetUnderlyingType(type) != null; + // Not a collection + return null; + } } } 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 @@ - 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/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..bcf9f44 100644 --- a/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs +++ b/src/Kuddle.Net/Serialization/Attributes/KdlNodeAttribute.cs @@ -3,8 +3,9 @@ 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 string? ElementName { get; init; } = null; + public bool Flatten { get; set; } } diff --git a/src/Kuddle.Net/Serialization/Attributes/KdlNodeDictionaryAttribute.cs b/src/Kuddle.Net/Serialization/Attributes/KdlNodeDictionaryAttribute.cs new file mode 100644 index 0000000..9ec6c49 --- /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/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/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/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 new file mode 100644 index 0000000..2d1e24e --- /dev/null +++ b/src/Kuddle.Net/Serialization/KdlMemberMap.cs @@ -0,0 +1,52 @@ +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, + bool isFlattened = false, + string? collectionElementName = null + ) + { + Property = property; + Kind = kind; + KdlName = kdlName; + ArgumentIndex = argumentIndex; + TypeAnnotation = typeAnnotation; + IsFlattened = isFlattened; + ElementName = collectionElementName; + 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 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 bool IsFlattened { get; } + public string? ElementName { 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 388eab8..27fdabd 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 = KdlTypeMapping.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,242 +57,35 @@ 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); - } - - 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 metadata = TypeMetadata.For(); - var doc = new KdlDocument(); - - foreach (var item in items) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (item is null) - { - continue; - } - - var node = SerializeToNode(item); - doc.Nodes.Add(node); - } - + var doc = ObjectSerializer.SerializeDocument(instance, options); 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; + // /// + // /// Serializes multiple objects to a KDL string. + // /// + // public static string SerializeMany( + // IEnumerable items, + // KdlSerializerOptions? options = null, + // CancellationToken cancellationToken = default + // ) + // { + // ArgumentNullException.ThrowIfNull(items); - var entries = new List(); + // var doc = new KdlDocument(); - // 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); + // foreach (var item in items) + // { + // cancellationToken.ThrowIfCancellationRequested(); + // if (item is null) + // continue; - if (propValue is null) - { - continue; - } + // var node = ObjectSerializer.SerializeNode(item, options); + // doc.Nodes.Add(node); + // } - 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; - } + // return KdlWriter.Write(doc); + // } #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/KdlTypeMapping.cs b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs new file mode 100644 index 0000000..eaf7505 --- /dev/null +++ b/src/Kuddle.Net/Serialization/KdlTypeMapping.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +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(); + } + + 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; } + public bool HasMembers => Arguments.Count > 0 || Properties.Count > 0 || Children.Count > 0; + + 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); + + 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 static 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, + prop.PropertyType.IsDictionary + ? (p.Key ?? string.Empty) + : (p.Key ?? prop.Name.ToKebabCase()), + -1, + typeAnnotation + ), + KdlNodeAttribute n => new KdlMemberMap( + prop, + KdlMemberKind.ChildNode, + n.Name ?? prop.Name.ToKebabCase(), + -1, + typeAnnotation, + n.Flatten, + collectionElementName: n.ElementName + ), + + // 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 KdlNodeAttribute(prop.Name.ToKebabCase()), + { IsIEnumerable: true } => new KdlNodeAttribute(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 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 389bbab..8654a3a 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)) { @@ -163,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!, }; @@ -177,7 +185,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,23 +202,19 @@ 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(); } /// /// Converts a CLR value to a KDL value, throwing on failure. /// - public static KdlValue ToKdlOrThrow( - object? input, - string context, - string? typeAnnotation = null - ) + 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. {context}" + $"Cannot convert CLR value of type '{typeName}' to KDL." ); } return kdlValue; 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..6dff304 --- /dev/null +++ b/src/Kuddle.Net/Serialization/ObjectDeserializer.cs @@ -0,0 +1,373 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +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); + + var mapping = KdlTypeMapping.For(); + var instance = new T(); + + if (mapping.Arguments.Count > 0 || mapping.Properties.Count > 0) + { + 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(matches[0], instance, mapping); + } + else + { + // 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 = KdlTypeMapping.For(); + ValidateNodeName(node, metadata.NodeName); + + var instance = new T(); + worker.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, KdlTypeMapping mapping) + { + foreach (var map in mapping.Arguments) + { + var kdlValue = node.Arg(map.ArgumentIndex); + if (kdlValue != null) + { + var val = KdlValueConverter.FromKdlOrThrow( + kdlValue, + map.Property.PropertyType, + map.KdlName, + map.TypeAnnotation + ); + map.SetValue(instance, val); + } + } + 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) + { + var val = KdlValueConverter.FromKdlOrThrow( + kdlValue, + map.Property.PropertyType, + map.KdlName, + 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) + { + // 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, + dictionaryNodes, + mapping.DictionaryKeyProperty!.PropertyType, + mapping.DictionaryValueProperty!.PropertyType + ); + } + MapChildren(node.Children.Nodes, instance, mapping); + } + } + + /// + /// Maps child KDL nodes to properties marked with [KdlNode]. + /// + private void MapChildren(List? nodes, object instance, KdlTypeMapping mapping) + { + if (nodes is null || nodes.Count == 0) + return; + + foreach (var map in mapping.Children) + { + List matches = nodes + .Where(n => n.Name.Value.Equals(map.KdlName, NodeNameComparison)) + .ToList(); + + if (matches is null || matches.Count == 0) + continue; + + List nodesToProcess = map.IsFlattened + ? matches + : matches[^1].Children?.Nodes ?? []; + + if (map.IsDictionary) + { + var dict = EnsureInstance(instance, map) as IDictionary; + PopulateDictionary( + dict!, + nodesToProcess, + map.DictionaryKeyProperty!.PropertyType, + map.DictionaryValueProperty!.PropertyType + ); + } + else if (map.IsCollection) + { + PopulateCollection(instance, nodesToProcess, map); + } + else + { + var last = matches.Last(); + object? value; + + if (map.Property.PropertyType.IsKdlScalar) + { + 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); + } + } + } + + private void PopulateCollection( + object parentInstance, + IEnumerable nodes, + KdlMemberMap map + ) + { + var list = CreateList(map.ElementType!); + var elementMapping = KdlTypeMapping.For(map.ElementType!); + + foreach (var node in nodes) + { + // If the element name matches (or we are in a wrapped block), deserialize it + object? item; + if (map.ElementType!.IsKdlScalar) + { + var kdlVal = node.Arg(0); + item = + kdlVal != null + ? KdlValueConverter.FromKdlOrThrow( + kdlVal, + map.ElementType!, + node.Name.Value + ) + : null; + } + else + { + item = DeserializeObject(node, map.ElementType!); + } + + if (item != null) + list.Add(item); + } + + map.SetValue( + parentInstance, + ConvertCollection(list, map.Property.PropertyType, map.ElementType!) + ); + } + + private void PopulateDictionary( + IDictionary dict, + IEnumerable nodes, + Type keyType, + Type valueType + ) + { + foreach (var node in nodes) + { + object key = Convert.ChangeType(node.Name.Value, keyType); + object? value; + + if (valueType.IsKdlScalar) + { + var arg = node.Arg(0); + value = + arg != null + ? KdlValueConverter.FromKdlOrThrow(arg, valueType, key.ToString()!) + : null; + } + else + { + value = DeserializeObject(node, valueType); + } + + if (value != null) + dict[key] = value; + } + } + + private object DeserializeObject(KdlNode node, Type type) + { + if (type.IsKdlScalar) { } + var instance = + Activator.CreateInstance(type) + ?? throw new KuddleSerializationException( + $"Failed to create instance of '{type.Name}'." + ); + + MapNodeToObject(node, instance, KdlTypeMapping.For(type)); + return instance; + } + + 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 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) + { + var array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + return array; + } + 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 new file mode 100644 index 0000000..7a7a557 --- /dev/null +++ b/src/Kuddle.Net/Serialization/ObjectSerializer.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +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); + + if (type.IsKdlScalar) + { + throw new KuddleSerializationException( + $"Cannot serialize primitive type '{type.Name}'. Only complex types are supported." + ); + } + + var doc = new KdlDocument(); + + // 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) + { + foreach (var item in enumerable) + { + if (item != null) + doc.Nodes.Add(worker.SerializeObject(item)); + } + } + else + { + doc.Nodes.Add(worker.SerializeObject(instance)); + } + + return doc; + } + + 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) + { + var val = KdlValueConverter.ToKdlOrThrow(map.GetValue(instance), map.TypeAnnotation); + node.Entries.Add(new KdlArgument(val)); + } + + foreach (var map in mapping.Properties) + { + var raw = map.GetValue(instance); + if (raw == null && _options.IgnoreNullValues) + continue; + + 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(); + foreach (var map in mapping.Children) + { + var childData = map.GetValue(instance); + if (childData is null) + continue; + + if (map.IsDictionary && childData is IEnumerable mapDict) + { + var items = SerializeDictionary( + mapDict, + map.DictionaryKeyProperty, + map.DictionaryValueProperty + ); + 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) + { + 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 + { + 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; + } + + private IEnumerable SerializeCollection(IEnumerable enumerable, KdlMemberMap map) + { + foreach (var item in enumerable) + { + if (item is null) + continue; + + 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) + { + var val = KdlValueConverter.ToKdlOrThrow(item, typeAnnotation); + return new KdlNode(KdlValue.From(kdlName)) { Entries = [new KdlArgument(val)] }; + } + else + { + return SerializeObject(item, kdlName); + } + } + + private IEnumerable SerializeDictionary( + IEnumerable dict, + PropertyInfo? keyProp, + PropertyInfo? valProp, + string? typeAnno = null + ) + { + foreach (var item in dict) + { + var key = keyProp?.GetValue(item); + var val = valProp?.GetValue(item); + if (key == null || val == null) + continue; + + yield return MapToNode(val, key.ToString()!, typeAnno); + } + } +} 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).