< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Utilities.Yaml.YamlTypeConverter
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Utilities/Yaml/YamlTypeConverter.cs
Tag: Kestrun/Kestrun@ca54e35c77799b76774b3805b6f075cdbc0c5fbe
Line coverage
87%
Covered lines: 165
Uncovered lines: 23
Coverable lines: 188
Total lines: 481
Line coverage: 87.7%
Branch coverage
82%
Covered branches: 128
Total branches: 156
Branch coverage: 82%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/13/2025 - 16:52:37 Line coverage: 75% (141/188) Branch coverage: 67.3% (105/156) Total lines: 481 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e01/15/2026 - 23:50:39 Line coverage: 87.7% (165/188) Branch coverage: 82% (128/156) Total lines: 481 Tag: Kestrun/Kestrun@2d823cb7ceae127151c8880ca073ffbb9c6322aa 10/13/2025 - 16:52:37 Line coverage: 75% (141/188) Branch coverage: 67.3% (105/156) Total lines: 481 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e01/15/2026 - 23:50:39 Line coverage: 87.7% (165/188) Branch coverage: 82% (128/156) Total lines: 481 Tag: Kestrun/Kestrun@2d823cb7ceae127151c8880ca073ffbb9c6322aa

Metrics

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Utilities/Yaml/YamlTypeConverter.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Numerics;
 3using System.Text.RegularExpressions;
 4using YamlDotNet.RepresentationModel;
 5using YamlDotNet.Core;
 6using System.Collections.Specialized;
 7
 8namespace Kestrun.Utilities.Yaml;
 9
 10/// <summary>
 11/// Utility class for converting YAML nodes to appropriate .NET types
 12/// </summary>
 13public static partial class YamlTypeConverter
 14{
 15    // YAML 1.2: .inf, +.inf, -.inf, and permissive "inf" / "infinity"
 116    private static readonly Regex InfinityRegex =
 117        MyRegex();
 18    // Integers (promote via BigInteger, then downcast if safe)
 19    // Uses cached BigInteger boundary constants (declare once at class scope):
 120    private static readonly BigInteger IntMinBig = new(int.MinValue);
 121    private static readonly BigInteger IntMaxBig = new(int.MaxValue);
 122    private static readonly BigInteger LongMinBig = new(long.MinValue);
 123    private static readonly BigInteger LongMaxBig = new(long.MaxValue);
 24
 25    /// <summary>
 26    /// Convert a YamlNode to the most appropriate .NET type based on its tag and content
 27    /// </summary>
 28    /// <param name="node">The YAML node to convert.</param>
 29    /// <returns>The converted .NET object, or null if the node is null.</returns>
 30    /// <exception cref="FormatException">Thrown when the node's value cannot be converted to the appropriate type.</exc
 31    public static object? ConvertValueToProperType(YamlNode node)
 32    {
 6233        if (node is not YamlScalarNode scalar)
 34        {
 135            return node; // non-scalar passthrough
 36        }
 37
 6138        var tag = GetSafeTagValue(scalar);
 6139        var value = scalar.Value;
 6140        if (value is null)
 41        {
 042            return scalar; // null scalar value
 43        }
 44
 6145        if (!string.IsNullOrEmpty(tag))
 46        {
 3447            var tagged = TryParseTaggedScalar(tag, value);
 3248            if (tagged.parsed)
 49            {
 3250                return tagged.value;
 51            }
 52        }
 53
 2754        if (scalar.Style == ScalarStyle.Plain)
 55        {
 2356            var plain = TryParsePlainScalar(value);
 2357            if (plain.parsed)
 58            {
 1459                return plain.value;
 60            }
 61
 962            if (IsExplicitNullToken(value))
 63            {
 864                return null;
 65            }
 66        }
 67
 568        return value; // fallback string
 69    }
 70
 71    /// <summary>Safely obtains a scalar tag string or null when non-specific/unavailable.</summary>
 72    private static string? GetSafeTagValue(YamlScalarNode scalar)
 73    {
 74        try
 75        {
 6176            if (!scalar.Tag.IsEmpty && !scalar.Tag.IsNonSpecific)
 77            {
 3478                return scalar.Tag.Value;
 79            }
 2780        }
 081        catch
 82        {
 83            // ignore and return null
 084        }
 2785        return null;
 3486    }
 87
 88    /// <summary>Attempts to parse a tagged scalar. Returns (parsed=false) if tag unrecognized.</summary>
 89    private static (bool parsed, object? value) TryParseTaggedScalar(string tag, string value)
 90    {
 91        switch (tag)
 92        {
 93            case "tag:yaml.org,2002:str":
 194                return (true, value);
 95            case "tag:yaml.org,2002:null":
 296                return (true, null);
 97            case "tag:yaml.org,2002:bool":
 398                if (bool.TryParse(value, out var b))
 99                {
 2100                    return (true, b);
 101                }
 1102                throw new FormatException($"Failed to parse scalar '{value}' as boolean.");
 103            case "tag:yaml.org,2002:int":
 15104                return (true, ParseYamlInt(value));
 105            case "tag:yaml.org,2002:float":
 10106                return (true, ParseTaggedFloat(value));
 107            case "tag:yaml.org,2002:timestamp":
 3108                return (true, ParseTaggedTimestamp(value));
 109            default:
 0110                return (false, null);
 111        }
 112    }
 113
 114    /// <summary>Parses a YAML float (tagged) honoring infinity tokens, else decimal.</summary>
 115    private static object ParseTaggedFloat(string value)
 116    {
 10117        return InfinityRegex.IsMatch(value)
 10118            ? value.StartsWith('-') ? double.NegativeInfinity : double.PositiveInfinity
 10119            : decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var dec)
 10120            ? (object)dec
 10121            : throw new FormatException($"Failed to parse scalar '{value}' as decimal.");
 122    }
 123
 124    /// <summary>
 125    /// Parses a YAML timestamp preserving unspecified semantics or converting to local DateTime for zone-aware values.
 126    /// </summary>
 127    /// <param name="value">The YAML timestamp value to parse.</param>
 128    private static object ParseTaggedTimestamp(string value)
 129    {
 3130        var hasTime = value.Contains(':');
 3131        var hasExplicitZone = hasTime && (value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || MyRegex1().IsMatch(
 3132        return hasExplicitZone && DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripK
 3133            ? dto.LocalDateTime
 3134            : DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var naive)
 3135            ? (object)DateTime.SpecifyKind(naive, DateTimeKind.Unspecified)
 3136            : throw new FormatException($"Failed to parse scalar '{value}' as DateTime.");
 137    }
 138
 139    /// <summary>Attempts plain-style heuristic parsing for bool/int/float. Returns (parsed=false) if none match.</summa
 140    private static (bool parsed, object? value) TryParsePlainScalar(string value)
 141    {
 23142        if (bool.TryParse(value, out var bPlain))
 143        {
 2144            return (true, bPlain);
 145        }
 21146        if (TryParseBigInteger(value, out var bigInt))
 147        {
 148#pragma warning disable IDE0078
 9149            if (bigInt >= IntMinBig && bigInt <= IntMaxBig)
 150            {
 8151                return (true, (int)bigInt);
 152            }
 1153            if (bigInt >= LongMinBig && bigInt <= LongMaxBig)
 154            {
 0155                return (true, (long)bigInt);
 156            }
 157#pragma warning restore IDE0078
 1158            return (true, bigInt);
 159        }
 12160        if (decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var dPlain))
 161        {
 3162            return (true, dPlain);
 163        }
 9164        if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var dblPlain))
 165        {
 0166            return (true, dblPlain);
 167        }
 9168        return (false, null);
 169    }
 170
 171    /// <summary>Determines if a plain scalar is an explicit YAML null token.</summary>
 172    private static bool IsExplicitNullToken(string value)
 9173        => value == string.Empty || value == "~" || value == "$null" || value == "''" ||
 9174           string.Equals(value, "null", StringComparison.Ordinal) ||
 9175           string.Equals(value, "Null", StringComparison.Ordinal) ||
 9176           string.Equals(value, "NULL", StringComparison.Ordinal);
 177
 178    /// <summary>
 179    /// Parse a YAML integer scalar, handling base prefixes (0o, 0x) and scientific notation (e.g., 1e3).
 180    /// </summary>
 181    /// <param name="value">The YAML integer scalar to parse.</param>
 182    /// <returns>The parsed integer value.</returns>
 183    /// <exception cref="FormatException">Thrown when the input is not a valid YAML integer.</exception>
 184    private static object ParseYamlInt(string value)
 185    {
 186        // 1. Scientific notation branch
 15187        if (ScientificIntRegex().IsMatch(value))
 188        {
 5189            return ParseScientificInteger(value);
 190        }
 191
 192        // 2. Base-prefixed branch (octal / hex)
 10193        if (TryParseBasePrefixedInteger(value, out var basePrefixed))
 194        {
 4195            return basePrefixed;
 196        }
 197
 198        // 3. Generic integer (BigInteger + downcast)
 6199        return ParseGenericInteger(value);
 200    }
 201
 202    /// <summary>
 203    /// Parse a scientific-notation integer (mantissa * 10^exp) ensuring exponent is non-negative and result integral.
 204    /// Mirrors previous behavior including exception wrapping.
 205    /// </summary>
 206    private static object ParseScientificInteger(string value)
 207    {
 208        try
 209        {
 5210            var (mantissa, sign, exp) = SplitScientificParts(value);
 5211            if (exp < 0)
 212            {
 1213                throw new FormatException($"Failed to parse scalar '{value}' as integer (negative exponent not integral)
 214            }
 4215            var pow = Pow10(exp);
 4216            var bigVal = mantissa * pow * sign;
 4217            return DowncastBigInteger(bigVal);
 218        }
 1219        catch (Exception ex)
 220        {
 1221            throw new FormatException($"Failed to parse scalar '{value}' as integer (scientific).", ex);
 222        }
 4223    }
 224
 225    /// <summary>
 226    /// Splits a scientific notation string into mantissa BigInteger, sign (+/-1), and exponent.
 227    /// </summary>
 228    /// <param name="value">The scientific notation string (e.g., "1.23e+3").</param>
 229    /// <returns>A tuple containing the mantissa as BigInteger, the sign as int, and the exponent as int.</returns>
 230    private static (BigInteger mantissa, int sign, int exp) SplitScientificParts(string value)
 231    {
 5232        var parts = value.Split('e', 'E');
 5233        var mantissaStr = parts[0];
 5234        var expStr = parts[1];
 5235        var exp = int.Parse(expStr, CultureInfo.InvariantCulture);
 5236        var sign = 1;
 5237        if (mantissaStr.StartsWith('+'))
 238        {
 0239            mantissaStr = mantissaStr[1..];
 240        }
 5241        else if (mantissaStr.StartsWith('-'))
 242        {
 1243            sign = -1;
 1244            mantissaStr = mantissaStr[1..];
 245        }
 5246        if (mantissaStr.Trim('0').Length == 0)
 247        {
 0248            mantissaStr = "0"; // normalize all-zero mantissa
 249        }
 5250        return !BigInteger.TryParse(mantissaStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var mantissa)
 5251            ? throw new FormatException($"Failed to parse scalar '{value}' as integer (mantissa invalid).")
 5252            : (mantissa, sign, exp);
 253    }
 254
 255    /// <summary>
 256    /// Attempts to parse octal (0o) or hexadecimal (0x) integer representations, returning true if handled.
 257    /// </summary>
 258    /// <param name="value">The input string to parse.</param>
 259    /// <param name="result">The parsed integer value.</param>
 260    /// <returns>True if the input was successfully parsed as a base-prefixed integer; otherwise, false.</returns>
 261    private static bool TryParseBasePrefixedInteger(string value, out object result)
 262    {
 10263        result = default!;
 10264        if (value.Length <= 2)
 265        {
 1266            return false;
 267        }
 9268        var prefix = value[..2];
 9269        if (prefix.Equals("0o", StringComparison.OrdinalIgnoreCase))
 270        {
 2271            var asLong = Convert.ToInt64(value[2..], 8);
 2272            result = DowncastInteger(asLong);
 2273            return true;
 274        }
 7275        if (prefix.Equals("0x", StringComparison.OrdinalIgnoreCase))
 276        {
 2277            var asLong = Convert.ToInt64(value[2..], 16);
 2278            result = DowncastInteger(asLong);
 2279            return true;
 280        }
 5281        return false;
 282    }
 283
 284    /// <summary>
 285    /// Generic integer parsing path using BigInteger with downcasting. Throws FormatException on failure.
 286    /// </summary>
 287    /// <param name="value">The input string to parse.</param>
 288    /// <returns>The parsed integer value.</returns>
 289    /// <exception cref="FormatException">Thrown when the input is not a valid integer.</exception>
 290    private static object ParseGenericInteger(string value)
 291    {
 6292        return !TryParseBigInteger(value, out var big)
 6293            ? throw new FormatException($"Failed to parse scalar '{value}' as integer.")
 6294            : DowncastBigInteger(big);
 295    }
 296
 297    /// <summary>
 298    /// Downcasts a BigInteger to int or long when within range; otherwise returns the original BigInteger.
 299    /// </summary>
 300    /// <param name="bigVal">The BigInteger value to downcast.</param>
 301    /// <returns>The downcasted integer value or the original BigInteger if out of range.</returns>
 302    private static object DowncastBigInteger(BigInteger bigVal)
 303    {
 304#pragma warning disable IDE0078
 10305        if (bigVal >= int.MinValue && bigVal <= int.MaxValue)
 306        {
 5307            return (int)bigVal;
 308        }
 5309        if (bigVal >= long.MinValue && bigVal <= long.MaxValue)
 310        {
 2311            return (long)bigVal;
 312        }
 313#pragma warning restore IDE0078
 3314        return bigVal;
 315    }
 316
 317    /// <summary>
 318    /// Attempts to parse a string as a BigInteger, including support for scientific notation with non-negative exponent
 319    /// </summary>
 320    /// <param name="s">The input string to parse.</param>
 321    /// <param name="result">The parsed BigInteger value.</param>
 322    /// <returns>True if the parsing was successful; otherwise, false.</returns>
 323    private static bool TryParseBigInteger(string s, out BigInteger result)
 324    {
 325        // Recognize scientific integer in plain style (e.g., 1e+3) where exponent >= 0.
 27326        if (ScientificIntRegex().IsMatch(s))
 327        {
 328            try
 329            {
 2330                var parts = s.Split('e', 'E');
 2331                var mantissaStr = parts[0];
 2332                var expStr = parts[1];
 2333                var exp = int.Parse(expStr, CultureInfo.InvariantCulture);
 2334                if (exp < 0)
 335                {
 1336                    result = default;
 1337                    return false; // would be fractional
 338                }
 1339                var sign = 1;
 1340                if (mantissaStr.StartsWith('+'))
 341                {
 0342                    mantissaStr = mantissaStr[1..];
 343                }
 1344                else if (mantissaStr.StartsWith('-'))
 345                {
 0346                    sign = -1;
 0347                    mantissaStr = mantissaStr[1..];
 348                }
 1349                if (!BigInteger.TryParse(mantissaStr, NumberStyles.Integer | NumberStyles.AllowLeadingSign, CultureInfo.
 350                {
 0351                    result = default;
 0352                    return false;
 353                }
 1354                var pow = Pow10(exp);
 1355                result = mantissa * pow * sign;
 1356                return true;
 357            }
 0358            catch
 359            {
 360                // fallthrough to normal parse
 0361            }
 362        }
 363        // Mirror PS code using Float|Integer flags (allow underscores not by default—YamlDotNet usually strips them)
 25364        return BigInteger.TryParse(
 25365            s,
 25366            NumberStyles.Integer | NumberStyles.AllowLeadingSign,
 25367            CultureInfo.InvariantCulture,
 25368            out result
 25369        );
 2370    }
 371
 4372    private static object DowncastInteger(long v) => v is >= int.MinValue and <= int.MaxValue ? (int)v : (object)v;
 373
 374    /// <summary>
 375    /// Convert a YamlMappingNode to a dictionary. If <paramref name="ordered"/> is true,
 376    /// returns an OrderedDictionary; otherwise a Dictionary&lt;string,object?&gt;.
 377    /// </summary>
 378    public static object ConvertYamlMappingToHashtable(YamlMappingNode node, bool ordered = false)
 379    {
 5380        if (ordered)
 381        {
 1382            var ret = new OrderedDictionary(StringComparer.Ordinal);
 6383            foreach (var kv in node.Children)
 384            {
 2385                var key = KeyToString(kv.Key);
 2386                var val = key == "datesAsStrings" && kv.Value is YamlSequenceNode seq
 0387                    ? seq.Children.Select(c => c is YamlScalarNode s ? s.Value : c.ToString()).ToArray()
 2388                    : ConvertYamlDocumentToPSObject(kv.Value, ordered);
 2389                ret[key] = val;
 390            }
 1391            return ret;
 392        }
 393        else
 394        {
 4395            var ret = new Dictionary<string, object?>(StringComparer.Ordinal);
 24396            foreach (var kv in node.Children)
 397            {
 8398                var key = KeyToString(kv.Key);
 8399                ret[key] = key == "datesAsStrings" && kv.Value is YamlSequenceNode seq
 2400                    ? seq.Children.Select(c => c is YamlScalarNode s ? s.Value : c.ToString()).ToArray()
 8401                    : ConvertYamlDocumentToPSObject(kv.Value, ordered);
 402            }
 4403            return ret;
 404        }
 405    }
 406
 407    /// <summary>
 408    /// Convert a YamlSequenceNode to an array (object?[]), preserving element order.
 409    /// </summary>
 410    public static object?[] ConvertYamlSequenceToArray(YamlSequenceNode node, bool ordered = false)
 411    {
 0412        var list = new List<object?>(node.Children.Count);
 0413        foreach (var child in node.Children)
 414        {
 0415            list.Add(ConvertYamlDocumentToPSObject(child, ordered));
 416        }
 0417        return [.. list];
 418    }
 419
 420    /// <summary>
 421    /// Dispatcher that mirrors Convert-YamlDocumentToPSObject:
 422    /// maps Mapping→Hashtable/(OrderedDictionary), Sequence→array, Scalar→typed value.
 423    /// </summary>
 424    public static object? ConvertYamlDocumentToPSObject(YamlNode node, bool ordered = false) =>
 13425        node switch
 13426        {
 5427            YamlMappingNode m => ConvertYamlMappingToHashtable(m, ordered),
 0428            YamlSequenceNode s => ConvertYamlSequenceToArray(s, ordered),
 8429            YamlScalarNode => ConvertValueToProperType(node),
 0430            _ => node // fallback: return the node itself
 13431        };
 432
 433    /// <summary>
 434    /// Convenience overload to defensively handle scenarios where (due to dynamic invocation from PowerShell)
 435    /// a KeyValuePair&lt;YamlNode,YamlNode&gt; is passed instead of just the Value node. We unwrap and delegate.
 436    /// This should not normally be necessary, but avoids brittle failures when reflection / dynamic binding
 437    /// mis-identifies the argument type.
 438    /// </summary>
 439    public static object? ConvertYamlDocumentToPSObject(KeyValuePair<YamlNode, YamlNode> pair, bool ordered = false)
 1440        => ConvertYamlDocumentToPSObject(pair.Value, ordered);
 441
 442    private static string KeyToString(YamlNode keyNode)
 443    {
 444        // PowerShell code uses $i.Value; in YAML, keys are typically scalars.
 10445        if (keyNode is YamlScalarNode sk && sk.Value is not null)
 446        {
 10447            return sk.Value;
 448        }
 449
 450        // Fallback: ToString() so we don't throw on exotic keys.
 0451        return keyNode.ToString() ?? string.Empty;
 452    }
 453
 454    [GeneratedRegex(@"^[\+\-]?(?:\.?inf(?:inity)?)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
 455    private static partial Regex MyRegex();
 456
 457    [GeneratedRegex(@"^[\+\-]?\d+[eE][\+\-]?\d+$", RegexOptions.CultureInvariant)]
 458    private static partial Regex ScientificIntRegex();
 459
 460    private static BigInteger Pow10(int exp)
 461    {
 462        // Efficient power-of-ten computation without double rounding
 463        // Use exponentiation by squaring with base 10 as BigInteger
 5464        var result = BigInteger.One;
 5465        var baseVal = new BigInteger(10);
 5466        var e = exp;
 15467        while (e > 0)
 468        {
 10469            if ((e & 1) == 1)
 470            {
 9471                result *= baseVal;
 472            }
 10473            baseVal *= baseVal;
 10474            e >>= 1;
 475        }
 5476        return result;
 477    }
 478
 479    [GeneratedRegex(@"[\+\-]\d{1,2}(:?\d{2})?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
 480    private static partial Regex MyRegex1();
 481}