diff --git a/src/Imazen.Abstractions/Internal/Fnv1AHash.cs b/src/Imazen.Abstractions/Internal/Fnv1AHash.cs new file mode 100644 index 0000000..ee134f9 --- /dev/null +++ b/src/Imazen.Abstractions/Internal/Fnv1AHash.cs @@ -0,0 +1,78 @@ +namespace Imazen.Abstractions.Internal; + +internal struct Fnv1AHash +{ + private const ulong FnvPrime = 0x00000100000001B3; + private const ulong FnvOffsetBasis = 0xCBF29CE484222325; + private ulong hash; + public ulong CurrentHash => hash; + + public static Fnv1AHash Create() + { + return new Fnv1AHash(){ + hash = FnvOffsetBasis + }; + } + + private void HashInternal(ReadOnlySpan array) + { + foreach (var t in array) + { + unchecked + { + hash ^= t; + hash *= FnvPrime; + } + } + } + private void HashInternal(ReadOnlySpan array) + { + foreach (var t in array) + { + unchecked + { + hash ^= (byte)(t & 0xFF); + hash *= FnvPrime; + hash ^= (byte)((t >> 8) & 0xFF); + hash *= FnvPrime; + } + } + } + public void Add(string? s) + { + if (s == null) return; + HashInternal(s.AsSpan()); + } + public void Add(ReadOnlySpan s) + { + HashInternal(s); + } + + public void Add(byte[] array) + { + HashInternal(array); + } + public void Add(int i) + { + unchecked + { + byte b1 = (byte) (i & 0xFF); + byte b2 = (byte) ((i >> 8) & 0xFF); + byte b3 = (byte) ((i >> 16) & 0xFF); + byte b4 = (byte) ((i >> 24) & 0xFF); + hash ^= b1; + hash *= FnvPrime; + hash ^= b2; + hash *= FnvPrime; + hash ^= b3; + hash *= FnvPrime; + hash ^= b4; + } + } + public void Add(long val) + { + Add((int)(val & 0xFFFFFFFF)); + Add((int) (val >> 32)); + } + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Logging/ReLogStore.cs b/src/Imazen.Abstractions/Logging/ReLogStore.cs index 830fd80..d984530 100644 --- a/src/Imazen.Abstractions/Logging/ReLogStore.cs +++ b/src/Imazen.Abstractions/Logging/ReLogStore.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Text; +using Imazen.Abstractions.Internal; using Microsoft.Extensions.Logging; namespace Imazen.Abstractions.Logging @@ -177,74 +178,4 @@ public void Format(StringBuilder sb, int totalCount, bool relativeTime) } } - - internal struct Fnv1AHash - { - private const ulong FnvPrime = 0x00000100000001B3; - private const ulong FnvOffsetBasis = 0xCBF29CE484222325; - private ulong hash; - public ulong CurrentHash => hash; - - public static Fnv1AHash Create() - { - return new Fnv1AHash(){ - hash = FnvOffsetBasis - }; - } - - private void HashInternal(IReadOnlyList array, int ibStart, int cbSize) - { - for (var i = ibStart; i < cbSize; i++) - { - unchecked - { - hash ^= array[i]; - hash *= FnvPrime; - } - } - } - - public void Add(string? s) - { - if (s == null) return; - foreach (var c in s) - { - unchecked - { - hash ^= (byte)(c & 0xFF); - hash *= FnvPrime; - hash ^= (byte)((c >> 8) & 0xFF); - hash *= FnvPrime; - } - } - } - - public void Add(byte[] array) - { - HashInternal(array, 0, array.Length); - } - public void Add(int i) - { - unchecked - { - byte b1 = (byte) (i & 0xFF); - byte b2 = (byte) ((i >> 8) & 0xFF); - byte b3 = (byte) ((i >> 16) & 0xFF); - byte b4 = (byte) ((i >> 24) & 0xFF); - hash ^= b1; - hash *= FnvPrime; - hash ^= b2; - hash *= FnvPrime; - hash ^= b3; - hash *= FnvPrime; - hash ^= b4; - } - } - public void Add(long val) - { - Add((int)(val & 0xFFFFFFFF)); - Add((int) (val >> 32)); - } - - } } \ No newline at end of file diff --git a/src/Imazen.Routing/Imazen.Routing.csproj b/src/Imazen.Routing/Imazen.Routing.csproj index 5ae8456..e64b0da 100644 --- a/src/Imazen.Routing/Imazen.Routing.csproj +++ b/src/Imazen.Routing/Imazen.Routing.csproj @@ -14,6 +14,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Imazen.Routing/Matching/CharacterClass.cs b/src/Imazen.Routing/Matching/CharacterClass.cs new file mode 100644 index 0000000..bda860a --- /dev/null +++ b/src/Imazen.Routing/Matching/CharacterClass.cs @@ -0,0 +1,343 @@ +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Imazen.Abstractions.Internal; + +namespace Imazen.Routing.Matching; + +/// +/// Parsed and interned representation of a character class like [0-9a-zA-Z] or [^/] or [^a-z] or [\\\t\n\r\[\]\{\)\,] +/// +public record CharacterClass( + bool IsNegated, + ReadOnlyCollection Ranges, + ReadOnlyCollection Characters) +{ + public override string ToString() + { + // 5 is our guess on escaped chars + var sb = new StringBuilder(3 + Ranges.Count * 3 + Characters.Count + 5); + sb.Append(IsNegated ? "[^" : "["); + foreach (var range in Ranges) + { + AppendEscaped(sb, range.Start); + sb.Append('-'); + AppendEscaped(sb, range.End); + } + foreach (var c in Characters) + { + AppendEscaped(sb, c); + } + sb.Append(']'); + return sb.ToString(); + } + private void AppendEscaped(StringBuilder sb, char c) + { + if (ValidCharsToEscape.Contains(c)) + { + sb.Append('\\'); + } + sb.Append(c); + } + + + public record struct CharRange(char Start, char End); + + + public bool Contains(char c) + { + return IsNegated ? !WithinSet(c) : WithinSet(c); + } + private bool WithinSet(char c) + { + if (Characters.Contains(c)) return true; + foreach (var range in Ranges) + { + if (c >= range.Start && c <= range.End) return true; + } + return false; + } + + + private static ulong HashSpan(ReadOnlySpan span) + { + var fnv = Fnv1AHash.Create(); + fnv.Add(span); + return fnv.CurrentHash; + } + private record ParseResult(bool Success, string Input, CharacterClass? Result, string? Error); + private static readonly Lazy> InternedParseResults = new Lazy>(); + private static readonly Lazy> InternedParseResultsByHash = new Lazy>(); + private static ParseResult? TryGetFromInterned(ReadOnlySpan syntax) + { + var hash = HashSpan(syntax); + if (InternedParseResultsByHash.Value.TryGetValue(hash, out var parseResult)) + { + if (!syntax.Is(parseResult.Input)) + { + // This is a hash collision, fall back to the slow path + if (InternedParseResults.Value.TryGetValue(syntax.ToString(), out parseResult)) + { + return parseResult; + } + return default; + } + return parseResult; + } + return default; + } + + + public static bool TryParseInterned(ReadOnlyMemory syntax, bool storeIfMissing, + [NotNullWhen(true)] out CharacterClass? result, + [NotNullWhen(false)] out string? error) + { + var span = syntax.Span; + var existing = TryGetFromInterned(span); + if (existing is not null) + { + result = existing.Result; + error = existing.Error; + return existing.Success; + } + var success = TryParse(syntax, out result, out error); + if (storeIfMissing) + { + var hash = HashSpan(span); + var str = syntax.ToString(); + InternedParseResultsByHash.Value.TryAdd(hash, new ParseResult(success, str, result, error)); + InternedParseResults.Value.TryAdd(str, new ParseResult(success, str, result, error)); + } + return success; + } + + + public static bool TryParse(ReadOnlyMemory syntax, + [NotNullWhen(true)] out CharacterClass? result, + [NotNullWhen(false)] out string? error) + { + var span = syntax.Span; + if (span.Length < 3) + { + error = "Character class must be at least 3 characters long"; + result = default; + return false; + } + if (span[0] != '[') + { + error = "Character class must start with ["; + result = default; + return false; + } + + if (span[^1] != ']') + { + error = "Character class must end with ]"; + result = default; + return false; + } + + var isNegated = span[1] == '^'; + if (isNegated) + { + if (span.Length < 4) + { + error = "Negated character class must be at least 4 characters long"; + result = default; + return false; + } + } + + var startFrom = isNegated ? 2 : 1; + return TryParseInner(isNegated, syntax[startFrom..^1], out result, out error); + } + + private enum LexTokenType : byte + { + ControlDash, + EscapedCharacter, + SingleCharacter, + PredefinedClass, + DanglingEscape, + IncorrectlyEscapedCharacter, + SuspiciousEscapedCharacter + } + + private readonly record struct LexToken(LexTokenType Type, char Value) + { + public bool IsValidCharacter => Type is LexTokenType.EscapedCharacter or LexTokenType.SingleCharacter; + } + + private static readonly char[] SuspiciousCharsToEscape = ['d', 'D', 's', 'S', 'w', 'W', 'b', 'B']; + + private static readonly char[] ValidCharsToEscape = + ['t', 'n', 'r', 'f', 'v', '0', '[', ']', '\\', '-', '^', ',', '(', ')', '{', '}', '|']; + + private static readonly LexToken ControlDashToken = new LexToken(LexTokenType.ControlDash, '-'); + + private static IEnumerable LexInner(ReadOnlyMemory syntax) + { + var i = 0; + while (i < syntax.Length) + { + var c = syntax.Span[i]; + if (c == '\\') + { + if (i == syntax.Length - 1) + { + yield return new LexToken(LexTokenType.DanglingEscape, '\\'); + } + var c2 = syntax.Span[i + 1]; + + if (c2 == 'w') + { + yield return new LexToken(LexTokenType.PredefinedClass, c2); + i += 2; + continue; + } + + if (ValidCharsToEscape.Contains(c2)) + { + yield return new LexToken(LexTokenType.EscapedCharacter, c2); + i += 2; + continue; + } + + if (SuspiciousCharsToEscape.Contains(c2)) + { + yield return new LexToken(LexTokenType.SuspiciousEscapedCharacter, c2); + i += 2; + continue; + } + + yield return new LexToken(LexTokenType.IncorrectlyEscapedCharacter, c2); + } + + if (c == '-') + { + yield return ControlDashToken; + i++; + continue; + } + + yield return new LexToken(LexTokenType.SingleCharacter, c); + i++; + } + } + + /// + /// Here we parse the inside, like "0-9a-z\t\\r\n\[\]\{\}\,A-Z" + /// First we break it into units - escaped characters, single characters, predefined classes, and the control character '-' + /// + /// + /// + /// + /// + /// + private static bool TryParseInner(bool negated, ReadOnlyMemory syntax, + [NotNullWhen(true)] out CharacterClass? result, + [NotNullWhen(false)] out string? error) + { + + List? ranges = null; + List? characters = null; + + var tokens = LexInner(syntax).ToList(); + // Reject if we have dangling escape, incorrectly escaped character, or suspicious escaped character + if (tokens.Any(t => t.Type is LexTokenType.DanglingEscape)) + { + error = "Dangling backslash in character class"; + result = default; + return false; + } + + if (tokens.Any(t => t.Type is LexTokenType.IncorrectlyEscapedCharacter)) + { + error = + $"Incorrectly escaped character '{tokens.First(t => t.Type is LexTokenType.IncorrectlyEscapedCharacter).Value}' in character class"; + result = default; + return false; + } + + if (tokens.Any(t => t.Type is LexTokenType.SuspiciousEscapedCharacter)) + { + var t = tokens.First(t => t.Type is LexTokenType.SuspiciousEscapedCharacter); + // This feature isn't supported + error = $"You probably meant to use a predefined character range with \'{t.Value}'; it is not supported."; + result = default; + return false; + } + + // Search for ranges + int indexOfDash = tokens.IndexOf(ControlDashToken); + while (indexOfDash != -1) + { + // if it's the first, the last, or bounded by a character class, then it's an error + if (indexOfDash == 0 || indexOfDash == tokens.Count - 1 || !tokens[indexOfDash - 1].IsValidCharacter || + !tokens[indexOfDash + 1].IsValidCharacter) + { + //TODO: improve error message + error = "Dashes can only be used between single characters in a character class"; + result = default; + return false; + } + + // Extract the range + var start = tokens[indexOfDash - 1].Value; + var end = tokens[indexOfDash + 1].Value; + if (start > end) + { + error = + $"Character class range must go from lower to higher, here [{syntax.ToString()}] it goes from {(int)start} to {(int)end}"; + result = default; + return false; + } + + ranges ??= new List(); + ranges.Add(new CharRange(start, end)); + // Mutate the collection and re-search + tokens.RemoveRange(indexOfDash - 1, 3); + indexOfDash = tokens.IndexOf(ControlDashToken); + } + + // The rest are single characters or predefined classes + foreach (var token in tokens) + { + if (token.Type is LexTokenType.SingleCharacter or LexTokenType.EscapedCharacter) + { + characters ??= []; + characters.Add(token.Value); + } + else if (token.Type is LexTokenType.PredefinedClass) + { + if (token.Value == 'w') + { + ranges ??= []; + ranges.AddRange(new[] + { + new CharRange('a', 'z'), new CharRange('A', 'Z'), + new CharRange('0', '9') + }); + characters ??= []; + characters.Add('_'); + } + else + { + throw new InvalidOperationException($"Unsupported predefined character class {token.Value}"); + } + } + else + { + throw new InvalidOperationException($"Unexpected token type {token.Type}"); + } + } + + characters ??= []; + ranges ??= []; + result = new CharacterClass(negated, new ReadOnlyCollection(ranges), + new ReadOnlyCollection(characters)); + error = null; + return true; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/Conditions.cs b/src/Imazen.Routing/Matching/Conditions.cs similarity index 100% rename from src/Imazen.Routing/Layers/Conditions.cs rename to src/Imazen.Routing/Matching/Conditions.cs diff --git a/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs b/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs new file mode 100644 index 0000000..b3ec22b --- /dev/null +++ b/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs @@ -0,0 +1,272 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +namespace Imazen.Routing.Matching; + +internal static partial class ExpressionParsingHelpers +{ + public static bool Is(this ReadOnlySpan text, string value) + { + if (text.Length != value.Length) return false; + for (int i = 0; i < text.Length; i++) + { + if (text[i] != value[i]) return false; + } + return true; + } + + [Flags] + internal enum GlobChars + { + None = 0, + Star = 2, + DoubleStar = 4, + StarOptional = Star | Optional, + DoubleStarOptional = DoubleStar | Optional, + Optional = 1, + } + internal static GlobChars GetGlobChars(ReadOnlySpan text) + { + if (text.Length == 1) + { + return text[0] switch + { + '*' => GlobChars.Star, + '?' => GlobChars.Optional, + _ => GlobChars.None + }; + } + if (text.Length == 2) + { + return text[0] switch + { + '*' => text[1] switch + { + '*' => GlobChars.DoubleStar, + '?' => GlobChars.StarOptional, + _ => GlobChars.None + }, + _ => GlobChars.None + }; + } + if (text.Length == 3 && text[0] == '*' && text[1] == '*' && text[2] == '?') + { + return GlobChars.DoubleStarOptional; + } + return GlobChars.None; + } + + /// + /// Find the first character that is not escaped by escapeChar. + /// A character is considered escaped if it is preceded by an odd number of consecutive escapeChars. + /// + /// + /// + /// + /// + internal static int FindCharNotEscaped(ReadOnlySpan str,char c, char escapeChar) + { + var consecutiveEscapeChars = 0; + for (var i = 0; i < str.Length; i++) + { + if (str[i] == escapeChar) + { + consecutiveEscapeChars++; + continue; + } + if (str[i] == c && consecutiveEscapeChars % 2 == 0) + { + return i; + } + consecutiveEscapeChars = 0; + } + return -1; + } + + +#if NET8_0_OR_GREATER + [GeneratedRegex(@"^[a-zA-Z_][a-zA-Z0-9_]*$", + RegexOptions.CultureInvariant | RegexOptions.Singleline)] + private static partial Regex ValidSegmentName(); +#else + + private static readonly Regex ValidSegmentNameVar = + new(@"^[a-zA-Z_][a-zA-Z0-9_]*$", + RegexOptions.CultureInvariant | RegexOptions.Singleline, TimeSpan.FromMilliseconds(50)); + private static Regex ValidSegmentName() => ValidSegmentNameVar; +#endif + + /// + /// We allow [a-zA-Z_][a-zA-Z0-9_]* for segment names + /// + /// + /// + /// + /// + internal static bool ValidateSegmentName(string name, ReadOnlySpan segmentExpression, [NotNullWhen(false)]out string? error) + { + if (name.Length == 0) + { + error = "Don't use empty segment names, only null or valid"; + return false; + } + if (name.Contains('*') || name.Contains('?')) + { + error = + $"Invalid segment expression {{{segmentExpression.ToString()}}} Conditions and modifiers such as * and ? belong after the colon. Ex: {{name:*:?}} "; + return false; + } + if (!ValidSegmentName().IsMatch(name)) + { + error = $"Invalid name '{name}' in segment expression {{{segmentExpression.ToString()}}}. Names must start with a letter or underscore, and contain only letters, numbers, or underscores"; + return false; + } + error = null; + return true; + } + + internal static bool TryReadConditionName(ReadOnlySpan text, + [NotNullWhen(true)] out int? conditionNameEnds, + [NotNullWhen(false)] out string? error) + { + + conditionNameEnds = text.IndexOf('('); + if (conditionNameEnds == -1) + { + conditionNameEnds = text.Length; + } + // check for invalid characters + for (var i = 0; i < conditionNameEnds; i++) + { + var c = text[i]; + if (c != '_' && c != '-' && c is < 'a' or > 'z') + { + error = $"Invalid character '{text[i]}' (a-z-_ only) in condition name. Condition expression: '{text.ToString()}'"; + return false; + } + } + error = null; + return true; + } + + // parse a condition call (function call style) + // condition_name(arg1, arg2, arg3) + // where condition_name is [a-z_] + // where arguments can be + // character classes: [a-z_] + // 'string' or "string" or + // numbers 123 or 123.456 + // a | delimited string array a|b|c + // a unquoted string + // we don't support nested function calls + // args are comma delimited + // The following characters must be escaped: , | ( ) ' " \ [ ] + + public static bool TryParseCondition(ReadOnlyMemory text, + [NotNullWhen(true)] + out ReadOnlyMemory? functionName, out List>? args, + [NotNullWhen(false)] out string? error) + { + var textSpan = text.Span; + if (!TryReadConditionName(textSpan, out var functionNameEnds, out error)) + { + functionName = null; + args = null; + return false; + } + + functionName = text[..functionNameEnds.Value]; + + if (functionNameEnds == text.Length) + { + args = null; + error = null; + return true; + } + + if (textSpan[functionNameEnds.Value] != '(') + { + throw new InvalidOperationException("Unreachable code"); + } + + if (textSpan[^1] != ')') + { + error = $"Expected ')' at end of condition expression '{text.ToString()}'"; + functionName = null; + args = null; + return false; + } + + // now parse using unescaped commas + var argListSpan = text.Slice(functionNameEnds.Value + 1, text.Length - 1); + if (argListSpan.Length == 0) + { + error = null; + args = null; + return true; + } + // We have at least one character + args = new List>(); + // Split using FindCharNotEscaped(text, ',', '\\') + var start = 0; + while (start < argListSpan.Length) + { + var subSpan = textSpan[start..]; + var commaIndex = FindCharNotEscaped(subSpan, ',', '\\'); + if (commaIndex == -1) + { + args.Add(argListSpan[start..]); + break; + } + args.Add(argListSpan.Slice(start, commaIndex)); + start += commaIndex + 1; + } + error = null; + return true; + } + + [Flags] + public enum ArgType + { + Empty = 0, + CharClass = 1, + Char = 2, + String = 4, + Array = 8, + UnsignedNumeric = 16, + DecimalNumeric = 32, + IntegerNumeric = 64, + UnsignedDecimal = UnsignedNumeric | DecimalNumeric, + UnsignedInteger = UnsignedNumeric | IntegerNumeric, + } + + public static ArgType GetArgType(ReadOnlySpan arg) + { + if (arg.Length == 0) return ArgType.Empty; + if (arg[0] == '[' && arg[^1] == ']') return ArgType.CharClass; + if (FindCharNotEscaped(arg, '|', '\\') != -1) return ArgType.Array; + var type = ArgType.IntegerNumeric | ArgType.DecimalNumeric | ArgType.UnsignedNumeric; + foreach (var c in arg) + { + if (c is >= '0' and <= '9') continue; + if (c == '.') + { + type &= ~ArgType.IntegerNumeric; + } + if (c == '-') + { + type &= ~ArgType.UnsignedNumeric; + } + type &= ~ArgType.DecimalNumeric; + } + if (arg.Length == 1) type |= ArgType.Char; + else type |= ArgType.String; + return type; + } + + + public static bool IsCommonCaseInsensitiveChar(char c) + { + return c is (>= ' ' and < 'A') or (> 'Z' and < 'a') or (> 'z' and <= '~') or '\t' or '\r' or '\n'; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Matching/MatchExpression.cs b/src/Imazen.Routing/Matching/MatchExpression.cs new file mode 100644 index 0000000..deee136 --- /dev/null +++ b/src/Imazen.Routing/Matching/MatchExpression.cs @@ -0,0 +1,459 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +namespace Imazen.Routing.Matching; + + +public record MatchingContext +{ + public bool SupportGraphemeClusters { get; init; } + public bool OrdinalIgnoreCase { get; init; } + public required IReadOnlyCollection SupportedImageExtensions { get; init; } +} + +public partial class MatchExpression +{ + internal List Segments { get; } = new(); + + internal bool TryCreate(List segments, [NotNullWhen(true)] out MatchExpression? result, [NotNullWhen(false)]out string? error) + { + if (segments.Count == 0) + { + result = null; + error = "Zero segments found in expression"; + return false; + } + + result = new MatchExpression(); + result.Segments.AddRange(segments); + error = null; + return true; + } + + +#if NET8_0_OR_GREATER + [GeneratedRegex(@"^(([^{]+)|((? SplitSectionsVar; + #endif + + + public bool TryParse(MatchingContext context, string expression, + [NotNullWhen(true)] out MatchExpression? result, + [NotNullWhen(false)]out string? error) + { + if (string.IsNullOrWhiteSpace(expression)) + { + error = "Match expression cannot be empty"; + result = null; + return false; + } + // enumerate the segments in expression using SplitSections. + // The entire regex should match. + // If it doesn't, return false and set error to the first unmatched character. + // If it does, create a MatchSegment for each match, and add it to the result. + // Work right-to-left + + var matches = SplitSections().Matches(expression); + if (matches.Count == 0) + { + error = "Match expression must be composed of one or more {segments} or literal strings. Check for unmatched or improperly escaped { or }"; + result = null; + return false; + } + var segments = new Stack(); + + for (int i = matches.Count - 1; i >= 0; i--) + { + var match = matches[i]; + var segment = expression.AsMemory()[match.Index..match.Length]; + if (!MatchSegment.TryParseSegmentExpression(context, segment, segments, out var parsedSegment, out error)) + { + result = null; + return false; + } + segments.Push(parsedSegment.Value); + } + + return TryCreate(segments.ToList(), out result, out error); + } + public readonly record struct MatchExpressionCapture(string Name, ReadOnlyMemory Value); + public readonly record struct MatchExpressionResult(bool Success, IReadOnlyList? Captures, string? Error); + + + public bool TryMatch(ReadOnlyMemory input, [NotNullWhen(true)] out MatchExpressionResult? result, + [NotNullWhen(false)] out string? error) + { + // We scan with SegmentBoundary to establish + // A) the start and end of each segment's var capture + // B) the start and end of each segment's capture + // C) if the segment (when optional) is present + + // Consecutive optional segments or a glob followed by an optional segment are not allowed. + // At least not yet. + + // Once we have the segment boundaries, we can use the segment's conditions to validate the capture. + + } +} + + + +internal readonly record struct MatchSegment(string? Name, SegmentBoundary StartsOn, SegmentBoundary EndsOn, List? Conditions) +{ + + public bool IsOptional => StartsOn.IsOptional; + + internal static bool TryParseSegmentExpression(MatchingContext context, + ReadOnlyMemory exprMemory, + Stack laterSegments, + [NotNullWhen(true)]out MatchSegment? segment, + [NotNullWhen(false)]out string? error) + { + var expr = exprMemory.Span; + if (expr.IsEmpty) + { + segment = null; + error = "Empty segment"; + return false; + } + error = null; + + if (expr[0] == '{') + { + if (expr[^1] != '}') + { + error = $"Unmatched '{{' in segment expression {{{expr.ToString()}}}"; + segment = null; + return false; + } + var innerMem = exprMemory[1..^1]; + if (innerMem.Length == 0) + { + error = "Segment {} cannot be empty. Try {*}, {name}, {name:condition1:condition2}"; + segment = null; + return false; + } + return TryParseLogicalSegment(context, innerMem, laterSegments, out segment, out error); + + } + // it's a literal + segment = CreateLiteral(expr, context); + return true; + } + + private static bool TryParseLogicalSegment(MatchingContext context, + ReadOnlyMemory innerMemory, + Stack laterSegments, + [NotNullWhen(true)] out MatchSegment? segment, + [NotNullWhen(false)] out string? error) + { + + string? name = null; + SegmentBoundary? segmentStartLogic = null; + SegmentBoundary? segmentEndLogic = null; + segment = null; + + List? conditions = null; + var inner = innerMemory.Span; + // Enumerate segments delimited by : (ignoring \:, and breaking on \\:) + int startsAt = 0; + int segmentCount = 0; + while (true) + { + int colonIndex = ExpressionParsingHelpers.FindCharNotEscaped(inner[startsAt..], ':', '\\'); + var thisPartMemory = colonIndex == -1 ? innerMemory[startsAt..] : innerMemory[startsAt..(startsAt + colonIndex)]; + bool isCondition = true; + if (segmentCount == 0) + { + isCondition = ExpressionParsingHelpers.GetGlobChars(thisPartMemory.Span) != ExpressionParsingHelpers.GlobChars.None; + if (!isCondition && thisPartMemory.Length > 0) + { + name = thisPartMemory.ToString(); + if (!ExpressionParsingHelpers.ValidateSegmentName(name, inner, out error)) + { + return false; + } + } + } + + if (isCondition) + { + if (!TryParseConditionOrSegment(context, colonIndex == -1, thisPartMemory, inner, ref segmentStartLogic, ref segmentEndLogic, ref conditions, laterSegments, out error)) + { + return false; + } + } + segmentCount++; + if (colonIndex == -1) + { + break; // We're done + } + startsAt += colonIndex + 1; + } + + segmentStartLogic ??= SegmentBoundary.DefaultStart; + segmentEndLogic ??= SegmentBoundary.DefaultEnd; + + if (segmentEndLogic.Value.AsEndSegmentReliesOnSubsequentSegmentBoundary) + { + // Then the next segment MUST be searched for and required + if (laterSegments.Count > 0) + { + var next = laterSegments.Peek(); + if (next.IsOptional) + { + error = $"The segment {inner.ToString()} cannot be matched deterministically since it precedes an optional segment. Add an until() condition or put a literal between them."; + return false; + } + if (!next.StartsOn.SupportsScanning) + { + error = $"The segment {inner.ToString()} cannot be matched deterministically since it precedes a segment that cannot be searched for"; + return false; + } + } + } + segment = new MatchSegment(name, segmentStartLogic.Value, segmentEndLogic.Value, conditions); + error = null; + return true; + } + + + + private static bool TryParseConditionOrSegment(MatchingContext context, + bool isFinalCondition, + ReadOnlyMemory conditionMemory, + ReadOnlySpan segmentText, + ref SegmentBoundary? segmentStartLogic, + ref SegmentBoundary? segmentEndLogic, + ref List? conditions, + Stack laterSegments, + [NotNullWhen(false)] out string? error) + { + error = null; + var conditionSpan = conditionMemory.Span; + var globChars = ExpressionParsingHelpers.GetGlobChars(conditionSpan); + var makeOptional = (globChars | ExpressionParsingHelpers.GlobChars.Optional) == ExpressionParsingHelpers.GlobChars.Optional + || conditionSpan.Is("optional"); + if (makeOptional) + { + segmentStartLogic ??= SegmentBoundary.DefaultStart; + segmentStartLogic = segmentStartLogic.Value.MakeOptional(true); + } + // We ignore the glob chars, they don't constrain behavior any. + if (globChars != ExpressionParsingHelpers.GlobChars.None) + { + return true; + } + + if (!ExpressionParsingHelpers.TryParseCondition(conditionMemory, out var functionNameMemory, out var args, out error)) + { + return false; + } + var functionName = functionNameMemory.ToString() ?? throw new InvalidOperationException("Unreachable code"); + var conditionConsumed = false; + if (functionName is "starts_with" or "after" or "ends_with" or "until" or "equals" + && args is { Count: 1 }) + { + var arg0Span = args[0].Span; + var argType = ExpressionParsingHelpers.GetArgType(arg0Span); + if ((argType & ExpressionParsingHelpers.ArgType.String) > 0) + { + if (functionName is "starts_with" or "after" or "equals") + { + // starts_with(str/char) + // after(str/char) - captures but does not include as part of variable + // equals(str/char) - captures as part of the variable + if (segmentStartLogic is { HasDefaultStartWhen: false }) + { + error = $"The segment {segmentText.ToString()} has multiple start conditions; do not mix starts_with, after, and equals conditions"; + return false; + } + var optional = segmentStartLogic?.IsOptional ?? false; + segmentStartLogic = functionName switch + { + "starts_with" => SegmentBoundary.StartWith(arg0Span, context.OrdinalIgnoreCase, true).MakeOptional(optional), + "after" => SegmentBoundary.StartWith(arg0Span, context.OrdinalIgnoreCase, false).MakeOptional(optional), + "equals" => SegmentBoundary.StringEquals(arg0Span, context.OrdinalIgnoreCase, true).MakeOptional(optional), + _ => throw new InvalidOperationException("Unreachable code") + }; + conditionConsumed = true; + } + // ends_with(str/char) - captures as part of the variable + // until(str/char) - captures but does not include as part of variable + if (functionName is "ends_with" or "until") + { + if (segmentEndLogic is { HasDefaultEndWhen: false }) + { + error = $"The segment {segmentText.ToString()} has multiple end conditions; do not mix ends_with and until conditions"; + return false; + } + if (segmentStartLogic is { MatchesEntireSegment: true }) + { + error = $"The segment {segmentText.ToString()} already uses equals(), no end condition is permitted."; + return false; + } + var optional = segmentEndLogic?.IsOptional ?? false; + segmentEndLogic = functionName switch + { + "ends_with" => SegmentBoundary.StringEquals(arg0Span, context.OrdinalIgnoreCase, true).MakeOptional(optional), + "until" => SegmentBoundary.StringEquals(arg0Span, context.OrdinalIgnoreCase, false).MakeOptional(optional), + _ => throw new InvalidOperationException("Unreachable code") + }; + conditionConsumed = true; + } + + } + } + // TODO: skip adding if if (!conditionConsumed) + if (conditionConsumed) + { + + } + conditions ??= new List(); + if (!TryParseCondition(context, conditions, functionName, args, out var condition, out error)) + { + //TODO: add more context to error + return false; + } + conditions.Add(condition.Value); + return true; + } + + private static bool TryParseCondition(MatchingContext context, + List conditions, string functionName, + List>? args, [NotNullWhen(true)]out StringCondition? condition, [NotNullWhen(false)] out string? error) + { + var c = StringCondition.TryParse(out var cError, functionName, args, context.OrdinalIgnoreCase); + if (c == null) + { + condition = null; + error = cError ?? throw new InvalidOperationException("Unreachable code"); + return false; + } + condition = c.Value; + error = null; + return true; + } + + + private static MatchSegment CreateLiteral(ReadOnlySpan literal, MatchingContext context) + { + return new MatchSegment(null, + SegmentBoundary.Literal(literal, context.OrdinalIgnoreCase), + SegmentBoundary.LiteralEnd, null); + } +} + + +internal readonly record struct SegmentBoundary( + SegmentBoundary.Flags Behavior, + SegmentBoundary.When On, + string? Chars, + char Char + ) +{ + + + public static SegmentBoundary LiteralEnd = new(Flags.None, When.SegmentFullyMatchedByStartBoundary, null, '\0'); + + public static SegmentBoundary DefaultStart = new(Flags.None, When.StartsNow, null, '\0'); + public static SegmentBoundary DefaultEnd = new(Flags.None, When.InheritFromNextSegment, null, '\0'); + public static SegmentBoundary StartWith(ReadOnlySpan asSpan, bool ordinalIgnoreCase, bool includeInVar) + { + if (asSpan.Length == 1 && + (!ordinalIgnoreCase || ExpressionParsingHelpers.IsCommonCaseInsensitiveChar(asSpan[0]))) + { + return new(includeInVar ? Flags.ConsumeAndInclude : Flags.ConsumeMatchingText, + When.AtChar, null, asSpan[0]); + } + return new(includeInVar ? Flags.ConsumeAndInclude : Flags.ConsumeMatchingText, + ordinalIgnoreCase ? When.AtStringIgnoreCase : When.AtString, asSpan.ToString(), '\0'); + } + public static SegmentBoundary Literal(ReadOnlySpan literal, bool ignoreCase) => + StringEquals(literal, ignoreCase, false); + public static SegmentBoundary StringEquals(ReadOnlySpan asSpan, bool ordinalIgnoreCase, bool includeInVar) + { + if (asSpan.Length == 1 && + (!ordinalIgnoreCase || ExpressionParsingHelpers.IsCommonCaseInsensitiveChar(asSpan[0]))) + { + return new(includeInVar ? Flags.ConsumeAndInclude : Flags.ConsumeMatchingText, + When.EqualsChar, null, asSpan[0]); + } + return new(includeInVar ? Flags.ConsumeAndInclude : Flags.ConsumeMatchingText, + ordinalIgnoreCase ? When.EqualsOrdinalIgnoreCase : When.EqualsOrdinal, asSpan.ToString(), '\0'); + } + public bool IsOptional => (Behavior & Flags.SegmentOptional) == Flags.SegmentOptional; + public bool HasDefaultStartWhen => On == When.StartsNow; + public bool HasDefaultEndWhen => On == When.InheritFromNextSegment; + + public bool MatchesEntireSegment => + On == When.EqualsOrdinal || On == When.EqualsOrdinalIgnoreCase || On == When.EqualsChar; + public SegmentBoundary MakeOptional(bool makeOptional) + => makeOptional ? new(Flags.SegmentOptional | Behavior, On, Chars, Char) : this; + public SegmentBoundary SetOptional(bool optional) + => new(optional ? Flags.SegmentOptional | Behavior : Behavior ^ Flags.SegmentOptional, On, Chars, Char); + + public bool SupportsScanning => + On != When.StartsNow && + On != When.EndOfInput && + On != When.SegmentFullyMatchedByStartBoundary && + On != When.InheritFromNextSegment && + On != When.AfterStart; + + public bool AsEndSegmentReliesOnSubsequentSegmentBoundary => + On != When.StartsNow && + On != When.EndOfInput && + On != When.SegmentFullyMatchedByStartBoundary && + On != When.InheritFromNextSegment && + On != When.AfterStart; + + + [Flags] + public enum Flags:byte + { + None = 0, + SegmentOptional = 1, + ConsumeMatchingText = 2, + IncludeMatchingTextInVariable = 4, + ConsumeAndInclude = ConsumeMatchingText | IncludeMatchingTextInVariable, + GraphemeAware = 8 + } + public enum When:byte + { + /// + /// Cannot be combined with Optional. + /// Cannot be used for determining the end of a segment. + /// + /// + StartsNow, + EndOfInput, + SegmentFullyMatchedByStartBoundary, + /// + /// The default for ends + /// + InheritFromNextSegment, + AfterStart, + AtChar, + AtAnyOfChars, + AtAbsenceOfChars, + AtString, + AtStringIgnoreCase, + EqualsOrdinal, + EqualsChar, + EqualsOrdinalIgnoreCase, + } + + +} + + + + diff --git a/src/Imazen.Routing/Matching/StringCondition.cs b/src/Imazen.Routing/Matching/StringCondition.cs new file mode 100644 index 0000000..0151b7f --- /dev/null +++ b/src/Imazen.Routing/Matching/StringCondition.cs @@ -0,0 +1,528 @@ +using System.Diagnostics.CodeAnalysis; +using EnumFastToStringGenerated; + +namespace Imazen.Routing.Matching; + + + +// after(/): optional/?, +// equals(string), everything/** + +// alpha, alphanumeric, alphalower, alphaupper, guid, hex, int, i32, only([a-zA-Z0-9_\:,]), only([^/]) len(3), +// length(3), length(0,3),starts_with_only(3,[a-z]), +// +// ends_with(.jpg|.png|.gif), includes(str), supported_image_type +// ends_with(.jpg|.png|.gif), includes(stra|strb), long, int_range( +// } + +public readonly record struct StringCondition +{ + private StringCondition(StringConditionKind stringConditionKind, char? c, string? str, CharacterClass? charClass, string[]? strArray, int? int1, int? int2) + { + this.stringConditionKind = stringConditionKind; + this.c = c; + this.str = str; + this.charClass = charClass; + this.strArray = strArray; + this.int1 = int1; + this.int2 = int2; + } + private static StringCondition? TryCreate(out string? error, StringConditionKind stringConditionKind, char? c, string? str, CharacterClass? charClass, string[]? strArray, int? int1, int? int2) + { + var condition = new StringCondition(stringConditionKind, c, str, charClass, strArray, int1, int2); + if (!condition.ValidateArgsPresent(out error)) + { + return null; + } + if (!condition.IsInitialized) + { + error = "StringCondition Kind.Uninitialized is not allowed"; + return null; + } + return condition; + } + + internal static StringCondition? TryParse(out string? error, string name, List>? args, bool useIgnoreCaseVariant) + { + + if (!KindLookup.Value.TryGetValue(name, out var kinds)) + { + error = $"Unknown condition kind '{name}'"; + return null; + } + + if (useIgnoreCaseVariant && !name.EndsWith("-i")) + { + var ignoreCaseName = $"{name}-i"; + if (KindLookup.Value.TryGetValue(ignoreCaseName, out var ignoreCaseKinds)) + { + kinds = ignoreCaseKinds; + } + } + foreach (var kind in kinds) + { + var condition = TryCreate(kind, name, args, out error); + if (condition == null) continue; + if (!condition.Value.ValidateArgsPresent(out error)) + { + throw new InvalidOperationException("Condition was created with invalid arguments"); + } + return condition; + } + error = $"Invalid arguments for condition '{name}'. Received: {args ?? []}"; + return null; + } + + private static StringCondition? TryCreate(StringConditionKind stringConditionKind, string name, List>? args, out string? error) + { + var expectedArgs = ForKind(stringConditionKind); + if (args == null) + { + if (HasFlagFast(expectedArgs, ExpectedArgs.None)) + { + error = null; + return new StringCondition(stringConditionKind, null, null, null, null, null, null); + } + error = $"Expected argument type {expectedArgs} for condition '{name}'; received none."; + return null; + } + + if (HasFlagFast(expectedArgs, ExpectedArgs.Int321OrInt322)) + { + // int 1 or int 2 or both are required + if (args.Count != 2) + { + error = $"Expected 2 arguments for condition '{name}'; received {args.Count}: {args}."; + return null; + } + var arg1Type = ExpressionParsingHelpers.GetArgType(args[0].Span); + var arg2Type = ExpressionParsingHelpers.GetArgType(args[1].Span); + bool arg1IsInt = (arg1Type & ExpressionParsingHelpers.ArgType.IntegerNumeric) > 0; + bool arg1IsEmpty = arg1Type == ExpressionParsingHelpers.ArgType.Empty; + bool arg2IsInt = (arg2Type & ExpressionParsingHelpers.ArgType.IntegerNumeric) > 0; + bool arg2IsEmpty = arg2Type == ExpressionParsingHelpers.ArgType.Empty; + + if (arg1IsEmpty && arg2IsEmpty) + { + error = $"Expected integer for first or second argument of condition '{name}'; received empty args for both."; + return null; + } + if (!arg1IsInt && !arg1IsInt) + { + error = $"Expected int for first argument of condition '{name}'; received '{args[0]}'."; + return null; + } + if (!arg2IsInt && !arg2IsInt) + { + error = $"Expected int for second argument of condition '{name}'; received '{args[1]}'."; + return null; + } + + int? int1 = null; + int? int2 = null; + if (arg1IsInt) + { +#if NET6_0_OR_GREATER + if (int.TryParse(args[0].Span, out var i)) +#else + if (int.TryParse(args[0].ToString(), out var i)) +#endif + { + int1 = i; + } + else + { + error = $"Expected int for first argument of condition '{name}'; received '{args[0]}'."; + return null; + } + } + if (arg2IsInt) + { +#if NET6_0_OR_GREATER + if (int.TryParse(args[1].Span, out var i)) +#else + if (int.TryParse(args[1].ToString(), out var i)) +#endif + { + int2 = i; + } + else + { + error = $"Expected int for second argument of condition '{name}'; received '{args[1]}'."; + return null; + } + } + error = null; + return new StringCondition(stringConditionKind, null, null, null, null, int1, int2); + } + if (args.Count != 1) + { + error = $"Expected 1 argument for condition '{name}'; received {args.Count}: {args}."; + return null; + } + var obj = TryParseArg(args[0], out error); + if (obj == null) + { + return null; + } + if (obj is char c) + { + if (HasFlagFast(expectedArgs, ExpectedArgs.Char)) + { + error = null; + return new StringCondition(stringConditionKind, c, null, null, null, null, null); + } + error = $"Unexpected char argument for condition '{name}'; received '{args[0]}'."; + return null; + } + if (obj is string str) + { + if (HasFlagFast(expectedArgs, ExpectedArgs.String)) + { + error = null; + return new StringCondition(stringConditionKind, null, str, null, null, null, null); + } + error = $"Unexpected string argument for condition '{name}'; received '{args[0]}'."; + return null; + } + if (obj is string[] strArray) + { + if (HasFlagFast(expectedArgs, ExpectedArgs.StringArray)) + { + error = null; + return new StringCondition(stringConditionKind, null, null, null, strArray, null, null); + } + error = $"Unexpected string array argument for condition '{name}'; received '{args[0]}'."; + return null; + } + if (obj is CharacterClass cc) + { + if (HasFlagFast(expectedArgs, ExpectedArgs.CharClass)) + { + error = null; + return new StringCondition(stringConditionKind, null, null, cc, null, null, null); + } + error = $"Unexpected char class argument for condition '{name}'; received '{args[0]}'."; + return null; + } + throw new NotImplementedException("Unexpected argument type"); + } + + private static string? TryParseString(ReadOnlySpan arg, out string? error) + { + // Some characters must be escaped + // ' " \ [ ] | , ( ) + error = null; + return arg.ToString(); + } + + private static object? TryParseArg(ReadOnlyMemory argMemory, out string? error) + { + var arg = argMemory.Span; + var type = ExpressionParsingHelpers.GetArgType(arg); + if ((type & ExpressionParsingHelpers.ArgType.CharClass) > 0) + { + if (!CharacterClass.TryParseInterned(argMemory,true, out var cc, out error)) + { + return null; + } + return cc; + } + if ((type & ExpressionParsingHelpers.ArgType.Array) > 0) + { + // use FindCharNotEscaped + var list = new List(); + var start = 0; + while (start < arg.Length) + { + var commaIndex = ExpressionParsingHelpers.FindCharNotEscaped(arg[start..], '|', '\\'); + if (commaIndex == -1) + { + var lastStr = TryParseString(arg[start..], out error); + if (lastStr == null) return null; + list.Add(lastStr); + break; + } + + var s = TryParseString(arg[start..(start + commaIndex)], out error); + if (s == null) return null; + list.Add(s); + start += commaIndex + 1; + } + error = null; + return list.ToArray(); + } + if ((type & ExpressionParsingHelpers.ArgType.Char) > 0) + { + if (arg.Length != 1) + { + error = "Expected a single character"; + return null; + } + error = null; + return arg[0]; + } + + if ((type & ExpressionParsingHelpers.ArgType.String) > 0) + { + return TryParseString(arg, out error); + } + throw new NotImplementedException(); + } + + private static readonly Lazy>> KindLookup = new Lazy>>(() => + { + var dict = new Dictionary>(); + foreach (var kind in StringConditionKindEnumExtensions.GetValuesFast()) + { + var key = kind.ToDisplayFast(); + if (!dict.TryGetValue(key, out var list)) + { + list = new List(); + dict[key] = list; + } + ((List)list).Add(kind); + } + return dict; + }); + + + public static StringCondition Uninitialized => new StringCondition(StringConditionKind.Uninitialized, null, null, null, null, null, null); + private bool IsInitialized => stringConditionKind != StringConditionKind.Uninitialized; + + private readonly StringConditionKind stringConditionKind; + private readonly char? c; + private readonly string? str; + private readonly CharacterClass? charClass; + private readonly string[]? strArray; + private readonly int? int1; + private readonly int? int2; + + public bool Evaluate(ReadOnlySpan text, MatchingContext context) => stringConditionKind switch + { + StringConditionKind.EnglishAlphabet => text.IsEnglishAlphabet(), + StringConditionKind.NumbersAndEnglishAlphabet => text.IsNumbersAndEnglishAlphabet(), + StringConditionKind.LowercaseEnglishAlphabet => text.IsLowercaseEnglishAlphabet(), + StringConditionKind.UppercaseEnglishAlphabet => text.IsUppercaseEnglishAlphabet(), + StringConditionKind.Hexadecimal => text.IsHexadecimal(), + StringConditionKind.Int32 => text.IsInt32(), + StringConditionKind.Int64 => text.IsInt64(), + StringConditionKind.EndsWithSupportedImageExtension => context.EndsWithSupportedImageExtension(text), + StringConditionKind.IntegerRange => text.IsInIntegerRangeInclusive(int1, int2), + StringConditionKind.Guid => text.IsGuid(), + StringConditionKind.CharLength => text.LengthWithinInclusive(int1, int2), + StringConditionKind.EqualsOrdinal => text.EqualsOrdinal(str!), + StringConditionKind.EqualsOrdinalIgnoreCase => text.EqualsOrdinalIgnoreCase(str!), + StringConditionKind.StartsWithChar => text.StartsWithChar(c!.Value), + StringConditionKind.StartsWithOrdinal => text.StartsWithOrdinal(str!), + StringConditionKind.StartsWithOrdinalIgnoreCase => text.StartsWithOrdinalIgnoreCase(str!), + StringConditionKind.StartsWithAnyOrdinal => text.StartsWithAnyOrdinal(strArray!), + StringConditionKind.StartsWithAnyOrdinalIgnoreCase => text.StartsWithAnyOrdinalIgnoreCase(strArray!), + StringConditionKind.EndsWithChar => text.EndsWithChar(c!.Value), + StringConditionKind.EndsWithOrdinal => text.EndsWithOrdinal(str!), + StringConditionKind.EndsWithOrdinalIgnoreCase => text.EndsWithOrdinalIgnoreCase(str!), + StringConditionKind.EndsWithAnyOrdinal => text.EndsWithAnyOrdinal(strArray!), + StringConditionKind.EndsWithAnyOrdinalIgnoreCase => text.EndsWithAnyOrdinalIgnoreCase(strArray!), + StringConditionKind.IncludesOrdinal => text.IncludesOrdinal(str!), + StringConditionKind.IncludesOrdinalIgnoreCase => text.IncludesOrdinalIgnoreCase(str!), + StringConditionKind.IncludesAnyOrdinal => text.IncludesAnyOrdinal(strArray!), + StringConditionKind.IncludesAnyOrdinalIgnoreCase => text.IncludesAnyOrdinalIgnoreCase(strArray!), + StringConditionKind.True => true, + StringConditionKind.CharClass => text.IsCharClass(charClass!), + StringConditionKind.StartsWithNCharClass => text.StartsWithNCharClass(charClass!, int1!.Value), + StringConditionKind.StartsWithCharClass => text.StartsWithCharClass(charClass!), + StringConditionKind.EndsWithCharClass => text.EndsWithCharClass(charClass!), + StringConditionKind.Uninitialized => throw new InvalidOperationException("Uninitialized StringCondition was evaluated"), + _ => throw new NotImplementedException() + }; + + private bool ValidateArgsPresent([NotNullWhen(false)] out string? error) + { + var expected = ForKind(stringConditionKind); + if (HasFlagFast(expected, ExpectedArgs.Char) != c.HasValue) + { + error = c.HasValue + ? "Unexpected char parameter in StringCondition" + : "char parameter missing in StringCondition"; + return false; + } + + if (HasFlagFast(expected, ExpectedArgs.String) != (str != null)) + { + error = (str != null) + ? "Unexpected string parameter in StringCondition" + : "string parameter missing in StringCondition"; + return false; + } + + if (HasFlagFast(expected, ExpectedArgs.StringArray) != (strArray != null)) + { + error = (strArray != null) + ? "Unexpected string array parameter in StringCondition" + : "string array parameter missing in StringCondition"; + return false; + } + + if (HasFlagFast(expected, ExpectedArgs.Int321) != int1.HasValue) + { + error = int1.HasValue + ? "Unexpected int parameter in StringCondition" + : "int parameter missing in StringCondition"; + return false; + } + + if (HasFlagFast(expected, ExpectedArgs.Int321OrInt322) != (int1.HasValue || int2.HasValue)) + { + error = (int1.HasValue || int2.HasValue) + ? "Unexpected int parameter in StringCondition" + : "int parameter missing in StringCondition"; + return false; + } + + if (HasFlagFast(expected, ExpectedArgs.CharClass) != (charClass != null)) + { + error = (charClass != null) + ? "Unexpected char class parameter in StringCondition" + : "char class parameter missing in StringCondition"; + return false; + } + + throw new NotImplementedException(); + } + + [Flags] + private enum ExpectedArgs + { + None = 0, + Char = 1, + String = 2, + StringArray = 4, + Int321 = 8, + Int321OrInt322 = 16, + CharClass = 32 + } + private static bool HasFlagFast(ExpectedArgs a, ExpectedArgs b) => (a & b) != 0; + + + private static ExpectedArgs ForKind(StringConditionKind stringConditionKind) => + stringConditionKind switch + { + StringConditionKind.StartsWithChar => ExpectedArgs.Char, + StringConditionKind.EndsWithChar => ExpectedArgs.Char, + StringConditionKind.EqualsOrdinal => ExpectedArgs.String, + StringConditionKind.EqualsOrdinalIgnoreCase => ExpectedArgs.String, + StringConditionKind.StartsWithOrdinal => ExpectedArgs.String, + StringConditionKind.StartsWithOrdinalIgnoreCase => ExpectedArgs.String, + StringConditionKind.EndsWithOrdinal => ExpectedArgs.String, + StringConditionKind.EndsWithOrdinalIgnoreCase => ExpectedArgs.String, + StringConditionKind.IncludesOrdinal => ExpectedArgs.String, + StringConditionKind.IncludesOrdinalIgnoreCase => ExpectedArgs.String, + StringConditionKind.StartsWithAnyOrdinal => ExpectedArgs.StringArray, + StringConditionKind.StartsWithAnyOrdinalIgnoreCase => ExpectedArgs.StringArray, + StringConditionKind.EndsWithAnyOrdinal => ExpectedArgs.StringArray, + StringConditionKind.EndsWithAnyOrdinalIgnoreCase => ExpectedArgs.StringArray, + StringConditionKind.IncludesAnyOrdinal => ExpectedArgs.StringArray, + StringConditionKind.IncludesAnyOrdinalIgnoreCase => ExpectedArgs.StringArray, + StringConditionKind.CharLength => ExpectedArgs.Int321, + StringConditionKind.IntegerRange => ExpectedArgs.Int321OrInt322, + StringConditionKind.CharClass => ExpectedArgs.CharClass, + StringConditionKind.StartsWithNCharClass => ExpectedArgs.CharClass | ExpectedArgs.Int321, + StringConditionKind.EnglishAlphabet => ExpectedArgs.None, + StringConditionKind.NumbersAndEnglishAlphabet => ExpectedArgs.None, + StringConditionKind.LowercaseEnglishAlphabet => ExpectedArgs.None, + StringConditionKind.UppercaseEnglishAlphabet => ExpectedArgs.None, + StringConditionKind.Hexadecimal => ExpectedArgs.None, + StringConditionKind.Int32 => ExpectedArgs.None, + StringConditionKind.Int64 => ExpectedArgs.None, + StringConditionKind.Guid => ExpectedArgs.None, + StringConditionKind.StartsWithCharClass => ExpectedArgs.CharClass, + StringConditionKind.EndsWithCharClass => ExpectedArgs.CharClass, + StringConditionKind.EndsWithSupportedImageExtension => ExpectedArgs.None, + StringConditionKind.Uninitialized => ExpectedArgs.None, + StringConditionKind.True => ExpectedArgs.None, + _ => throw new ArgumentOutOfRangeException(nameof(stringConditionKind), stringConditionKind, null) + }; +} + +[EnumGenerator] +public enum StringConditionKind: byte +{ + Uninitialized = 0, + [Display(Name = "true")] + True, + /// + /// Case-insensitive (a-zA-Z) + /// + [Display(Name = "alpha")] + EnglishAlphabet, + [Display(Name = "alphanumeric")] + NumbersAndEnglishAlphabet, + [Display(Name = "alpha-lower")] + LowercaseEnglishAlphabet, + [Display(Name = "alpha-upper")] + UppercaseEnglishAlphabet, + /// + /// Case-insensitive (a-f0-9A-F) + /// + [Display(Name = "hex")] + Hexadecimal, + [Display(Name = "int32")] + Int32, + [Display(Name = "integer")] + Int64, + [Display(Name = "integer-range")] + IntegerRange, + [Display(Name = "only")] + CharClass, + StartsWithNCharClass, + [Display(Name = "length")] + CharLength, + [Display(Name = "guid")] + Guid, + [Display(Name = "equals")] + EqualsOrdinal, + [Display(Name = "equals-i")] + EqualsOrdinalIgnoreCase, + [Display(Name = "starts-with")] + StartsWithOrdinal, + [Display(Name = "starts-with")] + StartsWithChar, + [Display(Name = "starts-with")] + StartsWithCharClass, + [Display(Name = "starts-with-i")] + StartsWithOrdinalIgnoreCase, + [Display(Name = "starts-with")] + StartsWithAnyOrdinal, + [Display(Name = "starts-with-i")] + StartsWithAnyOrdinalIgnoreCase, + [Display(Name = "ends-with")] + EndsWithOrdinal, + [Display(Name = "ends-with")] + EndsWithChar, + [Display(Name = "ends-with")] + EndsWithCharClass, + [Display(Name = "ends-with-i")] + EndsWithOrdinalIgnoreCase, + [Display(Name = "ends-with")] + EndsWithAnyOrdinal, + [Display(Name = "ends-with-i")] + EndsWithAnyOrdinalIgnoreCase, + [Display(Name = "includes")] + IncludesOrdinal, + [Display(Name = "includes-i")] + IncludesOrdinalIgnoreCase, + [Display(Name = "includes")] + IncludesAnyOrdinal, + [Display(Name = "includes-i")] + IncludesAnyOrdinalIgnoreCase, + [Display(Name = "supported-image-extension")] + EndsWithSupportedImageExtension +} + +[AttributeUsage( + AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Class, + AllowMultiple = false)] +internal sealed class DisplayAttribute : Attribute +{ + public string? Name { get; set; } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs b/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs new file mode 100644 index 0000000..ddb8983 --- /dev/null +++ b/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs @@ -0,0 +1,221 @@ +namespace Imazen.Routing.Matching; + +internal static class StringConditionMatchingHelpers +{ + internal static bool IsEnglishAlphabet(this ReadOnlySpan chars) + { + foreach (var c in chars) + { + switch (c) + { + case >= 'a' and <= 'z': + case >= 'A' and <= 'Z': + continue; + default: + return false; + } + } + return true; + } + internal static bool IsNumbersAndEnglishAlphabet(this ReadOnlySpan chars) + { + foreach (var c in chars) + { + switch (c) + { + case >= 'a' and <= 'z': + case >= 'A' and <= 'Z': + case >= '0' and <= '9': + continue; + default: + return false; + } + } + return true; + } + internal static bool IsLowercaseEnglishAlphabet(this ReadOnlySpan chars) + { + foreach (var c in chars) + { + switch (c) + { + case >= 'a' and <= 'z': + continue; + default: + return false; + } + } + return true; + } + internal static bool IsUppercaseEnglishAlphabet(this ReadOnlySpan chars) + { + foreach (var c in chars) + { + switch (c) + { + case >= 'A' and <= 'Z': + continue; + default: + return false; + } + } + return true; + } + internal static bool IsHexadecimal(this ReadOnlySpan chars) + { + foreach (var c in chars) + { + switch (c) + { + case >= 'a' and <= 'f': + case >= 'A' and <= 'F': + case >= '0' and <= '9': + continue; + default: + return false; + } + } + return true; + } +#if NET6_0_OR_GREATER + internal static bool IsInt32(this ReadOnlySpan chars) => int.TryParse(chars, out _); + internal static bool IsInt64(this ReadOnlySpan chars) => long.TryParse(chars, out _); + internal static bool IsInIntegerRangeInclusive(this ReadOnlySpan chars, int? min, int? max) + { + if (!int.TryParse(chars, out var value)) return false; + if (min != null && value < min) return false; + if (max != null && value > max) return false; + return true; + } + internal static bool IsGuid(this ReadOnlySpan chars) => Guid.TryParse(chars, out _); +#else + internal static bool IsGuid(this ReadOnlySpan chars) => Guid.TryParse(chars.ToString(), out _); + internal static bool IsInt32(this ReadOnlySpan chars) => int.TryParse(chars.ToString(), out _); + internal static bool IsInt64(this ReadOnlySpan chars) => long.TryParse(chars.ToString(), out _); + internal static bool IsInIntegerRangeInclusive(this ReadOnlySpan chars, int? min, int? max) + { + if (!int.TryParse(chars.ToString(), out var value)) return false; + if (min != null && value < min) return false; + if (max != null && value > max) return false; + return true; + } +#endif + + internal static bool IsOnlyCharsInclusive(this ReadOnlySpan chars, string[]? allowedChars) + { + if (allowedChars == null) return false; + foreach (var c in chars) + { + if (!allowedChars.Any(x => x.Contains(c))) return false; + } + return true; + } + internal static bool C(this ReadOnlySpan chars, string[]? disallowedChars) + { + if (disallowedChars == null) return false; + foreach (var c in chars) + { + if (disallowedChars.Any(x => x.Contains(c))) return false; + } + return true; + } + internal static bool LengthWithinInclusive(this ReadOnlySpan chars, int? min, int? max) + { + if (min != null && chars.Length < min) return false; + if (max != null && chars.Length > max) return false; + return true; + } + internal static bool EqualsOrdinal(this ReadOnlySpan chars, string value) => chars.Equals(value.AsSpan(), StringComparison.Ordinal); + internal static bool EqualsOrdinalIgnoreCase(this ReadOnlySpan chars, string value) => chars.Equals(value.AsSpan(), StringComparison.OrdinalIgnoreCase); + internal static bool StartsWithChar(this ReadOnlySpan chars, char c) => chars.Length > 0 && chars[0] == c; + internal static bool StartsWithOrdinal(this ReadOnlySpan chars, string value) => chars.StartsWith(value.AsSpan(), StringComparison.Ordinal); + internal static bool StartsWithOrdinalIgnoreCase(this ReadOnlySpan chars, string value) => chars.StartsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase); + + internal static bool StartsWithAnyOrdinal(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.StartsWith(value.AsSpan(), StringComparison.Ordinal)) return true; + } + return false; + } + internal static bool StartsWithAnyOrdinalIgnoreCase(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.StartsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + internal static bool EndsWithChar(this ReadOnlySpan chars, char c) => chars.Length > 0 && chars[^1] == c; + internal static bool EndsWithOrdinal(this ReadOnlySpan chars, string value) => chars.EndsWith(value.AsSpan(), StringComparison.Ordinal); + internal static bool EndsWithOrdinalIgnoreCase(this ReadOnlySpan chars, string value) => chars.EndsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase); + internal static bool EndsWithAnyOrdinal(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.EndsWith(value.AsSpan(), StringComparison.Ordinal)) return true; + } + return false; + } + internal static bool EndsWithAnyOrdinalIgnoreCase(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.EndsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + internal static bool IncludesOrdinal(this ReadOnlySpan chars, string value) => chars.IndexOf(value.AsSpan(), StringComparison.Ordinal) != -1; + internal static bool IncludesOrdinalIgnoreCase(this ReadOnlySpan chars, string value) => chars.IndexOf(value.AsSpan(), StringComparison.OrdinalIgnoreCase) != -1; + internal static bool IncludesAnyOrdinal(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.IndexOf(value.AsSpan(), StringComparison.Ordinal) != -1) return true; + } + return false; + } + internal static bool IncludesAnyOrdinalIgnoreCase(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.IndexOf(value.AsSpan(), StringComparison.OrdinalIgnoreCase) != -1) return true; + } + return false; + } + internal static bool IsCharClass(this ReadOnlySpan chars, CharacterClass charClass) + { + foreach (var c in chars) + { + if (!charClass.Contains(c)) return false; + } + return true; + } + internal static bool StartsWithNCharClass(this ReadOnlySpan chars, CharacterClass charClass, int n) + { + if (chars.Length < n) return false; + for (int i = 0; i < n; i++) + { + if (!charClass.Contains(chars[i])) return false; + } + return true; + } + internal static bool StartsWithCharClass(this ReadOnlySpan chars, CharacterClass charClass) + { + return chars.Length != 0 && charClass.Contains(chars[0]); + } + internal static bool EndsWithCharClass(this ReadOnlySpan chars, CharacterClass charClass) + { + return chars.Length != 0 && charClass.Contains(chars[^1]); + } + internal static bool EndsWithSupportedImageExtension(this MatchingContext context, ReadOnlySpan chars) + { + foreach (var ext in context.SupportedImageExtensions) + { + if (chars.EndsWith(ext.AsSpan(), StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + +} \ No newline at end of file diff --git a/src/Imazen.Routing/Matching/matching.md b/src/Imazen.Routing/Matching/matching.md new file mode 100644 index 0000000..c5d51a2 --- /dev/null +++ b/src/Imazen.Routing/Matching/matching.md @@ -0,0 +1,167 @@ +# Design of route matcher syntax + +We only want non-backtracking functionality. +all conditions are AND, and variable strings are parsed before conditions are applied, with the following exceptions: +after, until. +If a condition lacks until, it is taken from the following character. + + + +Variables in match strings will be +{name:condition1:condition2} +They will terminate their matching when the character that follows them is reached. We explain that variables implictly match until their last character + +"/images/{id:int:until(/):optional}seoname" +"/images/{id:int}/seoname" +"/image_{id:int}_seoname" +"/image_{id:int}_{w:int}_seoname" +"/image_{id:int}_{w:int:until(_):optional}seoname" +"/image_{id:int}_{w:int:until(_)}/{**}" + +A trailing ? means the variable (and its trailing character (leading might be also useful?)) is optional. + +Partial matches +match_path="/images/{path:**}" +remove_matched_part_for_children + +or +consume_prefix "/images/" + + +match_path_extension +match_path +match_path_and_query +match_query + + +Variables can be inserted in target strings using ${name:transform} +where transform can be `lower`, `upper`, `trim`, `trim(a-zA-Z\t\:\\-)) + + +## conditions + +alpha, alphanumeric, alphalower, alphaupper, guid, hex, int, only([a-zA-Z0-9_\:\,]), only(^/) len(3), length(3), length(0,3),starts_with_only(3,a-z), until(/), after(/): optional/?, equals(string), everything/** + ends_with((.jpg|.png|.gif)), includes(), supported_image_type +ends_with(.jpg|.png|.gif), until(), after(), includes(), + +until and after specify trailing and leading characters that are part of the matching group, but are only useful if combined with `optional`. + +TODO: sha256/auth stuff + + +respond_400_on_variable_condition_failure=true +process_image=true +pass_throgh=true +allow_pass_through=true +stop_here=true +case_sensitive=true/false (IIS/ASP.NET default to insensitive, but it's a bad default) + +[routes.accepts_any] +accept_header_has_type="*/*" +add_query_value="accepts=*" +set_query_value="format=auto" + +[routes.accepts_webp] +accept_header_has_type="image/webp" +add_query_value="accepts=webp" +set_query_value="format=auto" + +[routes.accepts_avif] +accept_header_has_type="image/avif" +add_query_value="accepts=avif" +set_query_value="format=auto" + + +# Escaping characters + +JSON/TOML escapes include +\" +\\ +\/ (JSON only) +\b +\f +\n +\r +\t +\u followed by four-hex-digits +\UXXXXXXXX - unicode (U+XXXXXXXX) (TOML only?) + +Test with url decoded foreign strings too + +# Real-world examples + +Setting a max size by folder or authentication status.. + +We were thinking we could save as a GUID and have some mapping, where we asked for image “St-Croix-Legend-Extreme-Rod….” and we somehow did a lookup to see what the actual GUID image name was but seems we would introduce more issues, like needing to make ImageResizer and handler and not a module to do the lookup and creating an extra database call per image. Doesn’t seem like a great solution, any ideas? Just use the descriptive name? + +2. You're free to use any URL rewriting solution, the provided Config.Current.Pipeline.Rewrite event, or the included Presets plugin: http://imageresizing.net/plugins/presets + +You can also add a Rewrite handler and check the HOST header if you wanted +to use subdomains instead of prefixes. Prefixes are probably better though. + + +RewriteLog D:\LogFiles\iirf_g4\iirfLogG4.out +RewriteFilterPriority MEDIUM +RewriteLogLevel 0 + +#skus +RewriteRule ^/.*~p~((\w{3}).*)~(\d+)~(\d+)(\.\d+)?\.jpg /sku/$2/$1.jpg.ashx?width=$3&quality=$4 [I,L] + +/{:*}~p~{sku_folder:length(3):*}{sku_file:*}~{width:int}~{quality:int}{ignored:after(.):int}.jpg + +RewriteRule ^/.*~p~((\w{3}).*)~(\d+)(\.\d+)?\.jpg /sku/$2/$1.jpg.ashx?width=$3 [I,L] + +/{:*}~p~{sku_folder:length(3):*}{sku_file:*}~{width:int}{ignored:after(.):int}.jpg + +RewriteRule ^/.*~p~((\w{3}).*)~(\d+)x(\d+)(\.\d+)?\.jpg /sku/$2/$1.jpg.ashx?width=$3&height=$4 [I,L] + +#alt images +RewriteRule ^/.*~a~((\w{3}).*)~(\d+)(\.\d+)?\.jpg /alt/$2/$1.jpg.ashx?width=$3 [I,L] +RewriteRule ^/.*~a~((\w{3}).*)~(\d+)x(\d+)(\.\d+)?\.jpg /alt/$2/$1.jpg.ashx?width=$3&height=$4 [I,L] + +#brands +RewriteRule ^/.*~b~(.*)~(\d+)(\.\d+)?\.jpg /brand/$1.jpg.ashx?width=$2&height=$3 [I,L] +RewriteRule ^/.*~b~(.*)~(\d+)x(\d+)(\.\d+)?\.jpg /brand/$1.jpg.ashx?width=$2 [I,L] + +#customer products +RewriteRule ^/.*~c~((\w{3}).*)~(\d+)~(\d+)(\.\d+)?\.(\w+)(\?(.+)) /customer/product/$2/$1.$3.$6.ashx?width=$4&height=$4&$8 [I,L] +RewriteRule ^/.*~c~((\w{3}).*)~(\d+)~(\d+)(\.\d+)?\.(\w+) /customer/product/$2/$1.$3.$6.ashx?width=$4&height=$4 [I,L] + +#Legacy erez rewrites +RewriteRule ^/erez4/erez\?src=BrandImages/(.*)\.tif&tmp=BrandG4& +/brand/$1.jpg.ashx?width=160&height=100 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=Small& /sku/$2/$1.jpg.ashx?width=55 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=OMS& /sku/$2/$1.jpg.ashx?width=70 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=SmallThumbnail& /sku/$2/$1.jpg.ashx?width=80 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=Thumbnail& /sku/$2/$1.jpg.ashx?width=120 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=EcardThumbnail& /sku/$2/$1.jpg.ashx?width=120 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=Medium& /sku/$2/$1.jpg.ashx?width=178 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=Checkout& /sku/$2/$1.jpg.ashx?width=114 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=CheckoutZoom& /sku/$2/$1.jpg.ashx?width=228 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=Large& /sku/$2/$1.jpg.ashx?width=400 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=Fullsize& /sku/$2/$1.jpg.ashx?width=600 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=ExtraSmallG4& /sku/$2/$1.jpg.ashx?width=60 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=SmallG4& /sku/$2/$1.jpg.ashx?width=80 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=SmallMediumG4& /sku/$2/$1.jpg.ashx?width=100 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=MediumG4& /sku/$2/$1.jpg.ashx?width=120 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=MediumLargeG4& /sku/$2/$1.jpg.ashx?width=160 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=MediumLargerG4& /sku/$2/$1.jpg.ashx?width=220 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=LargeSmallG4& /sku/$2/$1.jpg.ashx?width=250 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=LargeG4& /sku/$2/$1.jpg.ashx?width=340 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=ZoomG4& /sku/$2/$1.jpg.ashx?width=1500 [I,L] +RewriteRule ^/erez4/erez\?src=ProductImages/((\w{3}).*)\.tif&tmp=LargeMobile& /sku/$2/$1.jpg.ashx?width=320 [I,L] + + + +## Example Accept header values +image/avif,image/webp,*/* +image/webp,*/* +*/* +image/png,image/*;q=0.8,*/*;q=0.5 +image/webp,image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5 +image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5 +image/avif,image/webp,image/apng,image/*,*/*;q=0.8 + +video +video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5 audio/webm,audio/ogg,audio/wav,audio/*;q=0.9,application/ogg;q=0.7,video/*;q=0.6,*/*;q=0.5 +*/* \ No newline at end of file diff --git a/src/Imazen.Routing/packages.lock.json b/src/Imazen.Routing/packages.lock.json index 8cfdce4..7e036ca 100644 --- a/src/Imazen.Routing/packages.lock.json +++ b/src/Imazen.Routing/packages.lock.json @@ -49,6 +49,12 @@ "resolved": "1.14.1", "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" }, + "Supernova.Enum.Generators": { + "type": "Direct", + "requested": "[1.*, )", + "resolved": "1.0.13", + "contentHash": "8Oj8BebIgklaFXE7oVmal5i6ahsN9K926ycicgUj8+RzHG6dVa0q4DP4bZ0PCWYPTxG3ap66mZXFnFmdnjBWlw==" + }, "System.Collections.Immutable": { "type": "Direct", "requested": "[6.*, )", @@ -258,6 +264,12 @@ "resolved": "1.14.1", "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" }, + "Supernova.Enum.Generators": { + "type": "Direct", + "requested": "[1.*, )", + "resolved": "1.0.13", + "contentHash": "8Oj8BebIgklaFXE7oVmal5i6ahsN9K926ycicgUj8+RzHG6dVa0q4DP4bZ0PCWYPTxG3ap66mZXFnFmdnjBWlw==" + }, "System.Collections.Immutable": { "type": "Direct", "requested": "[6.*, )", @@ -414,6 +426,12 @@ "resolved": "1.14.1", "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" }, + "Supernova.Enum.Generators": { + "type": "Direct", + "requested": "[1.*, )", + "resolved": "1.0.13", + "contentHash": "8Oj8BebIgklaFXE7oVmal5i6ahsN9K926ycicgUj8+RzHG6dVa0q4DP4bZ0PCWYPTxG3ap66mZXFnFmdnjBWlw==" + }, "System.Collections.Immutable": { "type": "Direct", "requested": "[6.*, )",