diff --git a/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs b/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs index ce72ec0..3a804ac 100644 --- a/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs +++ b/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs @@ -107,11 +107,12 @@ internal static bool ValidateSegmentName(string name, ReadOnlySpan segment { if (name.Length == 0) { - error = "Don't use empty segment names, only null or valid"; + error = "Don't use empty segment names, only null or valid (internal error, callers should filter empty names)"; return false; } if (name.Contains('*') || name.Contains('?')) { + // Nullified by external logic, actually. error = $"Invalid segment expression {{{segmentExpression.ToString()}}} Conditions and modifiers such as * and ? belong after the colon. Ex: {{name:*:?}} "; return false; @@ -121,6 +122,11 @@ internal static bool ValidateSegmentName(string name, ReadOnlySpan segment 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; } + if (StringCondition.IsReservedName(name)) + { + error = $"Did you forget to put : before your condition? '{name}' cannot be used as a variable name in {{{segmentExpression.ToString()}}} (for clarity), since it has a function."; + return false; + } error = null; return true; } diff --git a/src/Imazen.Routing/Matching/MatchExpression.cs b/src/Imazen.Routing/Matching/MatchExpression.cs index f2361d9..d98ca59 100644 --- a/src/Imazen.Routing/Matching/MatchExpression.cs +++ b/src/Imazen.Routing/Matching/MatchExpression.cs @@ -5,7 +5,7 @@ namespace Imazen.Routing.Matching; - +// TODO: split into data used during runtime and during parsing.. public record MatchingContext { public bool OrdinalIgnoreCase { get; init; } @@ -21,6 +21,11 @@ public record MatchingContext OrdinalIgnoreCase = false, SupportedImageExtensions = new []{"jpg", "jpeg", "png", "gif", "webp"} }; + + /// + /// If true, all segments will capture the / character by default. If false, segments must specify {:**} to capture slashes. + /// + public bool CaptureSlashesByDefault { get; init; } } public partial record class MatchExpression @@ -181,6 +186,22 @@ public bool TryMatchVerbose(in MatchingContext context, in ReadOnlyMemory return true; } + public MatchExpressionSuccess MatchOrThrow(in MatchingContext context, in ReadOnlyMemory input) + { + var matched = this.TryMatchVerbose(context, input, out var result, out var error); + if (!matched) + { + throw new ArgumentException($"Expression {this} incorrectly failed to match {input} with error {error}"); + } + return result!.Value; + } + + public Dictionary CaptureDictOrThrow(in MatchingContext context, string input) + { + var match = MatchOrThrow(context, input.AsMemory()); + return match.Captures!.ToDictionary(x => x.Name, x => x.Value.ToString()); + } + public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, [NotNullWhen(true)] out MatchExpressionSuccess? result, [NotNullWhen(false)] out string? error, [NotNullWhen(false)] out int? failingSegmentIndex) { @@ -206,6 +227,7 @@ public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, var boundaryStarts = -1; var boundaryFinishes = -1; var foundBoundaryOrEnd = false; + SegmentBoundary foundBoundary = default; var closingBoundary = false; // No more segments to try? if (currentSegment >= Segments.Length) @@ -215,6 +237,7 @@ public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, // We still have an open segment, so we close it and capture it. boundaryStarts = boundaryFinishes = inputSpan.Length; foundBoundaryOrEnd = true; + foundBoundary = default; closingBoundary = true; }else if (remainingInput.Length == 0) { @@ -282,10 +305,11 @@ public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, boundaryStarts = s == -1 ? -1 : charactersConsumed + s; boundaryFinishes = f == -1 ? -1 : charactersConsumed + f; foundBoundaryOrEnd = searchResult; + foundBoundary = searchSegment; } if (!foundBoundaryOrEnd) { - + foundBoundary = default; if (Segments[currentSegment].IsOptional) { // We didn't find the segment, but it's optional, so we can skip it. @@ -318,7 +342,12 @@ public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, var variableStart = openSegment.StartsOn.IncludesMatchingTextInVariable ? openSegmentAbsoluteStart : openSegmentAbsoluteEnd; - var variableEnd = boundaryStarts; + + var variableEnd = (foundBoundary != default && foundBoundary.IsEndingBoundary && + foundBoundary.IncludesMatchingTextInVariable) + ? boundaryFinishes + : boundaryStarts; + var conditionsOk = openSegment.ConditionsMatch(context, inputSpan[variableStart..variableEnd]); if (!conditionsOk) { @@ -431,9 +460,9 @@ internal static bool TryParseSegmentExpression(MatchingContext context, } // it's a literal // Check for invalid characters like & - if (expr.IndexOfAny(new[] {'*', '?'}) != -1) + if (expr.IndexOf('*') != -1 || expr.IndexOf('?') != -1) { - error = "Literals cannot contain * or ? operators, they must be enclosed in {} such as {name:?} or {name:*:?}"; + error = "Literals cannot contain * or ? operators, they must be enclosed in {} such as {name:?} or {name:**:?}"; segment = null; return false; } @@ -534,11 +563,20 @@ private static bool TryParseConditionOrSegment(MatchingContext context, var makeOptional = (globChars & ExpressionParsingHelpers.GlobChars.Optional) == ExpressionParsingHelpers.GlobChars.Optional || conditionSpan.Is("optional"); + var hasDoubleStar = (globChars & ExpressionParsingHelpers.GlobChars.DoubleStar) == + ExpressionParsingHelpers.GlobChars.DoubleStar; if (makeOptional) { segmentStartLogic ??= SegmentBoundary.DefaultStart; segmentStartLogic = segmentStartLogic.Value.SetOptional(true); } + if (!hasDoubleStar && context.CaptureSlashesByDefault) + { + conditions ??= new List(); + // exclude '/' from chars + conditions.Add(StringCondition.ExcludeForwardSlash); + } + // We ignore the glob chars, they don't constrain behavior any. if (globChars != ExpressionParsingHelpers.GlobChars.None @@ -554,6 +592,8 @@ private static bool TryParseConditionOrSegment(MatchingContext context, } var functionName = functionNameMemory.ToString() ?? throw new InvalidOperationException("Unreachable code"); + + var conditionConsumed = false; if (args is { Count: 1 }) { @@ -570,7 +610,7 @@ private static bool TryParseConditionOrSegment(MatchingContext context, { if (segmentEndLogic is { HasDefaultEndWhen: false }) { - error = $"The segment {segmentText.ToString()} has conflicting end conditions; do not mix equals and ends-with and suffix conditions"; + error = $"The segment {segmentText.ToString()} has conflicting end conditions; do not mix equals, length, ends-with, and suffix conditions"; return false; } segmentEndLogic = sb; @@ -597,6 +637,7 @@ private static bool TryParseConditionOrSegment(MatchingContext context, //TODO: add more context to error return false; } + conditions.Add(condition.Value); } return true; @@ -657,7 +698,8 @@ private enum SegmentBoundaryFunction IgnoreCase = 16, IncludeInVar = 32, EndingBoundary = 64, - SegmentOptional = 128 + SegmentOptional = 128, + FixedLength = 256 } private static SegmentBoundaryFunction FromString(string name, bool useIgnoreCaseVariant, bool segmentOptional) @@ -669,6 +711,7 @@ private static SegmentBoundaryFunction FromString(string name, bool useIgnoreCas "ends_with" or "ends-with" or "ends" => SegmentBoundaryFunction.StartsWith | SegmentBoundaryFunction.IncludeInVar | SegmentBoundaryFunction.EndingBoundary, "prefix" => SegmentBoundaryFunction.StartsWith, "suffix" => SegmentBoundaryFunction.StartsWith | SegmentBoundaryFunction.EndingBoundary, + "len" or "length" => SegmentBoundaryFunction.FixedLength | SegmentBoundaryFunction.EndingBoundary | SegmentBoundaryFunction.IncludeInVar, _ => SegmentBoundaryFunction.None }; if (fn == SegmentBoundaryFunction.None) @@ -681,6 +724,11 @@ private static SegmentBoundaryFunction FromString(string name, bool useIgnoreCas } if (segmentOptional) { + if (fn == SegmentBoundaryFunction.FixedLength) + { + // When a fixed length segment is optional, we don't make a end boundary for it. + return SegmentBoundaryFunction.None; + } fn |= SegmentBoundaryFunction.SegmentOptional; } return fn; @@ -688,6 +736,8 @@ private static SegmentBoundaryFunction FromString(string name, bool useIgnoreCas public static SegmentBoundary Literal(ReadOnlySpan literal, bool ignoreCase) => StringEquals(literal, ignoreCase, false); + + public static SegmentBoundary LiteralEnd = new(Flags.EndingBoundary, When.SegmentFullyMatchedByStartBoundary, null, '\0'); @@ -745,17 +795,20 @@ public static bool TryCreate(string function, bool useIgnoreCase, bool segmentOp private static bool TryCreate(SegmentBoundaryFunction function, ReadOnlySpan arg0, out SegmentBoundary? result) { var argType = ExpressionParsingHelpers.GetArgType(arg0); + if ((argType & ExpressionParsingHelpers.ArgType.String) == 0) { result = null; return false; } + var includeInVar = (function & SegmentBoundaryFunction.IncludeInVar) == SegmentBoundaryFunction.IncludeInVar; var ignoreCase = (function & SegmentBoundaryFunction.IgnoreCase) == SegmentBoundaryFunction.IgnoreCase; var startsWith = (function & SegmentBoundaryFunction.StartsWith) == SegmentBoundaryFunction.StartsWith; var equals = (function & SegmentBoundaryFunction.Equals) == SegmentBoundaryFunction.Equals; var segmentOptional = (function & SegmentBoundaryFunction.SegmentOptional) == SegmentBoundaryFunction.SegmentOptional; var endingBoundary = (function & SegmentBoundaryFunction.EndingBoundary) == SegmentBoundaryFunction.EndingBoundary; + var segmentFixedLength = (function & SegmentBoundaryFunction.FixedLength) == SegmentBoundaryFunction.FixedLength; if (startsWith) { result = StartWith(arg0, ignoreCase, includeInVar, endingBoundary).SetOptional(segmentOptional); @@ -767,6 +820,25 @@ private static bool TryCreate(SegmentBoundaryFunction function, ReadOnlySpan 0) + { + //parse the number into char + var len = int.Parse(arg0.ToString()); + result = FixedLengthEnd(len); + return true; + } + result = null; + return false; + } throw new InvalidOperationException("Unreachable code"); } @@ -801,7 +873,16 @@ private static SegmentBoundary StringEquals(ReadOnlySpan asSpan, bool ordi return new(includeInVar ? Flags.IncludeMatchingTextInVariable : Flags.None, ordinalIgnoreCase ? When.EqualsOrdinalIgnoreCase : When.EqualsOrdinal, asSpan.ToString(), '\0'); } - + private static SegmentBoundary FixedLengthEnd(int length) + { + if (length < 1) throw new ArgumentOutOfRangeException(nameof(length) + , "Fixed length must be greater than 0"); + if (length > char.MaxValue) throw new ArgumentOutOfRangeException(nameof(length) + , "Fixed length must be less than or equal to " + char.MaxValue); + return new SegmentBoundary(Flags.IncludeMatchingTextInVariable | Flags.EndingBoundary, + When.FixedLength + , null, (char)length); + } [Flags] private enum Flags : byte { @@ -833,6 +914,7 @@ private enum When : byte EqualsOrdinal, EqualsChar, EqualsOrdinalIgnoreCase, + FixedLength, } @@ -858,6 +940,14 @@ public bool TryMatch(ReadOnlySpan text, out int start, out int end) if (text.Length == 0) return false; switch (On) { + case When.FixedLength: + if (text.Length >= this.Char) + { + start = 0; + end = this.Char; + return true; + } + return false; case When.AtChar or When.EqualsChar: if (text[0] == Char) { @@ -912,6 +1002,14 @@ public bool TryScan(ReadOnlySpan text, out int start, out int end) if (text.Length == 0) return false; switch (On) { + case When.FixedLength: + if (text.Length >= this.Char) + { + start = this.Char; + end = this.Char; + return true; + } + return false; case When.AtChar or When.EqualsChar: var index = text.IndexOf(Char); if (index == -1) return false; @@ -959,10 +1057,15 @@ public override string ToString() ? (isStartBoundary ? "starts-with" : "ends-with") : (isStartBoundary ? "prefix" : "suffix"), When.EqualsOrdinal or When.EqualsChar or When.EqualsOrdinalIgnoreCase => "equals", + When.FixedLength => $"len", _ => throw new InvalidOperationException("Unreachable code") }; var ignoreCase = On is When.AtStringIgnoreCase or When.EqualsOrdinalIgnoreCase ? "-i" : ""; var optional = (Behavior & Flags.SegmentOptional) != 0 ? "?": ""; + if (On == When.FixedLength) + { + return $"{name}({(int)Char}){ignoreCase}{optional}"; + } if (Chars != null) { name = $"{name}({Chars}){ignoreCase}{optional}"; diff --git a/src/Imazen.Routing/Matching/StringCondition.cs b/src/Imazen.Routing/Matching/StringCondition.cs index 55f8fc0..cbb574b 100644 --- a/src/Imazen.Routing/Matching/StringCondition.cs +++ b/src/Imazen.Routing/Matching/StringCondition.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Text; using EnumFastToStringGenerated; @@ -28,6 +29,10 @@ private StringCondition(StringConditionKind stringConditionKind, char? c, string this.int1 = int1; this.int2 = int2; } + + internal static StringCondition ExcludeForwardSlash = new (StringConditionKind.CharClass, null, null, + new CharacterClass(true, new ReadOnlyCollection(new CharacterClass.CharRange[]{}), + new ReadOnlyCollection(new[] {'/'})), null, null, null); 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); @@ -42,6 +47,11 @@ private StringCondition(StringConditionKind stringConditionKind, char? c, string } return condition; } + + internal static bool IsReservedName(string name) + { + return TryGetKindsForConditionAlias(name, true, out _); + } private static bool TryGetKindsForConditionAlias(string name, bool useIgnoreCaseVariant, [NotNullWhen(true)] out IReadOnlyCollection? kinds) { var normalized = StringConditionKindAliases.NormalizeAliases(name); @@ -106,7 +116,8 @@ private static bool TryGetKindsForConditionAlias(string name, bool useIgnoreCase var obj = TryParseArg(args[0], wantsFirstArgNumeric, firstOptional, out error); if (error != null) { - throw new InvalidOperationException($"Error parsing 1st argument: {error}"); + error = $"Error parsing 1st argument: {error}"; + return null; } var twoIntArgs = HasFlagsFast(expectedArgs, ExpectedArgs.Int321OrInt322); var intArgAndClassArg = HasFlagsFast(expectedArgs, ExpectedArgs.Int321 | ExpectedArgs.CharClass); @@ -217,7 +228,14 @@ private static bool TryGetKindsForConditionAlias(string name, bool useIgnoreCase error = $"Unexpected char class argument for condition '{name}'; received '{args[0]}'."; return null; } - throw new NotImplementedException("Unexpected argument type"); + + if (twoIntArgs && obj is null) + { + var int2 = obj2 as int?; + error = null; + return new StringCondition(stringConditionKind, null, null, null, null, null, int2); + } + throw new NotImplementedException("Unexpected argument type: " + obj?.GetType()); } private static string? TryParseString(ReadOnlySpan arg, out string? error) @@ -351,6 +369,8 @@ private static bool TryGetKindsForConditionAlias(string name, bool useIgnoreCase StringConditionKind.Hexadecimal => text.IsHexadecimal(), StringConditionKind.Int32 => text.IsInt32(), StringConditionKind.Int64 => text.IsInt64(), + StringConditionKind.UInt32 => text.IsU32(), + StringConditionKind.UInt64 => text.IsU64(), StringConditionKind.EndsWithSupportedImageExtension => context.EndsWithSupportedImageExtension(text), StringConditionKind.IntegerRange => text.IsInIntegerRangeInclusive(int1, int2), StringConditionKind.Guid => text.IsGuid(), @@ -377,7 +397,9 @@ private static bool TryGetKindsForConditionAlias(string name, bool useIgnoreCase StringConditionKind.CharClass => text.IsCharClass(charClass!), StringConditionKind.StartsWithNCharClass => text.StartsWithNCharClass(charClass!, int1!.Value), StringConditionKind.StartsWithCharClass => text.StartsWithCharClass(charClass!), + StringConditionKind.StartsWithCharClassIgnoreCase => text.StartsWithCharClass(charClass!), // TODO: doesn't ignore case, but we transform calls StringConditionKind.EndsWithCharClass => text.EndsWithCharClass(charClass!), + StringConditionKind.EndsWithCharClassIgnoreCase => text.EndsWithCharClass(charClass!), // TODO: doesn't ignore case, but we transform calls StringConditionKind.Uninitialized => throw new InvalidOperationException("Uninitialized StringCondition was evaluated"), _ => throw new NotImplementedException() }; @@ -491,9 +513,13 @@ private static ExpectedArgs ForKind(StringConditionKind stringConditionKind) => StringConditionKind.Hexadecimal => ExpectedArgs.None, StringConditionKind.Int32 => ExpectedArgs.None, StringConditionKind.Int64 => ExpectedArgs.None, + StringConditionKind.UInt32 => ExpectedArgs.None, + StringConditionKind.UInt64 => ExpectedArgs.None, StringConditionKind.Guid => ExpectedArgs.None, StringConditionKind.StartsWithCharClass => ExpectedArgs.CharClass, + StringConditionKind.StartsWithCharClassIgnoreCase => ExpectedArgs.CharClass, StringConditionKind.EndsWithCharClass => ExpectedArgs.CharClass, + StringConditionKind.EndsWithCharClassIgnoreCase => ExpectedArgs.CharClass, StringConditionKind.EndsWithSupportedImageExtension => ExpectedArgs.None, StringConditionKind.Uninitialized => ExpectedArgs.None, StringConditionKind.True => ExpectedArgs.None, @@ -505,6 +531,7 @@ public bool IsMatch(ReadOnlySpan varSpan, int i, int varSpanLength) throw new NotImplementedException(); } + // ToString should return function call syntax public override string ToString() { @@ -558,10 +585,13 @@ internal static string NormalizeAliases(string name) return name switch { "int" => "int32", + "uint" => "uint32", "hexadecimal" => "hex", "integer" => "int32", "long" => "int64", "i32" => "int32", + "u32" => "uint32", + "u64" => "uint64", "i64" => "int64", "integer-range" => "range", "only" => "allowed-chars", @@ -616,6 +646,10 @@ public enum StringConditionKind: byte Int32, [Display(Name = "int64")] Int64, + [Display(Name = "uint32")] + UInt32, + [Display(Name = "uint64")] + UInt64, [Display(Name = "range")] IntegerRange, [Display(Name = "allowed-chars")] @@ -641,6 +675,8 @@ public enum StringConditionKind: byte [Display(Name = "starts-with")] StartsWithCharClass, [Display(Name = "starts-with-i")] + StartsWithCharClassIgnoreCase, + [Display(Name = "starts-with-i")] StartsWithOrdinalIgnoreCase, [Display(Name = "starts-with")] StartsWithAnyOrdinal, @@ -653,6 +689,8 @@ public enum StringConditionKind: byte [Display(Name = "ends-with")] EndsWithCharClass, [Display(Name = "ends-with-i")] + EndsWithCharClassIgnoreCase, + [Display(Name = "ends-with-i")] EndsWithOrdinalIgnoreCase, [Display(Name = "ends-with")] EndsWithAnyOrdinal, diff --git a/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs b/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs index be87bfa..5cd101c 100644 --- a/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs +++ b/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs @@ -79,7 +79,9 @@ internal static bool IsHexadecimal(this ReadOnlySpan chars) } #if NET6_0_OR_GREATER internal static bool IsInt32(this ReadOnlySpan chars) => int.TryParse(chars, out _); + internal static bool IsU32(this ReadOnlySpan chars) => uint.TryParse(chars, out _); internal static bool IsInt64(this ReadOnlySpan chars) => long.TryParse(chars, out _); + internal static bool IsU64(this ReadOnlySpan chars) => ulong.TryParse(chars, out _); internal static bool IsInIntegerRangeInclusive(this ReadOnlySpan chars, int? min, int? max) { if (!int.TryParse(chars, out var value)) return false; @@ -92,7 +94,9 @@ internal static bool IsInIntegerRangeInclusive(this ReadOnlySpan chars, in #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 IsU32(this ReadOnlySpan chars) => uint.TryParse(chars.ToString(), out _); internal static bool IsInt64(this ReadOnlySpan chars) => long.TryParse(chars.ToString(), out _); + internal static bool IsU64(this ReadOnlySpan chars) => ulong.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; diff --git a/tests/ImazenShared.Tests/Routing/Matching/MatchExpressionTests.cs b/tests/ImazenShared.Tests/Routing/Matching/MatchExpressionTests.cs index 91b6150..d86321b 100644 --- a/tests/ImazenShared.Tests/Routing/Matching/MatchExpressionTests.cs +++ b/tests/ImazenShared.Tests/Routing/Matching/MatchExpressionTests.cs @@ -27,8 +27,16 @@ public void TestCaseSensitivity(bool isMatch, bool caseSensitive, string path) [Theory] [InlineData(true, "/{name}/{country}{:(/):?}", "/hi/usa", "/hi/usa/")] + [InlineData(true, "/{name}/{country:ends(/)}", "/hi/usa/")] + [InlineData(true, "{int}", "-1300")] + [InlineData(false, "{uint}", "-1300")] + [InlineData(true, "{int:range(-1000,1000)}", "-1000", "1000")] + [InlineData(false, "{int:range(-1000,1000)}", "-1001", "1001")] + + [InlineData(true, "/{name}/{country:suffix(/)}", "/hi/usa/")] [InlineData(true, "/{name}/{country}{:eq(/):optional}", "/hi/usa", "/hi/usa/")] [InlineData(true, "/{name}/{country:len(3)}", "/hi/usa")] + [InlineData(true, "/{name}/{country:len(3)}/{state:len(2)}", "/hi/usa/CO")] [InlineData(false, "/{name}/{country:length(3)}", "/hi/usa2")] [InlineData(false, "/{name}/{country:length(3)}", "/hi/usa/")] [InlineData(true, "/images/{seo_string_ignored}/{sku:guid}/{image_id:integer-range(0,1111111)}{width:integer:prefix(_):optional}.{format:equals(jpg|png|gif)}" @@ -53,13 +61,181 @@ public void TestAll(bool s, string expr, params string[] inputs) } } } - - // Test MatchExpression.Parse - [Fact] - public void TestParse() + [Theory] + [InlineData("/{name:ends-with(y)}", "/cody", "name=cody")] + // ints + [InlineData("{:int}", "123", "int=123")] + [InlineData("{:int}", "-123", "int=-123")] + [InlineData("{:int}", "0", "int=0")] + [InlineData("{:u64}", "123", "u64=123")] + [InlineData("{:u64}", "0", "u64=0")] + [InlineData("{:u64}", "-123", null)] + [InlineData("/{name}/{country}{:(/):?}", "/hi/usa", "name=hi&country=usa")] + [InlineData("/{name}/{country}{:(/):?}", "/hi/usa/", "name=hi&country=usa")] + [InlineData("/{name}/{country}{:eq(/):optional}", "/hi/usa", "name=hi&country=usa")] + [InlineData("/{name}/{country:len(3)}", "/hi/usa", "name=hi&country=usa")] + [InlineData("/{name}/{country:len(3)}/{state:len(2)}", "/hi/usa/CO", "name=hi&country=usa&state=CO")] + [InlineData("{country:len(3)}{state:len(2)}", "USACO", "country=USA&state=CO")] + [InlineData("/images/{seo_string_ignored}/{sku:guid}/{image_id:integer-range(0,1111111)}{width:integer:prefix(_):optional}.{format:equals(jpg|png|gif)}" + , "/images/seo-string/12345678-1234-1234-1234-123456789012/12678_300.jpg", "seo_string_ignored=seo-string&sku=12345678-1234-1234-1234-123456789012&image_id=12678&width=300&format=jpg")] + [InlineData("/images/{seo_string_ignored}/{sku:guid}/{image_id:integer-range(0,1111111)}{width:integer:prefix(_):optional}.{format:equals(jpg|png|gif)}" + , "/images/seo-string/12345678-1234-1234-1234-123456789012/12678.jpg", "seo_string_ignored=seo-string&sku=12345678-1234-1234-1234-123456789012&image_id=12678&format=jpg")] + [InlineData("/{dir}/{file}.{ext}", "/path/to/file.txt", "dir=path&file=to/file&ext=txt")] + [InlineData("/{dir}/{file}.{ext}", "/path/to/nested/dir/file.txt", "dir=path&file=to/nested/dir/file&ext=txt")] + public void TestCaptures(string expr, string input, string? expectedCaptures) { - var c = CaseSensitive; - var expr = MatchExpression.Parse(c, "/{name}/{country}{:equals(/):?}"); - Assert.Equal(5, expr.SegmentCount); + var caseSensitive = expr.Contains("(i)"); + expr = expr.Replace("(i)", ""); + var c = caseSensitive ? CaseSensitive : CaseInsensitive; + var me = MatchExpression.Parse(c, expr); + var path = input; + var matched = me.TryMatchVerbose(c, path.AsMemory(), out var result, out var error); + if (!matched && expectedCaptures != null) + { + Assert.Fail($"Expression {expr} incorrectly failed to match {path} with error {error}"); + } + if (matched && expectedCaptures == null) + { + var captureString = result!.Value.Captures == null ? "null" : string.Join("&", result!.Value.Captures.Select(x => $"{x.Name}={x.Value}")); + Assert.Fail($"False positive! Expression {expr} should not have matched {path} with captures {captureString}! False positive."); + } + var expectedPairs = Imazen.Routing.Helpers.PathHelpers.ParseQuery(expectedCaptures)! + .ToDictionary(x => x.Key, x => x.Value.ToString()); + + var actualPairs = result!.Value.Captures! + .ToDictionary(x => x.Name, x => x.Value.ToString()); + + Assert.Equal(expectedPairs, actualPairs); + + } + [Theory] + [InlineData("{name:starts-with(foo):ends-with(bar)?}", false)] + [InlineData("{name:starts-with(foo):ends-with(bar)}", true)] + [InlineData("{name:starts-with(foo):?}", true)] + [InlineData("{name:prefix(foo):suffix(bar)}", true)] + [InlineData("prefix(foo){name}suffix(bar)", true)] + [InlineData("{name:len(5):alpha()}", true)] + [InlineData("{name:alpha():length(5,10)}", true)] + [InlineData("{name:len(5)}", true)] + [InlineData("{name:equals(foo):equals(bar)}", false)] + [InlineData("{name:equals(foo|bar)}", true)] + [InlineData("{name:starts-with(foo)}suffix(bar)", true)] //suffix(bar) will be seen as a literal + [InlineData("{name:starts-with(foo)}/suffix(bar)", true)] + [InlineData("{name:starts-with(foo)}:ends-with(baz)suffix(bar)", true)] + [InlineData("{?}", true)] + [InlineData("{*}", true)] + [InlineData("{:?}", true)] + [InlineData("{name:?}", true)] + [InlineData("{name:int32}", true)] + [InlineData("{name:int32()}", true)] + [InlineData("{name:starts-with(foo}:ends-with(bar)}", false)] + [InlineData("{name:starts-with(foo):ends-with(bar)}{:alpha()}", true)] + [InlineData("{name:starts-with(foo):ends-with(bar)}/{:alpha()}", true)] + [InlineData("{name:starts-with(foo)}{:ends-with(bar):alpha()}", false)] + [InlineData("{name:prefix(foo):?}", true)] + [InlineData("{name:suffix(bar)}", true)] + [InlineData("{name:suffix(bar):?}", true)] + [InlineData("{name:ends-with(bar):?}", true)] + [InlineData("{name:contains(foo)}", true)] + [InlineData("{name:contains(foo):?}", true)] + [InlineData("{name:contains-i(foo)}", true)] + [InlineData("{name:contains-i(foo):?}", true)] + [InlineData("{name:equals(foo):?}", true)] + [InlineData("{name:equals-i(foo)}", true)] + [InlineData("{name:equals-i(foo):?}", true)] + [InlineData("{name:starts-with-i(foo)}", true)] + [InlineData("{name:starts-with-i(foo):?}", true)] + [InlineData("{name:ends-with-i(bar)}", true)] + [InlineData("{name:ends-with-i(bar):?}", true)] + [InlineData("{name:len(5,10)}", true)] + [InlineData("{name:len(5,10):?}", true)] + [InlineData("{name:length(,5)}", true)] + [InlineData("{name:length(5,)}", true)] + [InlineData("{name:length(5)}", true)] + [InlineData("{name:length(5):?}", true)] + [InlineData("{name:length(5,10)}", true)] + [InlineData("{name:length(5,10):?}", true)] + [InlineData("{name:alpha}", true)] + [InlineData("{name:alpha:?}", true)] + [InlineData("{name:alpha-lower}", true)] + [InlineData("{name:alpha-lower:?}", true)] + [InlineData("{name:alpha-upper}", true)] + [InlineData("{name:alpha-upper:?}", true)] + [InlineData("{name:alphanumeric}", true)] + [InlineData("{name:alphanumeric:?}", true)] + [InlineData("{name:hex}", true)] + [InlineData("{name:hex:?}", true)] + [InlineData("{name:int64}", true)] + [InlineData("{name:int64:?}", true)] + [InlineData("{name:guid}", true)] + [InlineData("{name:guid:?}", true)] + [InlineData("{name:equals(foo|bar|baz)}", true)] + [InlineData("{name:equals(foo|bar|baz):?}", true)] + [InlineData("{name:equals-i(foo|bar|baz)}", true)] + [InlineData("{name:equals-i(foo|bar|baz):?}", true)] + [InlineData("{name:starts-with(foo|bar|baz)}", true)] + [InlineData("{name:starts-with(foo|bar|baz):?}", true)] + [InlineData("{name:starts-with-i(foo|bar|baz)}", true)] + [InlineData("{name:starts-with-i(foo|bar|baz):?}", true)] + [InlineData("{name:ends-with(foo|bar|baz)}", true)] + [InlineData("{name:ends-with(foo|bar|baz):?}", true)] + [InlineData("{name:ends-with-i(foo|bar|baz)}", true)] + [InlineData("{name:ends-with-i(foo|bar|baz):?}", true)] + [InlineData("{name:contains(foo|bar|baz)}", true)] + [InlineData("{name:contains(foo|bar|baz):?}", true)] + [InlineData("{name:contains-i(foo|bar|baz)}", true)] + [InlineData("{name:contains-i(foo|bar|baz):?}", true)] + [InlineData("{name:range(1,100)}", true)] + [InlineData("{name:range(1,100):?}", true)] + [InlineData("{name:image-ext-supported}", true)] + [InlineData("{name:image-ext-supported:?}", true)] + [InlineData("{name:allowed-chars([a-zA-Z0-9_\\-])}", true)] + [InlineData("{name:allowed-chars([a-zA-Z0-9_\\-]):?}", true)] + [InlineData("{name:starts-with-chars(3,[a-zA-Z])}", true)] + [InlineData("{name:starts-with-chars(3,[a-zA-Z]):?}", true)] + [InlineData("{name:ends-with([$%^])}", true)] + [InlineData("{name:ends-with([$%^]):?}", true)] + [InlineData("{name:ends-with-i([$%^])}", true)] + [InlineData("{name:ends-with-i([$%^]):?}", true)] + [InlineData("{name:starts-with([0-9])}", true)] + [InlineData("{name:starts-with([0-9]):?}", true)] + [InlineData("{name:starts-with-i([0-9])}", true)] + [InlineData("{name:starts-with-i([0-9]):?}", true)] + [InlineData("{name:starts-with([aeiouAEIOU])}", true)] + [InlineData("{name:starts-with([aeiouAEIOU]):?}", true)] + [InlineData("{name:starts-with-i([aeiouAEIOU])}", true)] + [InlineData("{name:starts-with-i([aeiouAEIOU]):?}", true)] + [InlineData("{name:ends-with([!@#$%^&*])}", true)] + [InlineData("{name:ends-with([!@#$%^&*]):?}", true)] + [InlineData("{name:ends-with-i([!@#$%^&*])}", true)] + [InlineData("{name:ends-with-i([!@#$%^&*]):?}", true)] + [InlineData("{name:ends-with([aeiouAEIOU])}", true)] + [InlineData("{name:ends-with([aeiouAEIOU]):?}", true)] + [InlineData("{name:ends-with-i([aeiouAEIOU])}", true)] + [InlineData("{name:ends-with-i([aeiouAEIOU]):?}", true)] + [InlineData("{name:ends-with-i([aeiouAEIOU]|[a-z]):?}", false)] + // Ensure that they won't parse if we use any aliases or conditions as names + [InlineData("{int}", false)] + [InlineData("{uint}", false)] + [InlineData("{alpha}", false)] + [InlineData("{alphanumeric}", false)] + [InlineData("{hex}", false)] + [InlineData("{guid}", false)] + public void TestMatchExpressionParsing(string expression, bool shouldParse) + { + var context = MatchingContext.DefaultCaseInsensitive; + var result = MatchExpression.TryParse(context, expression, out var matchExpression, out var error); + + if (shouldParse) + { + Assert.True(result, $"Expression '{expression}' should parse successfully, but got error: {error}"); + Assert.NotNull(matchExpression); + } + else + { + Assert.False(result, $"Expression '{expression}' should not parse successfully, but it did."); + Assert.Null(matchExpression); + Assert.NotNull(error); + } } } \ No newline at end of file