< 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@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
75%
Covered lines: 141
Uncovered lines: 47
Coverable lines: 188
Total lines: 481
Line coverage: 75%
Branch coverage
67%
Covered branches: 105
Total branches: 156
Branch coverage: 67.3%
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@10d476bee71c71ad215bb8ab59f219887b5b4a5e 10/13/2025 - 16:52:37 Line coverage: 75% (141/188) Branch coverage: 67.3% (105/156) Total lines: 481 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e

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    {
 3033        if (node is not YamlScalarNode scalar)
 34        {
 035            return node; // non-scalar passthrough
 36        }
 37
 3038        var tag = GetSafeTagValue(scalar);
 3039        var value = scalar.Value;
 3040        if (value is null)
 41        {
 042            return scalar; // null scalar value
 43        }
 44
 3045        if (!string.IsNullOrEmpty(tag))
 46        {
 1347            var tagged = TryParseTaggedScalar(tag, value);
 1348            if (tagged.parsed)
 49            {
 1350                return tagged.value;
 51            }
 52        }
 53
 1754        if (scalar.Style == ScalarStyle.Plain)
 55        {
 1756            var plain = TryParsePlainScalar(value);
 1757            if (plain.parsed)
 58            {
 1059                return plain.value;
 60            }
 61
 762            if (IsExplicitNullToken(value))
 63            {
 664                return null;
 65            }
 66        }
 67
 168        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        {
 3076            if (!scalar.Tag.IsEmpty && !scalar.Tag.IsNonSpecific)
 77            {
 1378                return scalar.Tag.Value;
 79            }
 1780        }
 081        catch
 82        {
 83            // ignore and return null
 084        }
 1785        return null;
 1386    }
 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":
 196                return (true, null);
 97            case "tag:yaml.org,2002:bool":
 198                if (bool.TryParse(value, out var b))
 99                {
 1100                    return (true, b);
 101                }
 0102                throw new FormatException($"Failed to parse scalar '{value}' as boolean.");
 103            case "tag:yaml.org,2002:int":
 7104                return (true, ParseYamlInt(value));
 105            case "tag:yaml.org,2002:float":
 2106                return (true, ParseTaggedFloat(value));
 107            case "tag:yaml.org,2002:timestamp":
 1108                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    {
 2117        return InfinityRegex.IsMatch(value)
 2118            ? value.StartsWith('-') ? double.NegativeInfinity : double.PositiveInfinity
 2119            : decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var dec)
 2120            ? (object)dec
 2121            : 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    {
 1130        var hasTime = value.Contains(':');
 1131        var hasExplicitZone = hasTime && (value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || MyRegex1().IsMatch(
 1132        return hasExplicitZone && DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripK
 1133            ? dto.LocalDateTime
 1134            : DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var naive)
 1135            ? (object)DateTime.SpecifyKind(naive, DateTimeKind.Unspecified)
 1136            : 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    {
 17142        if (bool.TryParse(value, out var bPlain))
 143        {
 1144            return (true, bPlain);
 145        }
 16146        if (TryParseBigInteger(value, out var bigInt))
 147        {
 148#pragma warning disable IDE0078
 7149            if (bigInt >= IntMinBig && bigInt <= IntMaxBig)
 150            {
 6151                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        }
 9160        if (decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var dPlain))
 161        {
 2162            return (true, dPlain);
 163        }
 7164        if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var dblPlain))
 165        {
 0166            return (true, dblPlain);
 167        }
 7168        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)
 7173        => value == string.Empty || value == "~" || value == "$null" || value == "''" ||
 7174           string.Equals(value, "null", StringComparison.Ordinal) ||
 7175           string.Equals(value, "Null", StringComparison.Ordinal) ||
 7176           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
 7187        if (ScientificIntRegex().IsMatch(value))
 188        {
 2189            return ParseScientificInteger(value);
 190        }
 191
 192        // 2. Base-prefixed branch (octal / hex)
 5193        if (TryParseBasePrefixedInteger(value, out var basePrefixed))
 194        {
 2195            return basePrefixed;
 196        }
 197
 198        // 3. Generic integer (BigInteger + downcast)
 3199        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        {
 2210            var (mantissa, sign, exp) = SplitScientificParts(value);
 2211            if (exp < 0)
 212            {
 0213                throw new FormatException($"Failed to parse scalar '{value}' as integer (negative exponent not integral)
 214            }
 2215            var pow = Pow10(exp);
 2216            var bigVal = mantissa * pow * sign;
 2217            return DowncastBigInteger(bigVal);
 218        }
 0219        catch (Exception ex)
 220        {
 0221            throw new FormatException($"Failed to parse scalar '{value}' as integer (scientific).", ex);
 222        }
 2223    }
 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    {
 2232        var parts = value.Split('e', 'E');
 2233        var mantissaStr = parts[0];
 2234        var expStr = parts[1];
 2235        var exp = int.Parse(expStr, CultureInfo.InvariantCulture);
 2236        var sign = 1;
 2237        if (mantissaStr.StartsWith('+'))
 238        {
 0239            mantissaStr = mantissaStr[1..];
 240        }
 2241        else if (mantissaStr.StartsWith('-'))
 242        {
 0243            sign = -1;
 0244            mantissaStr = mantissaStr[1..];
 245        }
 2246        if (mantissaStr.Trim('0').Length == 0)
 247        {
 0248            mantissaStr = "0"; // normalize all-zero mantissa
 249        }
 2250        return !BigInteger.TryParse(mantissaStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var mantissa)
 2251            ? throw new FormatException($"Failed to parse scalar '{value}' as integer (mantissa invalid).")
 2252            : (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    {
 5263        result = default!;
 5264        if (value.Length <= 2)
 265        {
 0266            return false;
 267        }
 5268        var prefix = value[..2];
 5269        if (prefix.Equals("0o", StringComparison.OrdinalIgnoreCase))
 270        {
 1271            var asLong = Convert.ToInt64(value[2..], 8);
 1272            result = DowncastInteger(asLong);
 1273            return true;
 274        }
 4275        if (prefix.Equals("0x", StringComparison.OrdinalIgnoreCase))
 276        {
 1277            var asLong = Convert.ToInt64(value[2..], 16);
 1278            result = DowncastInteger(asLong);
 1279            return true;
 280        }
 3281        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    {
 3292        return !TryParseBigInteger(value, out var big)
 3293            ? throw new FormatException($"Failed to parse scalar '{value}' as integer.")
 3294            : 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
 5305        if (bigVal >= int.MinValue && bigVal <= int.MaxValue)
 306        {
 2307            return (int)bigVal;
 308        }
 3309        if (bigVal >= long.MinValue && bigVal <= long.MaxValue)
 310        {
 1311            return (long)bigVal;
 312        }
 313#pragma warning restore IDE0078
 2314        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.
 19326        if (ScientificIntRegex().IsMatch(s))
 327        {
 328            try
 329            {
 1330                var parts = s.Split('e', 'E');
 1331                var mantissaStr = parts[0];
 1332                var expStr = parts[1];
 1333                var exp = int.Parse(expStr, CultureInfo.InvariantCulture);
 1334                if (exp < 0)
 335                {
 1336                    result = default;
 1337                    return false; // would be fractional
 338                }
 0339                var sign = 1;
 0340                if (mantissaStr.StartsWith('+'))
 341                {
 0342                    mantissaStr = mantissaStr[1..];
 343                }
 0344                else if (mantissaStr.StartsWith('-'))
 345                {
 0346                    sign = -1;
 0347                    mantissaStr = mantissaStr[1..];
 348                }
 0349                if (!BigInteger.TryParse(mantissaStr, NumberStyles.Integer | NumberStyles.AllowLeadingSign, CultureInfo.
 350                {
 0351                    result = default;
 0352                    return false;
 353                }
 0354                var pow = Pow10(exp);
 0355                result = mantissa * pow * sign;
 0356                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)
 18364        return BigInteger.TryParse(
 18365            s,
 18366            NumberStyles.Integer | NumberStyles.AllowLeadingSign,
 18367            CultureInfo.InvariantCulture,
 18368            out result
 18369        );
 1370    }
 371
 2372    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    {
 3380        if (ordered)
 381        {
 0382            var ret = new OrderedDictionary(StringComparer.Ordinal);
 0383            foreach (var kv in node.Children)
 384            {
 0385                var key = KeyToString(kv.Key);
 0386                var val = key == "datesAsStrings" && kv.Value is YamlSequenceNode seq
 0387                    ? seq.Children.Select(c => c is YamlScalarNode s ? s.Value : c.ToString()).ToArray()
 0388                    : ConvertYamlDocumentToPSObject(kv.Value, ordered);
 0389                ret[key] = val;
 390            }
 0391            return ret;
 392        }
 393        else
 394        {
 3395            var ret = new Dictionary<string, object?>(StringComparer.Ordinal);
 20396            foreach (var kv in node.Children)
 397            {
 7398                var key = KeyToString(kv.Key);
 7399                ret[key] = key == "datesAsStrings" && kv.Value is YamlSequenceNode seq
 0400                    ? seq.Children.Select(c => c is YamlScalarNode s ? s.Value : c.ToString()).ToArray()
 7401                    : ConvertYamlDocumentToPSObject(kv.Value, ordered);
 402            }
 3403            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) =>
 8425        node switch
 8426        {
 3427            YamlMappingNode m => ConvertYamlMappingToHashtable(m, ordered),
 0428            YamlSequenceNode s => ConvertYamlSequenceToArray(s, ordered),
 5429            YamlScalarNode => ConvertValueToProperType(node),
 0430            _ => node // fallback: return the node itself
 8431        };
 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)
 0440        => 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.
 7445        if (keyNode is YamlScalarNode sk && sk.Value is not null)
 446        {
 7447            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
 2464        var result = BigInteger.One;
 2465        var baseVal = new BigInteger(10);
 2466        var e = exp;
 6467        while (e > 0)
 468        {
 4469            if ((e & 1) == 1)
 470            {
 3471                result *= baseVal;
 472            }
 4473            baseVal *= baseVal;
 4474            e >>= 1;
 475        }
 2476        return result;
 477    }
 478
 479    [GeneratedRegex(@"[\+\-]\d{1,2}(:?\d{2})?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
 480    private static partial Regex MyRegex1();
 481}