| | | 1 | | using System.Text; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | |
| | | 4 | | namespace TeleFlow.Telegram.Internal.Handlers; |
| | | 5 | | |
| | | 6 | | internal static class TelegramTemplateRouteParser |
| | | 7 | | { |
| | 1 | 8 | | private static readonly Regex PlaceholderRegex = new( |
| | 1 | 9 | | @"\{(?<name>[A-Za-z_][A-Za-z0-9_]*)(?:(?<nameOptional>\?)|:(?<constraint>[A-Za-z][A-Za-z0-9_]*)(?<constraintOpti |
| | 1 | 10 | | RegexOptions.CultureInvariant); |
| | | 11 | | |
| | | 12 | | public static IReadOnlyList<TelegramRouteValueDescriptor> GetRouteValues(string template) |
| | | 13 | | { |
| | 30 | 14 | | ArgumentException.ThrowIfNullOrWhiteSpace(template); |
| | | 15 | | |
| | 30 | 16 | | var values = new List<TelegramRouteValueDescriptor>(); |
| | 30 | 17 | | var names = new HashSet<string>(StringComparer.Ordinal); |
| | 30 | 18 | | var position = 0; |
| | | 19 | | |
| | 113 | 20 | | foreach (Match match in PlaceholderRegex.Matches(template)) |
| | | 21 | | { |
| | 27 | 22 | | if (match.Index != position) |
| | | 23 | | { |
| | 27 | 24 | | EnsureNoUnparsedPlaceholder(template, position, match.Index); |
| | | 25 | | } |
| | | 26 | | |
| | 27 | 27 | | var placeholder = GetPlaceholder(template, match); |
| | 27 | 28 | | var name = placeholder.Name; |
| | | 29 | | |
| | 27 | 30 | | if (!names.Add(name)) |
| | | 31 | | { |
| | 0 | 32 | | throw new InvalidOperationException($"Telegram route template '{template}' contains duplicate placeholde |
| | | 33 | | } |
| | | 34 | | |
| | 27 | 35 | | values.Add(new TelegramRouteValueDescriptor( |
| | 27 | 36 | | name, |
| | 27 | 37 | | GetConstraintType(template, placeholder.Constraint), |
| | 27 | 38 | | placeholder.IsOptional)); |
| | 26 | 39 | | position = match.Index + match.Length; |
| | | 40 | | } |
| | | 41 | | |
| | 29 | 42 | | EnsureNoUnparsedPlaceholder(template, position, template.Length); |
| | | 43 | | |
| | 28 | 44 | | return values; |
| | | 45 | | } |
| | | 46 | | |
| | | 47 | | public static Regex BuildRegex( |
| | | 48 | | string template, |
| | | 49 | | bool ignoreCase) |
| | | 50 | | { |
| | 100 | 51 | | ArgumentException.ThrowIfNullOrWhiteSpace(template); |
| | | 52 | | |
| | 100 | 53 | | var builder = new StringBuilder("^"); |
| | 100 | 54 | | var position = 0; |
| | | 55 | | |
| | 346 | 56 | | foreach (Match match in PlaceholderRegex.Matches(template)) |
| | | 57 | | { |
| | 73 | 58 | | if (match.Index != position) |
| | | 59 | | { |
| | 73 | 60 | | EnsureNoUnparsedPlaceholder(template, position, match.Index); |
| | | 61 | | } |
| | | 62 | | |
| | 73 | 63 | | var literal = template[position..match.Index]; |
| | 73 | 64 | | var placeholder = GetPlaceholder(template, match); |
| | | 65 | | |
| | 73 | 66 | | if (placeholder.IsOptional) |
| | | 67 | | { |
| | 30 | 68 | | var optionalPrefixLength = GetTrailingWhitespaceLength(literal); |
| | 30 | 69 | | var requiredLiteral = literal[..(literal.Length - optionalPrefixLength)]; |
| | 30 | 70 | | var optionalPrefix = literal[(literal.Length - optionalPrefixLength)..]; |
| | | 71 | | |
| | 30 | 72 | | builder.Append(Regex.Escape(requiredLiteral)); |
| | 30 | 73 | | builder.Append("(?:"); |
| | 30 | 74 | | builder.Append(Regex.Escape(optionalPrefix)); |
| | 30 | 75 | | AppendRouteValueGroup(builder, template, placeholder); |
| | 30 | 76 | | builder.Append(")?"); |
| | | 77 | | } |
| | | 78 | | else |
| | | 79 | | { |
| | 43 | 80 | | builder.Append(Regex.Escape(literal)); |
| | 43 | 81 | | AppendRouteValueGroup(builder, template, placeholder); |
| | | 82 | | } |
| | | 83 | | |
| | 73 | 84 | | position = match.Index + match.Length; |
| | | 85 | | } |
| | | 86 | | |
| | 100 | 87 | | EnsureNoUnparsedPlaceholder(template, position, template.Length); |
| | 100 | 88 | | builder.Append(Regex.Escape(template[position..])); |
| | 100 | 89 | | builder.Append('$'); |
| | | 90 | | |
| | 100 | 91 | | return new Regex( |
| | 100 | 92 | | builder.ToString(), |
| | 100 | 93 | | GetRegexOptions(ignoreCase)); |
| | | 94 | | } |
| | | 95 | | |
| | | 96 | | public static int GetSpecificity(string template) |
| | | 97 | | { |
| | 100 | 98 | | ArgumentException.ThrowIfNullOrWhiteSpace(template); |
| | | 99 | | |
| | 100 | 100 | | var score = 0; |
| | 100 | 101 | | var position = 0; |
| | | 102 | | |
| | 346 | 103 | | foreach (Match match in PlaceholderRegex.Matches(template)) |
| | | 104 | | { |
| | 73 | 105 | | if (match.Index != position) |
| | | 106 | | { |
| | 73 | 107 | | EnsureNoUnparsedPlaceholder(template, position, match.Index); |
| | | 108 | | } |
| | | 109 | | |
| | 73 | 110 | | score += GetLiteralSpecificity(template[position..match.Index]); |
| | | 111 | | |
| | 73 | 112 | | var placeholder = GetPlaceholder(template, match); |
| | 73 | 113 | | score += GetConstraintSpecificity(template, placeholder.Constraint); |
| | 73 | 114 | | score += placeholder.IsOptional ? 0 : 5; |
| | | 115 | | |
| | 73 | 116 | | position = match.Index + match.Length; |
| | | 117 | | } |
| | | 118 | | |
| | 100 | 119 | | EnsureNoUnparsedPlaceholder(template, position, template.Length); |
| | 100 | 120 | | score += GetLiteralSpecificity(template[position..]); |
| | | 121 | | |
| | 100 | 122 | | return score; |
| | | 123 | | } |
| | | 124 | | |
| | | 125 | | private static Placeholder GetPlaceholder(string template, Match match) |
| | | 126 | | { |
| | 173 | 127 | | var name = match.Groups["name"].Value; |
| | 173 | 128 | | var hasNameOptional = match.Groups["nameOptional"].Success; |
| | 173 | 129 | | var hasConstraintOptional = match.Groups["constraintOptional"].Success; |
| | | 130 | | |
| | 173 | 131 | | var constraint = match.Groups["constraint"].Success |
| | 173 | 132 | | ? match.Groups["constraint"].Value |
| | 173 | 133 | | : "string"; |
| | | 134 | | |
| | 173 | 135 | | return new Placeholder(name, constraint, hasNameOptional || hasConstraintOptional); |
| | | 136 | | } |
| | | 137 | | |
| | | 138 | | private static void AppendRouteValueGroup( |
| | | 139 | | StringBuilder builder, |
| | | 140 | | string template, |
| | | 141 | | Placeholder placeholder) |
| | | 142 | | { |
| | 73 | 143 | | builder.Append("(?<"); |
| | 73 | 144 | | builder.Append(placeholder.Name); |
| | 73 | 145 | | builder.Append('>'); |
| | 73 | 146 | | builder.Append(GetConstraintPattern(template, placeholder.Constraint)); |
| | 73 | 147 | | builder.Append(')'); |
| | 73 | 148 | | } |
| | | 149 | | |
| | | 150 | | private static int GetTrailingWhitespaceLength(string value) |
| | | 151 | | { |
| | 30 | 152 | | var length = 0; |
| | | 153 | | |
| | 120 | 154 | | for (var index = value.Length - 1; index >= 0 && char.IsWhiteSpace(value[index]); index--) |
| | | 155 | | { |
| | 30 | 156 | | length++; |
| | | 157 | | } |
| | | 158 | | |
| | 30 | 159 | | return length; |
| | | 160 | | } |
| | | 161 | | |
| | | 162 | | private static int GetLiteralSpecificity(string value) |
| | | 163 | | { |
| | 1263 | 164 | | return value.Count(static character => !char.IsWhiteSpace(character)) * 1000; |
| | | 165 | | } |
| | | 166 | | |
| | | 167 | | private static void EnsureNoUnparsedPlaceholder( |
| | | 168 | | string template, |
| | | 169 | | int start, |
| | | 170 | | int end) |
| | | 171 | | { |
| | 402 | 172 | | var segment = template[start..end]; |
| | | 173 | | |
| | 402 | 174 | | if (segment.Contains('{', StringComparison.Ordinal) || |
| | 402 | 175 | | segment.Contains('}', StringComparison.Ordinal)) |
| | | 176 | | { |
| | 1 | 177 | | throw new InvalidOperationException($"Telegram route template '{template}' contains an invalid placeholder." |
| | | 178 | | } |
| | 401 | 179 | | } |
| | | 180 | | |
| | | 181 | | private static Type GetConstraintType(string template, string constraint) |
| | | 182 | | { |
| | 27 | 183 | | return constraint switch |
| | 27 | 184 | | { |
| | 5 | 185 | | "string" => typeof(string), |
| | 9 | 186 | | "int" => typeof(int), |
| | 12 | 187 | | "long" => typeof(long), |
| | 1 | 188 | | _ => throw new InvalidOperationException( |
| | 1 | 189 | | $"Telegram route template '{template}' uses unsupported placeholder constraint '{constraint}'.") |
| | 27 | 190 | | }; |
| | | 191 | | } |
| | | 192 | | |
| | | 193 | | private static string GetConstraintPattern(string template, string constraint) |
| | | 194 | | { |
| | 73 | 195 | | return constraint switch |
| | 73 | 196 | | { |
| | 30 | 197 | | "string" => ".+?", |
| | 6 | 198 | | "int" => "-?\\d+", |
| | 37 | 199 | | "long" => "-?\\d+", |
| | 0 | 200 | | _ => throw new InvalidOperationException( |
| | 0 | 201 | | $"Telegram route template '{template}' uses unsupported placeholder constraint '{constraint}'.") |
| | 73 | 202 | | }; |
| | | 203 | | } |
| | | 204 | | |
| | | 205 | | private static int GetConstraintSpecificity(string template, string constraint) |
| | | 206 | | { |
| | 73 | 207 | | return constraint switch |
| | 73 | 208 | | { |
| | 30 | 209 | | "string" => 10, |
| | 6 | 210 | | "int" => 100, |
| | 37 | 211 | | "long" => 100, |
| | 0 | 212 | | _ => throw new InvalidOperationException( |
| | 0 | 213 | | $"Telegram route template '{template}' uses unsupported placeholder constraint '{constraint}'.") |
| | 73 | 214 | | }; |
| | | 215 | | } |
| | | 216 | | |
| | | 217 | | public static RegexOptions GetRegexOptions(bool ignoreCase) |
| | | 218 | | { |
| | 114 | 219 | | return RegexOptions.CultureInvariant | |
| | 114 | 220 | | (ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None); |
| | | 221 | | } |
| | | 222 | | |
| | | 223 | | private readonly record struct Placeholder( |
| | | 224 | | string Name, |
| | | 225 | | string Constraint, |
| | | 226 | | bool IsOptional); |
| | | 227 | | } |