using System.Collections.Immutable; using System.Text; using System.Text.RegularExpressions; namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal; internal static class RubyManifestParser { private static readonly Regex GemLineRegex = new(@"^\s*gem\s+(?['""])(?[^'""]+)\k(?.*)$", RegexOptions.Compiled); private static readonly Regex GroupStartRegex = new(@"^\s*group\s+(?.+?)\s+do\b", RegexOptions.Compiled); private static readonly Regex InlineGroupRegex = new(@"(group|groups)\s*[:=]>\s*(?\[.*?\]|:[A-Za-z0-9_]+)|(group|groups):\s*(?\[.*?\]|:[A-Za-z0-9_]+)", RegexOptions.Compiled); private static readonly Regex SymbolRegex = new(@":(?[A-Za-z0-9_]+)", RegexOptions.Compiled); private static readonly Regex StringRegex = new(@"['""](?[A-Za-z0-9_\-]+)['""]", RegexOptions.Compiled); public static IReadOnlyDictionary> ParseGroups(string manifestPath) { if (string.IsNullOrWhiteSpace(manifestPath) || !File.Exists(manifestPath)) { return ImmutableDictionary>.Empty; } try { return ParseInternal(manifestPath); } catch (IOException) { return ImmutableDictionary>.Empty; } catch (UnauthorizedAccessException) { return ImmutableDictionary>.Empty; } } private static IReadOnlyDictionary> ParseInternal(string manifestPath) { var groupStack = new Stack>(); var mapping = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var rawLine in File.ReadLines(manifestPath)) { var line = StripComment(rawLine); if (string.IsNullOrWhiteSpace(line)) { continue; } if (TryBeginGroup(line, groupStack)) { continue; } if (IsGroupEnd(line) && groupStack.Count > 0) { groupStack.Pop(); continue; } if (!TryParseGem(line, out var gemName, out var inlineGroups)) { continue; } if (!mapping.TryGetValue(gemName, out var groups)) { groups = new HashSet(StringComparer.OrdinalIgnoreCase); mapping[gemName] = groups; } if (groupStack.Count > 0) { foreach (var group in groupStack.Peek()) { groups.Add(group); } } foreach (var inline in inlineGroups) { groups.Add(inline); } if (groups.Count == 0) { groups.Add("default"); } } return mapping.ToDictionary( static pair => pair.Key, static pair => (IReadOnlyCollection)pair.Value .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase) .ToArray(), StringComparer.OrdinalIgnoreCase); } private static bool TryBeginGroup(string line, Stack> stack) { var match = GroupStartRegex.Match(line); if (!match.Success) { return false; } var parsedGroups = ParseGroupTokens(match.Groups["args"].Value); var inherited = stack.Count > 0 ? new HashSet(stack.Peek(), StringComparer.OrdinalIgnoreCase) : new HashSet(StringComparer.OrdinalIgnoreCase); if (parsedGroups.Count == 0 && inherited.Count == 0) { inherited.Add("default"); } foreach (var group in parsedGroups) { inherited.Add(group); } stack.Push(inherited); return true; } private static IReadOnlyCollection ParseGroupTokens(string tokens) { if (string.IsNullOrWhiteSpace(tokens)) { return Array.Empty(); } var normalized = tokens.Trim().Trim('(', ')'); var results = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var segment in normalized.Split(',', StringSplitOptions.RemoveEmptyEntries)) { var token = segment.Trim(); if (token.Length == 0) { continue; } var symbolMatch = SymbolRegex.Match(token); if (symbolMatch.Success) { results.Add(symbolMatch.Groups["symbol"].Value); continue; } var stringMatch = StringRegex.Match(token); if (stringMatch.Success) { results.Add(stringMatch.Groups["value"].Value); } } return results; } private static bool TryParseGem(string line, out string name, out IReadOnlyCollection inlineGroups) { var match = GemLineRegex.Match(line); if (!match.Success) { name = string.Empty; inlineGroups = Array.Empty(); return false; } name = match.Groups["name"].Value.Trim(); inlineGroups = ExtractInlineGroups(match.Groups["args"].Value); return !string.IsNullOrWhiteSpace(name); } private static IReadOnlyCollection ExtractInlineGroups(string args) { if (string.IsNullOrWhiteSpace(args)) { return Array.Empty(); } var groups = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Match match in InlineGroupRegex.Matches(args)) { if (!match.Success) { continue; } var valueGroup = match.Groups["value"].Success ? match.Groups["value"] : match.Groups["typed"]; if (!valueGroup.Success) { continue; } var value = valueGroup.Value; if (string.IsNullOrWhiteSpace(value)) { continue; } foreach (Match symbol in SymbolRegex.Matches(value)) { if (symbol.Success) { groups.Add(symbol.Groups["symbol"].Value); } } foreach (Match str in StringRegex.Matches(value)) { if (str.Success) { groups.Add(str.Groups["value"].Value); } } } return groups; } private static bool IsGroupEnd(string line) { var trimmed = line.Trim(); if (!trimmed.StartsWith("end", StringComparison.Ordinal)) { return false; } return trimmed.Length == 3 || char.IsWhiteSpace(trimmed[3]) || trimmed[3] == '#'; } private static string StripComment(string line) { if (string.IsNullOrEmpty(line)) { return string.Empty; } var builder = new StringBuilder(line.Length); var inSingle = false; var inDouble = false; for (var i = 0; i < line.Length; i++) { var ch = line[i]; if (ch == '\'' && !inDouble) { inSingle = !inSingle; } else if (ch == '"' && !inSingle) { inDouble = !inDouble; } if (ch == '#' && !inSingle && !inDouble) { break; } builder.Append(ch); } return builder.ToString(); } }