diff --git a/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs b/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs index b3ec22b..7676962 100644 --- a/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs +++ b/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs @@ -168,14 +168,15 @@ public static bool TryParseCondition(ReadOnlyMemory text, [NotNullWhen(false)] out string? error) { var textSpan = text.Span; - if (!TryReadConditionName(textSpan, out var functionNameEnds, out error)) + if (!TryReadConditionName(textSpan, out var functionNameEndsMaybe, out error)) { functionName = null; args = null; return false; } + int functionNameEnds = functionNameEndsMaybe.Value; - functionName = text[..functionNameEnds.Value]; + functionName = text[..functionNameEnds]; if (functionNameEnds == text.Length) { @@ -184,7 +185,7 @@ public static bool TryParseCondition(ReadOnlyMemory text, return true; } - if (textSpan[functionNameEnds.Value] != '(') + if (textSpan[functionNameEnds] != '(') { throw new InvalidOperationException("Unreachable code"); } @@ -198,7 +199,7 @@ public static bool TryParseCondition(ReadOnlyMemory text, } // now parse using unescaped commas - var argListSpan = text.Slice(functionNameEnds.Value + 1, text.Length - 1); + var argListSpan = text[(functionNameEnds + 1)..]; if (argListSpan.Length == 0) { error = null; @@ -215,7 +216,7 @@ public static bool TryParseCondition(ReadOnlyMemory text, var commaIndex = FindCharNotEscaped(subSpan, ',', '\\'); if (commaIndex == -1) { - args.Add(argListSpan[start..]); + args.Add(argListSpan[start..^1]); // no need to include the last character, it's a ) break; } args.Add(argListSpan.Slice(start, commaIndex)); @@ -260,7 +261,7 @@ public static ArgType GetArgType(ReadOnlySpan arg) type &= ~ArgType.DecimalNumeric; } if (arg.Length == 1) type |= ArgType.Char; - else type |= ArgType.String; + type |= ArgType.String; return type; } diff --git a/src/Imazen.Routing/Matching/MatchExpression.cs b/src/Imazen.Routing/Matching/MatchExpression.cs index d33ef8a..8536672 100644 --- a/src/Imazen.Routing/Matching/MatchExpression.cs +++ b/src/Imazen.Routing/Matching/MatchExpression.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; @@ -31,6 +32,8 @@ private MatchExpression(MatchSegment[] segments) private MatchSegment[] Segments; + public int SegmentCount => Segments.Length; + private static bool TryCreate(IReadOnlyCollection segments, [NotNullWhen(true)] out MatchExpression? result, [NotNullWhen(false)]out string? error) { if (segments.Count == 0) @@ -47,13 +50,13 @@ private static bool TryCreate(IReadOnlyCollection segments, [NotNu #if NET8_0_OR_GREATER - [GeneratedRegex(@"^(([^{]+)|((? SplitSectionsVar; #endif @@ -65,6 +68,60 @@ public static MatchExpression Parse(MatchingContext context, string expression) } return result!; } + + private static IEnumerable>SplitExpressionSections(ReadOnlyMemory input) + { + int lastOpen = -1; + int consumed = 0; + while (true) + { + if (lastOpen == -1) + { + lastOpen = ExpressionParsingHelpers.FindCharNotEscaped(input.Span[consumed..], '{', '\\'); + if (lastOpen != -1) + { + lastOpen += consumed; + // Return the literal before the open { + if (lastOpen > consumed) + { + yield return input[consumed..lastOpen]; + } + consumed = lastOpen + 1; + } + else + { + // The rest of the string is a literal + if (consumed < input.Length) + { + yield return input[consumed..]; + } + yield break; + } + } + else + { + // We have an open { pending + var close = ExpressionParsingHelpers.FindCharNotEscaped(input.Span[consumed..], '}', '\\'); + if (close != -1) + { + close += consumed; + // return the {segment} + yield return input[lastOpen..(close + 1)]; + consumed = close + 1; + lastOpen = -1; + } + else + { + // The rest of the string is a literal - a dangling one! + if (consumed < input.Length) + { + yield return input.Slice(consumed); + } + yield break; + } + } + } + } public static bool TryParse(MatchingContext context, string expression, @@ -83,19 +140,12 @@ public static bool TryParse(MatchingContext context, string expression, // 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 matches = SplitExpressionSections(expression.AsMemory()).ToArray(); var segments = new Stack(); - - for (int i = matches.Count - 1; i >= 0; i--) + for (int i = matches.Length - 1; i >= 0; i--) { - var match = matches[i]; - var segment = expression.AsMemory()[match.Index..match.Length]; + var segment = matches[i]; + if (segment.Length == 0) throw new InvalidOperationException($"SplitSections returned an empty segment. {matches}"); if (!MatchSegment.TryParseSegmentExpression(context, segment, segments, out var parsedSegment, out error)) { result = null; @@ -117,6 +167,20 @@ public bool IsMatch(in MatchingContext context, string input) { return TryMatch(context, input.AsMemory(), out _, out _, out _); } + + public bool TryMatchVerbose(in MatchingContext context, in ReadOnlyMemory input, + [NotNullWhen(true)] out MatchExpressionSuccess? result, + [NotNullWhen(false)] out string? error) + { + if (!TryMatch(context, input, out result, out error, out var ix)) + { + MatchSegment? segment = ix >= 0 && ix < Segments.Length ? Segments[ix.Value] : null; + error = $"{error}. Failing segment[{ix}]: {segment}"; + return false; + } + return true; + } + public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, [NotNullWhen(true)] out MatchExpressionSuccess? result, [NotNullWhen(false)] out string? error, [NotNullWhen(false)] out int? failingSegmentIndex) { @@ -139,17 +203,18 @@ public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, var currentSegment = 0; while (true) { - var beginStart = -1; - var beginEnd = -1; - var found = false; + var boundaryStarts = -1; + var boundaryFinishes = -1; + var foundBoundaryOrEnd = false; var closingBoundary = false; - if (currentSegment == Segments.Length) - { - // We've reached the end of the segments to test. + // No more segments to try? + if (currentSegment >= Segments.Length) + { if (openSegmentIndex != -1) { - beginStart = beginEnd = remainingInput.Length; - found = true; + // We still have an open segment, so we close it and capture it. + boundaryStarts = boundaryFinishes = inputSpan.Length; + foundBoundaryOrEnd = true; closingBoundary = true; }else if (remainingInput.Length == 0) { @@ -181,8 +246,8 @@ public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, if (searchSegment.AsEndSegmentReliesOnStartSegment) { // The start segment must have been equals or a literal - beginStart = beginEnd = 0; - found = true; + boundaryStarts = boundaryFinishes = charactersConsumed; + foundBoundaryOrEnd = true; } else if (searchSegment.AsEndSegmentReliesOnSubsequentSegmentBoundary) { // Move on to the next segment (or past the last segment, which triggers a match) @@ -190,14 +255,14 @@ public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, continue; } } - if (!found && !startingFresh && !searchSegment.SupportsScanning) + if (!foundBoundaryOrEnd && !startingFresh && !searchSegment.SupportsScanning) { error = $"The segment cannot cannot be scanned for"; failingSegmentIndex = currentSegment; result = null; return false; } - if (!found && startingFresh && !searchSegment.SupportsMatching) + if (!foundBoundaryOrEnd && startingFresh && !searchSegment.SupportsMatching) { error = $"The segment cannot be matched for"; failingSegmentIndex = currentSegment; @@ -207,10 +272,18 @@ public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, // Relying on these to throw exceptions if the constructed expression can // not be matched deterministically. - found = found || (startingFresh - ? searchSegment.TryMatch(remainingInput, out beginStart, out beginEnd) - : searchSegment.TryScan(remainingInput, out beginStart, out beginEnd)); - if (!found) + var s = -1; + var f = -1; + if (!foundBoundaryOrEnd) + { + var searchResult = (startingFresh + ? searchSegment.TryMatch(remainingInput, out s, out f) + : searchSegment.TryScan(remainingInput, out s, out f)); + boundaryStarts = s == -1 ? -1 : charactersConsumed + s; + boundaryFinishes = f == -1 ? -1 : charactersConsumed + f; + foundBoundaryOrEnd = searchResult; + } + if (!foundBoundaryOrEnd) { if (Segments[currentSegment].IsOptional) @@ -227,25 +300,25 @@ public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, return false; } } - if (found) + + if (foundBoundaryOrEnd) { + Debug.Assert(boundaryStarts != -1 && boundaryFinishes != -1); // We can get here under 3 conditions: // 1. We found the start of a segment and a previous segment is open // 2. We found the end of a segment and the current segment is open. // 3. We matched the start of a segment, no previous segment was open. - + // So first, we close and capture any open segment. // This happens if we found the start of a segment and a previous segment is open. // Or if we found the end of our current segment. if (openSegmentIndex != -1) { var openSegment = Segments[openSegmentIndex]; - var variableStart = openSegment.EndsOn.IncludesMatchingTextInVariable + var variableStart = openSegment.StartsOn.IncludesMatchingTextInVariable ? openSegmentAbsoluteStart : openSegmentAbsoluteEnd; - var variableEnd = charactersConsumed + beginStart; - remainingInput = remainingInput[beginStart..]; - charactersConsumed += beginStart; + var variableEnd = boundaryStarts; var conditionsOk = openSegment.ConditionsMatch(context, inputSpan[variableStart..variableEnd]); if (!conditionsOk) { @@ -256,21 +329,32 @@ public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, failingSegmentIndex = openSegmentIndex; return false; } + if (openSegment.Name != null) { captures ??= new List(); captures.Add(new(openSegment.Name, input[variableStart..variableEnd])); } - } + // We consume the characters (we had a formerly open segment). + charactersConsumed = boundaryFinishes; + remainingInput = inputSpan[charactersConsumed..]; + } + if (!closingBoundary){ openSegmentIndex = currentSegment; - openSegmentAbsoluteStart = charactersConsumed + beginStart; - openSegmentAbsoluteEnd = charactersConsumed + beginEnd; + openSegmentAbsoluteStart = boundaryStarts; + openSegmentAbsoluteEnd = boundaryFinishes; + // TODO: handle non-consuming char case? + charactersConsumed = boundaryFinishes; + remainingInput = inputSpan[charactersConsumed..]; continue; // Move on to the next segment } else { + openSegmentIndex = -1; + openSegmentAbsoluteStart = -1; + openSegmentAbsoluteEnd = -1; currentSegment++; } @@ -284,6 +368,19 @@ public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, internal readonly record struct MatchSegment(string? Name, SegmentBoundary StartsOn, SegmentBoundary EndsOn, List? Conditions) { + override public string ToString() + { + if (StartsOn.MatchesEntireSegment && Name == null && EndsOn.AsEndSegmentReliesOnStartSegment) + { + return $"'{StartsOn.MatchString}'"; + } + var conditionsString = Conditions == null ? "" : string.Join(":", Conditions); + if (conditionsString.Length > 0) + { + conditionsString = ":" + conditionsString; + } + return $"{Name ?? ""}:{StartsOn.AsCondition(true)}:{EndsOn.AsCondition(false)}{conditionsString}"; + } public bool ConditionsMatch(MatchingContext context, ReadOnlySpan text) { if (Conditions == null) return true; @@ -333,6 +430,13 @@ internal static bool TryParseSegmentExpression(MatchingContext context, } // it's a literal + // Check for invalid characters like & + if (expr.IndexOfAny(new[] {'*', '?'}) != -1) + { + error = "Literals cannot contain * or ? operators, they must be enclosed in {} such as {name:?} or {name:*:?}"; + segment = null; + return false; + } segment = CreateLiteral(expr, context); return true; } @@ -390,25 +494,24 @@ private static bool TryParseLogicalSegment(MatchingContext context, segmentStartLogic ??= SegmentBoundary.DefaultStart; segmentEndLogic ??= SegmentBoundary.DefaultEnd; - if (segmentEndLogic.Value.AsEndSegmentReliesOnSubsequentSegmentBoundary) + + segment = new MatchSegment(name, segmentStartLogic.Value, segmentEndLogic.Value, conditions); + + if (segmentEndLogic.Value.AsEndSegmentReliesOnSubsequentSegmentBoundary && laterSegments.Count > 0) { - // 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) { - 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; - } + error = $"The segment '{segment}' cannot be matched deterministically since it precedes non searchable segment '{next}'"; + return false; } } - segment = new MatchSegment(name, segmentStartLogic.Value, segmentEndLogic.Value, conditions); + error = null; return true; } @@ -546,20 +649,40 @@ private static MatchSegment CreateLiteral(ReadOnlySpan literal, MatchingCo } -internal readonly record struct SegmentBoundary( - SegmentBoundary.Flags Behavior, - SegmentBoundary.When On, - string? Chars, - char Char - ) +internal readonly record struct SegmentBoundary { - - + private readonly SegmentBoundary.Flags Behavior; + private readonly SegmentBoundary.When On; + private readonly string? Chars; + private readonly char Char; + + public override string ToString() + { + return $"{On} '{Chars ?? Char.ToString()}' {Behavior}"; + } + + private SegmentBoundary( + SegmentBoundary.Flags behavior, + SegmentBoundary.When on, + string? chars, + char c + ) + { + this.Behavior = behavior; + this.On = on; + this.Chars = chars; + this.Char = c; + } + + public static SegmentBoundary Literal(ReadOnlySpan literal, bool ignoreCase) => + StringEquals(literal, ignoreCase, false); + 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 DefaultStart = new(Flags.ConsumeAndInclude, When.StartsNow, null, '\0'); public static SegmentBoundary DefaultEnd = new(Flags.None, When.InheritFromNextSegment, null, '\0'); public static SegmentBoundary EqualsEnd = new(Flags.None, When.SegmentFullyMatchedByStartBoundary, null, '\0'); + public static SegmentBoundary StartWith(ReadOnlySpan asSpan, bool ordinalIgnoreCase, bool includeInVar) { if (asSpan.Length == 1 && @@ -568,11 +691,11 @@ public static SegmentBoundary StartWith(ReadOnlySpan asSpan, bool ordinalI return new(includeInVar ? Flags.ConsumeAndInclude : Flags.ConsumeMatchingText, When.AtChar, null, asSpan[0]); } - return new(includeInVar ? Flags.ConsumeAndInclude : Flags.ConsumeMatchingText, + + 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 && @@ -581,41 +704,56 @@ public static SegmentBoundary StringEquals(ReadOnlySpan asSpan, bool ordin return new(includeInVar ? Flags.ConsumeAndInclude : Flags.ConsumeMatchingText, When.EqualsChar, null, asSpan[0]); } - return new(includeInVar ? Flags.ConsumeAndInclude : Flags.ConsumeMatchingText, + + 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 string? MatchString => On switch + { + When.AtChar or When.EqualsChar => Char.ToString(), + When.AtString or When.AtStringIgnoreCase or + When.EqualsOrdinal or When.EqualsOrdinalIgnoreCase => Chars, + _ => null + }; + 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 AsEndSegmentReliesOnStartSegment => On == When.SegmentFullyMatchedByStartBoundary; - + public bool AsEndSegmentReliesOnSubsequentSegmentBoundary => On == When.InheritFromNextSegment; [Flags] - public enum Flags:byte + public enum Flags : byte { None = 0, SegmentOptional = 1, ConsumeMatchingText = 2, IncludeMatchingTextInVariable = 4, ConsumeAndInclude = ConsumeMatchingText | IncludeMatchingTextInVariable, - GraphemeAware = 8 } - public bool IncludesMatchingTextInVariable => (Behavior & Flags.IncludeMatchingTextInVariable) == Flags.IncludeMatchingTextInVariable; + + public bool IncludesMatchingTextInVariable => + (Behavior & Flags.IncludeMatchingTextInVariable) == Flags.IncludeMatchingTextInVariable; + public bool ConsumesMatchingText => (Behavior & Flags.ConsumeMatchingText) == Flags.ConsumeMatchingText; - public enum When:byte + + public enum When : byte { /// /// Cannot be combined with Optional. @@ -625,6 +763,7 @@ public enum When:byte StartsNow, EndOfInput, SegmentFullyMatchedByStartBoundary, + /// /// The default for ends /// @@ -644,33 +783,39 @@ public enum When:byte public bool SupportsMatching => On != When.InheritFromNextSegment && On != When.SegmentFullyMatchedByStartBoundary; + public bool TryMatch(ReadOnlySpan text, out int start, out int end) { if (!SupportsMatching) { throw new InvalidOperationException("Cannot match a segment boundary with " + On); } + start = 0; end = 0; if (On == When.EndOfInput) { return text.Length == 0; } + if (On == When.StartsNow) { return true; } + if (text.Length == 0) return false; switch (On) { - case When.AtChar: + case When.AtChar or When.EqualsChar: if (text[0] == Char) { start = 0; end = 1; return true; } + return false; + case When.AtString or When.EqualsOrdinal: var charSpan = Chars.AsSpan(); if (text.StartsWith(charSpan, StringComparison.Ordinal)) @@ -679,6 +824,7 @@ public bool TryMatch(ReadOnlySpan text, out int start, out int end) end = charSpan.Length; return true; } + return true; case When.AtStringIgnoreCase or When.EqualsOrdinalIgnoreCase: var charSpan2 = Chars.AsSpan(); @@ -688,6 +834,7 @@ public bool TryMatch(ReadOnlySpan text, out int start, out int end) end = charSpan2.Length; return true; } + return true; default: return false; @@ -713,7 +860,7 @@ public bool TryScan(ReadOnlySpan text, out int start, out int end) if (text.Length == 0) return false; switch (On) { - case When.AtChar: + case When.AtChar or When.EqualsChar: var index = text.IndexOf(Char); if (index == -1) return false; start = index; @@ -739,6 +886,35 @@ public bool TryScan(ReadOnlySpan text, out int start, out int end) } + + public string AsCondition(bool isStartBoundary) + { + var name = On switch + { + When.StartsNow => "now", + When.EndOfInput => "unterminated", + When.SegmentFullyMatchedByStartBoundary => "noop", + When.InheritFromNextSegment => "unterminated", + When.AtChar or When.AtString or When.AtStringIgnoreCase => + ((Behavior & Flags.IncludeMatchingTextInVariable) != 0) + ? (isStartBoundary ? "starts_with" : "ends_with") + : (isStartBoundary ? "after" : "until"), + When.EqualsOrdinal or When.EqualsChar or When.EqualsOrdinalIgnoreCase => "equals", + _ => throw new InvalidOperationException("Unreachable code") + }; + var ignoreCase = (When.AtStringIgnoreCase == On || When.EqualsOrdinalIgnoreCase == On) ? "-i" : ""; + name += ignoreCase; + if (Chars != null) + { + name = $"{name}({Chars})"; + } + else if (Char != '\0') + { + name = $"{name}({Char})"; + } + var optional = (Behavior & Flags.SegmentOptional) != 0 ? "?": ""; + return $"{name}{optional}"; + } } diff --git a/src/Imazen.Routing/Matching/StringCondition.cs b/src/Imazen.Routing/Matching/StringCondition.cs index 8344821..1de23d9 100644 --- a/src/Imazen.Routing/Matching/StringCondition.cs +++ b/src/Imazen.Routing/Matching/StringCondition.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Text; using EnumFastToStringGenerated; namespace Imazen.Routing.Matching; @@ -59,26 +60,32 @@ private StringCondition(StringConditionKind stringConditionKind, char? c, string kinds = ignoreCaseKinds; } } + var errors = new List(); foreach (var kind in kinds) { var condition = TryCreate(kind, name, args, out error); + if (error != null) + { + errors.Add($"Tried {kind} with {ArgsToStr(args)}: {error}"); + } if (condition == null) continue; if (!condition.Value.ValidateArgsPresent(out error)) { - throw new InvalidOperationException("Condition was created with invalid arguments"); + throw new InvalidOperationException($"Condition was created with invalid arguments. Received: ({ArgsToStr(args)})"); } return condition; } - error = $"Invalid arguments for condition '{name}'. Received: {args ?? []}"; + error = $"Invalid arguments for condition '{name}'. Received: ({ArgsToStr(args)}). Errors: {string.Join(", ", errors)}"; return null; } + private static string ArgsToStr(List>? args) => args == null ? "" : string.Join(",", args); 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)) + if (HasFlagsFast(expectedArgs, ExpectedArgs.None)) { error = null; return new StringCondition(stringConditionKind, null, null, null, null, null, null); @@ -86,98 +93,97 @@ private StringCondition(StringConditionKind stringConditionKind, char? c, string error = $"Expected argument type {expectedArgs} for condition '{name}'; received none."; return null; } + bool wantsFirstArgNumeric = HasFlagsFast(expectedArgs, ExpectedArgs.Int321) || HasFlagsFast(expectedArgs, ExpectedArgs.Int321OrInt322) + || stringConditionKind == StringConditionKind.StartsWithNCharClass; + + bool firstOptional = HasFlagsFast(expectedArgs, ExpectedArgs.Int321OrInt322); + var obj = TryParseArg(args[0], wantsFirstArgNumeric, firstOptional, out error); + if (error != null) + { + throw new InvalidOperationException($"Error parsing 1st argument: {error}"); + } + var twoIntArgs = HasFlagsFast(expectedArgs, ExpectedArgs.Int321OrInt322); + var intArgAndClassArg = HasFlagsFast(expectedArgs, ExpectedArgs.Int321 | ExpectedArgs.CharClass); + var secondArgRequired = intArgAndClassArg + || (twoIntArgs && obj == null); + var secondArgWanted = secondArgRequired || twoIntArgs; + var secondArgNumeric = twoIntArgs; + if (args.Count != 1 && !secondArgWanted) + { + error = $"Expected 1 argument for condition '{name}'; received {args.Count}: {ArgsToStr(args)}."; + return null; + } + if (secondArgRequired && args.Count != 2) + { + error = $"Expected 2 arguments for condition '{name}'; received {args.Count}: {ArgsToStr(args)}."; + return null; + } - if (HasFlagFast(expectedArgs, ExpectedArgs.Int321OrInt322)) + var obj2 = secondArgWanted && args.Count > 1 ? TryParseArg(args[1], secondArgNumeric, !secondArgRequired, out error) : null; + if (error != null) { - // 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; - } + throw new InvalidOperationException($"Error parsing 2nd argument: {error}"); + } + if (secondArgRequired && obj2 == null) + { + throw new InvalidOperationException($"Missing 2nd argument: {error}"); + } - int? int1 = null; - int? int2 = null; - if (arg1IsInt) + if (obj is double decVal) + { + error = $"Unexpected decimal argument for condition '{name}'; received '{args[0]}' {decVal}."; + return null; + } + if (obj is int intVal) + { + if (twoIntArgs) { -#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 + var int2 = obj2 as int?; + error = null; + return new StringCondition(stringConditionKind, null, null, null, null, intVal, int2); + }else if (intArgAndClassArg) + { + if (obj2 is CharacterClass cc2) { - int1 = i; + error = null; + return new StringCondition(stringConditionKind, null, null, cc2, null, intVal, null); } else { - error = $"Expected int for first argument of condition '{name}'; received '{args[0]}'."; + error = $"Unexpected argument for condition '{name}'; received '{args[1]}'."; return null; } + } else if (HasFlagsFast(expectedArgs, ExpectedArgs.Int321)) + { + error = null; + return new StringCondition(stringConditionKind, null, null, null, null, intVal, null); } - if (arg2IsInt) + else { -#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 = $"Unexpected int argument for condition '{name}'; received '{args[0]}'."; + 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)) + if (HasFlagsFast(expectedArgs, ExpectedArgs.Char)) { error = null; return new StringCondition(stringConditionKind, c, null, null, null, null, null); } + // try converting it to a string + if (HasFlagsFast(expectedArgs, ExpectedArgs.String)) + { + error = null; + return new StringCondition(stringConditionKind, null, c.ToString(), 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)) + if (HasFlagsFast(expectedArgs, ExpectedArgs.String)) { error = null; return new StringCondition(stringConditionKind, null, str, null, null, null, null); @@ -187,7 +193,7 @@ private StringCondition(StringConditionKind stringConditionKind, char? c, string } if (obj is string[] strArray) { - if (HasFlagFast(expectedArgs, ExpectedArgs.StringArray)) + if (HasFlagsFast(expectedArgs, ExpectedArgs.StringArray)) { error = null; return new StringCondition(stringConditionKind, null, null, null, strArray, null, null); @@ -197,7 +203,7 @@ private StringCondition(StringConditionKind stringConditionKind, char? c, string } if (obj is CharacterClass cc) { - if (HasFlagFast(expectedArgs, ExpectedArgs.CharClass)) + if (HasFlagsFast(expectedArgs, ExpectedArgs.CharClass)) { error = null; return new StringCondition(stringConditionKind, null, null, cc, null, null, null); @@ -216,10 +222,15 @@ private StringCondition(StringConditionKind stringConditionKind, char? c, string return arg.ToString(); } - private static object? TryParseArg(ReadOnlyMemory argMemory, out string? error) + private static object? TryParseArg(ReadOnlyMemory argMemory, bool tryParseNumeric, bool allowEmpty, out string? error) { var arg = argMemory.Span; var type = ExpressionParsingHelpers.GetArgType(arg); + if (allowEmpty && type == ExpressionParsingHelpers.ArgType.Empty) + { + error = null; + return null; + } if ((type & ExpressionParsingHelpers.ArgType.CharClass) > 0) { if (!CharacterClass.TryParseInterned(argMemory,true, out var cc, out error)) @@ -252,6 +263,33 @@ private StringCondition(StringConditionKind stringConditionKind, char? c, string error = null; return list.ToArray(); } + + if (tryParseNumeric & (type & ExpressionParsingHelpers.ArgType.IntegerNumeric) > 0) + { +#if NET6_0_OR_GREATER + if (int.TryParse(arg, out var i)) +#else + if (int.TryParse(arg.ToString(), out var i)) +#endif + { + error = null; + return i; + } + } + + if (tryParseNumeric & (type & ExpressionParsingHelpers.ArgType.DecimalNumeric) > 0) + { +#if NET6_0_OR_GREATER + if (double.TryParse(arg, out var d)) +#else + if (double.TryParse(arg.ToString(), out var d)) +#endif + { + error = null; + return d; + } + } + if ((type & ExpressionParsingHelpers.ArgType.Char) > 0) { if (arg.Length != 1) @@ -339,7 +377,7 @@ private StringCondition(StringConditionKind stringConditionKind, char? c, string private bool ValidateArgsPresent([NotNullWhen(false)] out string? error) { var expected = ForKind(stringConditionKind); - if (HasFlagFast(expected, ExpectedArgs.Char) != c.HasValue) + if (HasFlagsFast(expected, ExpectedArgs.Char) != c.HasValue) { error = c.HasValue ? "Unexpected char parameter in StringCondition" @@ -347,7 +385,7 @@ private bool ValidateArgsPresent([NotNullWhen(false)] out string? error) return false; } - if (HasFlagFast(expected, ExpectedArgs.String) != (str != null)) + if (HasFlagsFast(expected, ExpectedArgs.String) != (str != null)) { error = (str != null) ? "Unexpected string parameter in StringCondition" @@ -355,39 +393,47 @@ private bool ValidateArgsPresent([NotNullWhen(false)] out string? error) return false; } - if (HasFlagFast(expected, ExpectedArgs.StringArray) != (strArray != null)) + if (HasFlagsFast(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) + if (HasFlagsFast(expected, ExpectedArgs.CharClass) != (charClass != null)) { - error = int1.HasValue - ? "Unexpected int parameter in StringCondition" - : "int parameter missing in StringCondition"; + error = (charClass != null) + ? "Unexpected char class parameter in StringCondition" + : "char class parameter missing in StringCondition"; return false; } - - if (HasFlagFast(expected, ExpectedArgs.Int321OrInt322) != (int1.HasValue || int2.HasValue)) + + if (HasFlagsFast(expected, ExpectedArgs.Int321OrInt322)) + { + if (int1.HasValue || int2.HasValue) + { + error = null; + return true; + } + error = "int parameter(s) missing in StringCondition"; + return false; + } + + if (HasFlagsFast(expected, ExpectedArgs.Int321) != int1.HasValue) { - error = (int1.HasValue || int2.HasValue) + error = int1.HasValue ? "Unexpected int parameter in StringCondition" : "int parameter missing in StringCondition"; return false; } - if (HasFlagFast(expected, ExpectedArgs.CharClass) != (charClass != null)) + if (int2.HasValue) { - error = (charClass != null) - ? "Unexpected char class parameter in StringCondition" - : "char class parameter missing in StringCondition"; + error = "Unexpected 2nd int parameter in StringCondition"; return false; } - - throw new NotImplementedException(); + error = null; + return true; } [Flags] @@ -398,10 +444,11 @@ private enum ExpectedArgs String = 2, StringArray = 4, Int321 = 8, - Int321OrInt322 = 16, - CharClass = 32 + Int321OrInt322 = 16 | Int321, + CharClass = 32, + Int32AndCharClass = Int321 | CharClass } - private static bool HasFlagFast(ExpectedArgs a, ExpectedArgs b) => (a & b) != 0; + private static bool HasFlagsFast(ExpectedArgs value, ExpectedArgs flags) => (value & flags) == flags; private static ExpectedArgs ForKind(StringConditionKind stringConditionKind) => @@ -423,10 +470,10 @@ private static ExpectedArgs ForKind(StringConditionKind stringConditionKind) => StringConditionKind.EndsWithAnyOrdinalIgnoreCase => ExpectedArgs.StringArray, StringConditionKind.IncludesAnyOrdinal => ExpectedArgs.StringArray, StringConditionKind.IncludesAnyOrdinalIgnoreCase => ExpectedArgs.StringArray, - StringConditionKind.CharLength => ExpectedArgs.Int321, + StringConditionKind.CharLength => ExpectedArgs.Int321OrInt322, StringConditionKind.IntegerRange => ExpectedArgs.Int321OrInt322, StringConditionKind.CharClass => ExpectedArgs.CharClass, - StringConditionKind.StartsWithNCharClass => ExpectedArgs.CharClass | ExpectedArgs.Int321, + StringConditionKind.StartsWithNCharClass => ExpectedArgs.Int32AndCharClass, StringConditionKind.EnglishAlphabet => ExpectedArgs.None, StringConditionKind.NumbersAndEnglishAlphabet => ExpectedArgs.None, StringConditionKind.LowercaseEnglishAlphabet => ExpectedArgs.None, @@ -447,6 +494,51 @@ public bool IsMatch(ReadOnlySpan varSpan, int i, int varSpanLength) { throw new NotImplementedException(); } + + // ToString should return function call syntax + public override string ToString() + { + var name = stringConditionKind.ToDisplayFast() ?? stringConditionKind.ToStringFast(); + var sb = new StringBuilder(name); + sb.Append('('); + if (int1.HasValue) + { + sb.Append(int1); + } + if (int2.HasValue) + { + if (sb[^1] != '(') + sb.Append(","); + sb.Append(int2); + } + // TODO: escape strings properly + else if (c.HasValue) + { + if (sb[^1] != '(') + sb.Append(","); + sb.Append(c); + } + else if (str != null) + { + if (sb[^1] != '(') + sb.Append(","); + sb.Append(str); + } + else if (strArray != null) + { + if (sb[^1] != '(') + sb.Append(","); + sb.Append(string.Join("|", strArray)); + } + else if (charClass != null) + { + if (sb[^1] != '(') + sb.Append(","); + sb.Append(charClass); + } + sb.Append(')'); + return sb.ToString(); + } } [EnumGenerator] diff --git a/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs b/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs index ddb8983..399edad 100644 --- a/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs +++ b/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs @@ -83,6 +83,7 @@ internal static bool IsHexadecimal(this ReadOnlySpan chars) internal static bool IsInIntegerRangeInclusive(this ReadOnlySpan chars, int? min, int? max) { if (!int.TryParse(chars, out var value)) return false; + if (min.HasValue ^ max.HasValue) return value == min || value == max; if (min != null && value < min) return false; if (max != null && value > max) return false; return true; @@ -95,6 +96,7 @@ internal static bool IsInIntegerRangeInclusive(this ReadOnlySpan chars, in internal static bool IsInIntegerRangeInclusive(this ReadOnlySpan chars, int? min, int? max) { if (!int.TryParse(chars.ToString(), out var value)) return false; + if (min.HasValue ^ max.HasValue) return value == min || value == max; if (min != null && value < min) return false; if (max != null && value > max) return false; return true; @@ -121,6 +123,7 @@ internal static bool C(this ReadOnlySpan chars, string[]? disallowedChars) } internal static bool LengthWithinInclusive(this ReadOnlySpan chars, int? min, int? max) { + if (min.HasValue ^ max.HasValue) return chars.Length == min || chars.Length == max; if (min != null && chars.Length < min) return false; if (max != null && chars.Length > max) return false; return true; diff --git a/tests/ImazenShared.Tests/Routing/Matching/MatchExpressionTests.cs b/tests/ImazenShared.Tests/Routing/Matching/MatchExpressionTests.cs index 9338054..4534832 100644 --- a/tests/ImazenShared.Tests/Routing/Matching/MatchExpressionTests.cs +++ b/tests/ImazenShared.Tests/Routing/Matching/MatchExpressionTests.cs @@ -4,18 +4,59 @@ namespace Imazen.Common.Tests.Routing.Matching; public class MatchExpressionTests { + private static MatchingContext CaseSensitive = new MatchingContext + { + OrdinalIgnoreCase = false, + SupportedImageExtensions = [], + }; + private static MatchingContext CaseInsensitive = new MatchingContext + { + OrdinalIgnoreCase = true, + SupportedImageExtensions = [], + }; [Theory] [InlineData(true, true, "/hi")] [InlineData(false, true, "/Hi")] - [InlineData(true, false, "/Hi/")] - public void TestIsMatch(bool isMatch, bool caseSensitive, string path) + [InlineData(true, false, "/Hi")] + public void TestCaseSensitivity(bool isMatch, bool caseSensitive, string path) { - var c = new MatchingContext - { - OrdinalIgnoreCase = !caseSensitive, - SupportedImageExtensions = [], - }; + var c = caseSensitive ? CaseSensitive : CaseInsensitive; var expr = MatchExpression.Parse(c, "/hi"); Assert.Equal(isMatch, expr.IsMatch(c, path)); } + + [Theory] + [InlineData(true, "/{name}/{country}{:equals(/):?}", "/hi/usa", "/hi/usa/")] + [InlineData(true, "/{name}/{country:length(3)}", "/hi/usa")] + [InlineData(false, "/{name}/{country:length(3)}", "/hi/usa2")] + [InlineData(false, "/{name}/{country:length(3)}", "/hi/usa/")] + [InlineData(true, "/{name}/{country:length(3)}", "/hi/usa/")] + public void TestAll(bool s, string expr, params string[] inputs) + { + var caseSensitive = expr.Contains("(i)"); + expr = expr.Replace("(i)", ""); + var c = caseSensitive ? CaseSensitive : CaseInsensitive; + var me = MatchExpression.Parse(c, expr); + foreach (var path in inputs) + { + var matched = me.TryMatchVerbose(c, path.AsMemory(), out var result, out var error); + if (matched && !s) + { + Assert.Fail($"False positive! Expression {expr} should not have matched {path}! False positive."); + } + if (!matched && s) + { + Assert.Fail($"Expression {expr} incorrectly failed to match {path} with error {error}"); + } + } + } + + // Test MatchExpression.Parse + [Fact] + public void TestParse() + { + var c = CaseSensitive; + var expr = MatchExpression.Parse(c, "/{name}/{country}{:equals(/):?}"); + Assert.Equal(5, expr.SegmentCount); + } } \ No newline at end of file