< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Localization.StringTableParser
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Localization/StringTableParser.cs
Tag: Kestrun/Kestrun@ca54e35c77799b76774b3805b6f075cdbc0c5fbe
Line coverage
85%
Covered lines: 69
Uncovered lines: 12
Coverable lines: 81
Total lines: 221
Line coverage: 85.1%
Branch coverage
73%
Covered branches: 41
Total branches: 56
Branch coverage: 73.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 01/24/2026 - 19:35:59 Line coverage: 85.1% (69/81) Branch coverage: 73.2% (41/56) Total lines: 221 Tag: Kestrun/Kestrun@f59dcba478ea75f69584d696e5f1fb1cfa40aa51 01/24/2026 - 19:35:59 Line coverage: 85.1% (69/81) Branch coverage: 73.2% (41/56) Total lines: 221 Tag: Kestrun/Kestrun@f59dcba478ea75f69584d696e5f1fb1cfa40aa51

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ParseFile(...)87.5%8894.44%
ParseJsonFile(...)75%4492.85%
ShouldSkipLine(...)100%66100%
ProcessClosingBrace(...)100%22100%
ProcessKeyValueLine(...)80%101093.33%
FlattenJsonElement(...)70%161060%
BuildJsonKey(...)100%22100%
ConvertJsonValue(...)33.33%7666.66%
UnquoteValue(...)50%9880%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Localization/StringTableParser.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text;
 3using System.Text.Json;
 4
 5namespace Kestrun.Localization;
 6
 7/// <summary>
 8/// Parses PowerShell-style string table files containing key=value pairs.
 9/// </summary>
 10public static class StringTableParser
 11{
 12    /// <summary>
 13    /// Parses a string table file and returns a dictionary of key/value pairs.
 14    /// </summary>
 15    /// <param name="path">The file path to parse.</param>
 16    /// <returns>A dictionary containing parsed key/value pairs.</returns>
 17    public static IReadOnlyDictionary<string, string> ParseFile(string path)
 18    {
 919        ArgumentNullException.ThrowIfNull(path);
 20
 921        if (!File.Exists(path))
 22        {
 023            throw new FileNotFoundException("Localization file not found.", path);
 24        }
 25
 926        var map = new Dictionary<string, string>(StringComparer.Ordinal);
 927        var prefixStack = new Stack<string>();
 928        using var reader = new StreamReader(
 929            path,
 930            new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false),
 931            detectEncodingFromByteOrderMarks: true);
 32
 33        string? line;
 5734        while ((line = reader.ReadLine()) != null)
 35        {
 4836            var trimmed = line.Trim();
 37
 4838            if (ShouldSkipLine(trimmed))
 39            {
 40                continue;
 41            }
 42
 3643            if (string.Equals(trimmed, "}", StringComparison.Ordinal))
 44            {
 1445                ProcessClosingBrace(prefixStack);
 1446                continue;
 47            }
 48
 2249            ProcessKeyValueLine(trimmed, prefixStack, map);
 50        }
 51
 952        return map;
 953    }
 54
 55    /// <summary>
 56    /// Parses a JSON string table file and returns a dictionary of key/value pairs.
 57    /// </summary>
 58    /// <param name="path">The JSON file path to parse.</param>
 59    /// <returns>A dictionary containing parsed key/value pairs.</returns>
 60    public static IReadOnlyDictionary<string, string> ParseJsonFile(string path)
 61    {
 262        ArgumentNullException.ThrowIfNull(path);
 63
 264        if (!File.Exists(path))
 65        {
 066            throw new FileNotFoundException("Localization file not found.", path);
 67        }
 68
 269        using var stream = File.OpenRead(path);
 270        using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
 271        {
 272            AllowTrailingCommas = true,
 273            CommentHandling = JsonCommentHandling.Skip
 274        });
 75
 276        var map = new Dictionary<string, string>(StringComparer.Ordinal);
 277        if (document.RootElement.ValueKind == JsonValueKind.Object)
 78        {
 279            FlattenJsonElement(document.RootElement, prefix: null, map);
 80        }
 81
 282        return map;
 283    }
 84
 85    /// <summary>
 86    /// Determines whether a line should be skipped during parsing.
 87    /// </summary>
 88    /// <param name="trimmed">The trimmed line content.</param>
 89    /// <returns>True if the line should be skipped; otherwise false.</returns>
 90    private static bool ShouldSkipLine(string trimmed) =>
 4891        trimmed.Length == 0 ||
 4892        string.Equals(trimmed, "@{", StringComparison.Ordinal) ||
 4893        trimmed.StartsWith('#') ||
 4894        trimmed.StartsWith("//", StringComparison.Ordinal);
 95
 96    /// <summary>
 97    /// Processes a closing brace by popping the prefix stack.
 98    /// </summary>
 99    /// <param name="prefixStack">The prefix stack tracking nested hashtables.</param>
 100    private static void ProcessClosingBrace(Stack<string> prefixStack)
 101    {
 14102        if (prefixStack.Count > 0)
 103        {
 5104            _ = prefixStack.Pop();
 105        }
 14106    }
 107
 108    /// <summary>
 109    /// Processes a key=value line and adds it to the map.
 110    /// </summary>
 111    /// <param name="trimmed">The trimmed line content.</param>
 112    /// <param name="prefixStack">The prefix stack tracking nested hashtables.</param>
 113    /// <param name="map">The dictionary to populate.</param>
 114    private static void ProcessKeyValueLine(string trimmed, Stack<string> prefixStack, Dictionary<string, string> map)
 115    {
 22116        var equalsIndex = trimmed.IndexOf('=');
 22117        if (equalsIndex < 0)
 118        {
 1119            return;
 120        }
 121
 21122        var key = trimmed[..equalsIndex].Trim();
 21123        if (key.Length == 0)
 124        {
 0125            return;
 126        }
 127
 21128        var value = trimmed[(equalsIndex + 1)..].Trim();
 21129        if (string.Equals(value, "@{", StringComparison.Ordinal))
 130        {
 5131            var prefix = prefixStack.Count == 0 ? key : string.Concat(prefixStack.Peek(), ".", key);
 5132            prefixStack.Push(prefix);
 5133            return;
 134        }
 135
 16136        value = UnquoteValue(value);
 16137        var fullKey = prefixStack.Count == 0 ? key : string.Concat(prefixStack.Peek(), ".", key);
 16138        map[fullKey] = value;
 16139    }
 140
 141    /// <summary>
 142    /// Flattens a JSON element into dot-delimited keys.
 143    /// </summary>
 144    /// <param name="element">The JSON element to flatten.</param>
 145    /// <param name="prefix">The key prefix.</param>
 146    /// <param name="map">The dictionary to populate.</param>
 147    private static void FlattenJsonElement(JsonElement element, string? prefix, Dictionary<string, string> map)
 148    {
 9149        if (element.ValueKind == JsonValueKind.Object)
 150        {
 22151            foreach (var property in element.EnumerateObject())
 152            {
 7153                var nextPrefix = BuildJsonKey(prefix, property.Name);
 7154                FlattenJsonElement(property.Value, nextPrefix, map);
 155            }
 156
 4157            return;
 158        }
 159
 5160        if (element.ValueKind == JsonValueKind.Array)
 161        {
 0162            var index = 0;
 0163            foreach (var item in element.EnumerateArray())
 164            {
 0165                var nextPrefix = BuildJsonKey(prefix, index.ToString(CultureInfo.InvariantCulture));
 0166                FlattenJsonElement(item, nextPrefix, map);
 0167                index++;
 168            }
 169
 0170            return;
 171        }
 172
 5173        if (prefix is not null)
 174        {
 5175            map[prefix] = ConvertJsonValue(element);
 176        }
 5177    }
 178
 179    /// <summary>
 180    /// Builds a dot-delimited JSON key.
 181    /// </summary>
 182    /// <param name="prefix">The existing prefix.</param>
 183    /// <param name="name">The next segment name.</param>
 184    /// <returns>The combined key.</returns>
 185    private static string BuildJsonKey(string? prefix, string name) =>
 7186        string.IsNullOrWhiteSpace(prefix) ? name : string.Concat(prefix, ".", name);
 187
 188    /// <summary>
 189    /// Converts a JSON element to a string value for the string table.
 190    /// </summary>
 191    /// <param name="element">The JSON element.</param>
 192    /// <returns>The string value.</returns>
 193    private static string ConvertJsonValue(JsonElement element)
 194    {
 5195        return element.ValueKind switch
 5196        {
 5197            JsonValueKind.String => element.GetString() ?? string.Empty,
 0198            JsonValueKind.Null => string.Empty,
 0199            _ => element.GetRawText()
 5200        };
 201    }
 202
 203    /// <summary>
 204    /// Removes surrounding quotes from a value if present.
 205    /// </summary>
 206    /// <param name="value">The value to unquote.</param>
 207    /// <returns>The unquoted value.</returns>
 208    private static string UnquoteValue(string value)
 209    {
 16210        if (value.Length >= 2)
 211        {
 16212            var quote = value[0];
 16213            if ((quote == '"' || quote == '\'') && value[^1] == quote)
 214            {
 16215                return value[1..^1];
 216            }
 217        }
 218
 0219        return value;
 220    }
 221}