Skip to content

Commit de564a8

Browse files
committed
fix: align Lua pattern captures and replacement semantics
1 parent 05051bb commit de564a8

4 files changed

Lines changed: 280 additions & 117 deletions

File tree

‎src/Lua/Standard/Internal/MatchState.cs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,21 @@ internal struct Capture
2525
public readonly string Source = source;
2626
public readonly string Pattern = pattern;
2727
public int Level = 0;
28+
29+
// Only slots below Level are live; StartCapture initializes them before use.
2830
internal readonly Capture[] Captures = new Capture[LuaMaxCaptures];
2931
public int MatchDepth = MaxCalls;
3032

33+
public LuaValue GetCapture(int index)
34+
{
35+
var capture = Captures[index];
36+
if (capture.IsUnfinished)
37+
throw new LuaRuntimeException(State, "unfinished capture");
38+
return capture.IsPosition
39+
? new LuaValue(capture.Init + 1)
40+
: new LuaValue(Source.AsSpan(capture.Init, capture.Len).ToString());
41+
}
42+
3143
public static bool NoSpecials(ReadOnlySpan<char> pattern)
3244
{
3345
#if NET8_0_OR_GREATER

‎src/Lua/Standard/StringLibrary.cs‎

Lines changed: 65 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -522,20 +522,14 @@ CancellationToken cancellationToken
522522
MatchState matchState = new(context.State, s, pattern);
523523
var captures = matchState.Captures;
524524

525-
// Check for anchor at start
526-
var anchor = pattern.Length > 0 && pattern[0] == '^';
527-
var pIdx = anchor ? 1 : 0;
528-
529525
// Include the end position so patterns can match an empty string there.
530526
for (var sIdx = start; sIdx <= s.Length; sIdx++)
531527
{
532528
// Reset match state for each attempt
533529
matchState.Level = 0;
534530
matchState.MatchDepth = MatchState.MaxCalls;
535-
// Clear captures to avoid stale data
536-
Array.Clear(captures, 0, captures.Length);
537531

538-
var res = matchState.Match(sIdx, pIdx);
532+
var res = matchState.Match(sIdx, 0);
539533

540534
if (res >= 0)
541535
{
@@ -551,31 +545,17 @@ CancellationToken cancellationToken
551545
var buffer = context.GetReturnBuffer(resultLength);
552546
for (var i = 0; i < matchState.Level; i++)
553547
{
554-
var capture = captures[i];
555-
if (capture.IsPosition)
556-
{
557-
buffer[i] = capture.Init + 1; // 1-based position
558-
}
559-
else
560-
{
561-
buffer[i] = s.AsSpan(capture.Init, capture.Len).ToString();
562-
}
548+
buffer[i] = matchState.GetCapture(i);
563549
}
564550

565551
// Update start index for next iteration
566552
// Handle empty matches by advancing at least 1 position
567553
upValues[2] = res > sIdx ? res : sIdx + 1;
568554
return new(resultLength);
569555
}
570-
571-
// For anchored patterns, only try once
572-
if (anchor)
573-
{
574-
break;
575-
}
576556
}
577557

578-
return new(context.Return(LuaValue.Nil));
558+
return new(context.Return());
579559
}
580560
)
581561
)
@@ -654,18 +634,36 @@ CancellationToken cancellationToken
654634
var repl = context.GetArgument(2);
655635
var n_arg = context.HasArgument(3) ? context.GetArgument<double>(3) : s.Length + 1;
656636

657-
LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 4, n_arg);
658-
659-
var n = (int)n_arg;
637+
n_arg = Math.Truncate(n_arg);
638+
if (!MathEx.IsFinite(n_arg))
639+
LuaRuntimeException.BadArgumentNumberIsNotInteger(context.State, 4);
640+
if (
641+
repl.Type
642+
is not (
643+
LuaValueType.String
644+
or LuaValueType.Number
645+
or LuaValueType.Table
646+
or LuaValueType.Function
647+
)
648+
)
649+
LuaRuntimeException.BadArgument(context.State, 3, "string/function/table expected");
650+
// Lua 5.2 stores the limit as size_t; a negative integer means no practical limit.
651+
var n = n_arg < 0 || n_arg > int.MaxValue ? int.MaxValue : (int)n_arg;
652+
if (n == 0)
653+
return context.Return(s, 0);
660654

661-
// Use MatchState instead of regex
662655
MatchState matchState = new(context.State, s, pattern);
663656
var captures = matchState.Captures;
664657

665658
StringBuilder builder = new();
659+
var replacementText =
660+
repl.Type == LuaValueType.String ? repl.UnsafeReadString()
661+
: repl.Type == LuaValueType.Number
662+
? repl.UnsafeReadDouble().ToString(default, CultureInfo.InvariantCulture)
663+
: null;
666664
var replacedBuilder =
667-
repl.Type == LuaValueType.String
668-
? new StringBuilder(repl.UnsafeReadString().Length)
665+
replacementText != null && replacementText.IndexOf('%') >= 0
666+
? new StringBuilder(replacementText.Length)
669667
: null;
670668
var lastIndex = 0;
671669
var replaceCount = 0;
@@ -680,11 +678,6 @@ CancellationToken cancellationToken
680678
// Reset match state for each attempt
681679
matchState.Level = 0;
682680
Debug.Assert(matchState.MatchDepth == MatchState.MaxCalls);
683-
// Clear captures array to avoid stale data
684-
for (var i = 0; i < captures.Length; i++)
685-
{
686-
captures[i] = default;
687-
}
688681

689682
// Always start pattern from beginning (0 or 1 if anchored)
690683
var pIdx = anchor ? 1 : 0;
@@ -704,34 +697,30 @@ CancellationToken cancellationToken
704697
}
705698

706699
LuaValue result;
707-
if (repl.TryRead<string>(out var str))
700+
if (replacementText != null)
708701
{
709-
if (!str.Contains("%"))
702+
if (replacedBuilder == null)
710703
{
711-
result = str; // No special characters, use as is
704+
result = replacementText;
712705
}
713706
else
714707
{
715708
// String replacement
716-
replacedBuilder!.Clear();
717-
AppendReplacement(replacedBuilder, str, s, sIdx, res, matchState);
709+
replacedBuilder.Clear();
710+
AppendReplacement(
711+
replacedBuilder,
712+
replacementText,
713+
s,
714+
sIdx,
715+
res,
716+
matchState
717+
);
718718
result = replacedBuilder.ToString();
719719
}
720720
}
721721
else if (repl.TryRead<LuaTable>(out var table))
722722
{
723-
// Table lookup - use first capture or whole match
724-
LuaValue key;
725-
if (matchState.Level > 0)
726-
{
727-
key = captures[0].IsPosition
728-
? captures[0].Init + 1
729-
: s.AsSpan(captures[0].Init, captures[0].Len).ToString();
730-
}
731-
else
732-
{
733-
key = s.AsSpan(sIdx, res - sIdx).ToString();
734-
}
723+
var key = matchState.GetCapture(0);
735724

736725
if (!table.TryGetValue(key, out result) && table.Metatable != null)
737726
{
@@ -743,60 +732,32 @@ CancellationToken cancellationToken
743732
);
744733
}
745734
}
746-
else if (repl.TryRead<LuaFunction>(out var func))
735+
else
747736
{
737+
var func = repl.UnsafeRead<LuaFunction>();
748738
// Function call with captures as arguments
749739
var stack = context.State.Stack;
750740

751-
if (matchState.Level == 0)
741+
for (var k = 0; k < matchState.Level; k++)
752742
{
753-
// No captures, pass whole match
754-
stack.Push(s.AsSpan(sIdx, res - sIdx).ToString());
755-
var retCount = await context.State.RunAsync(func, 1, cancellationToken);
756-
using var results = context.State.ReadStack(retCount);
757-
result = results.Count > 0 ? results[0] : LuaValue.Nil;
743+
stack.Push(matchState.GetCapture(k));
758744
}
759-
else
760-
{
761-
// Pass all captures
762-
for (var k = 0; k < matchState.Level; k++)
763-
{
764-
var capture = captures[k];
765-
if (capture.IsPosition)
766-
{
767-
stack.Push(capture.Init + 1); // 1-based position
768-
}
769-
else
770-
{
771-
stack.Push(s.AsSpan(capture.Init, capture.Len).ToString());
772-
}
773-
}
774-
775-
var retCount = await context.State.RunAsync(
776-
func,
777-
matchState.Level,
778-
cancellationToken
779-
);
780-
using var results = context.State.ReadStack(retCount);
781-
result = results.Count > 0 ? results[0] : LuaValue.Nil;
782-
}
783-
}
784-
else
785-
{
786-
throw new LuaRuntimeException(
787-
context.State,
788-
"bad argument #3 to 'gsub' (string/function/table expected)"
745+
var retCount = await context.State.RunAsync(
746+
func,
747+
matchState.Level,
748+
cancellationToken
789749
);
750+
using var results = context.State.ReadStack(retCount);
751+
result = results.Count > 0 ? results[0] : LuaValue.Nil;
790752
}
791-
792753
// Handle replacement result
793754
if (result.TryRead<string>(out var rs))
794755
{
795756
builder.Append(rs);
796757
}
797758
else if (result.TryRead<double>(out var rd))
798759
{
799-
builder.Append(rd);
760+
AppendNumber(builder, rd);
800761
}
801762
else if (!result.ToBoolean())
802763
{
@@ -807,7 +768,7 @@ CancellationToken cancellationToken
807768
{
808769
throw new LuaRuntimeException(
809770
context.State,
810-
$"invalid replacement value (a {result.Type})"
771+
$"invalid replacement value (a {result.TypeToString()})"
811772
);
812773
}
813774

@@ -894,33 +855,22 @@ public ValueTask<int> FindAux(LuaFunctionExecutionContext context, bool find)
894855
{
895856
var s = context.GetArgument<string>(0);
896857
var pattern = context.GetArgument<string>(1);
897-
var init = context.HasArgument(2) ? context.GetArgument<int>(2) : 1;
898-
899-
LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 3, init);
900-
901-
// Convert to 0-based index
902-
if (init < 0)
903-
{
904-
init = s.Length + init + 1;
905-
}
906-
907-
init--; // Convert from 1-based to 0-based
858+
var init = context.HasArgument(2) ? ReadStringIndex(context, 2) : 1;
859+
var offset = Math.Max(1, StringHelper.RelativePosition(init, s.Length)) - 1;
908860

909861
// Check if init is beyond string bounds
910-
if (init > s.Length)
862+
if (offset > s.Length)
911863
{
912864
return new(context.Return(LuaValue.Nil));
913865
}
914866

915-
init = Math.Max(0, init); // Clamp to 0 if negative
916-
917867
// Check for plain search mode (4th parameter = true) or if pattern has no special characters
918868
if (find && (context.GetArgumentOrDefault(3).ToBoolean() || MatchState.NoSpecials(pattern)))
919869
{
920-
return PlainSearch(context, s, pattern, init);
870+
return PlainSearch(context, s, pattern, offset);
921871
}
922872

923-
return PatternSearch(context, s, pattern, init, find);
873+
return PatternSearch(context, s, pattern, offset, find);
924874
}
925875

926876
static ValueTask<int> PlainSearch(
@@ -961,7 +911,6 @@ bool find
961911
// Reset match state for each attempt
962912
matchState.Level = 0;
963913
matchState.MatchDepth = MatchState.MaxCalls;
964-
Array.Clear(captures, 0, captures.Length);
965914

966915
var res = matchState.Match(sIdx, pIdx);
967916

@@ -989,15 +938,7 @@ bool find
989938
// Return captures
990939
for (var i = 0; i < matchState.Level; i++)
991940
{
992-
var capture = captures[i];
993-
if (capture.IsPosition)
994-
{
995-
buffer[i] = capture.Init + 1; // 1-based position
996-
}
997-
else
998-
{
999-
buffer[i] = s.AsSpan(capture.Init, capture.Len).ToString();
1000-
}
941+
buffer[i] = matchState.GetCapture(i);
1001942
}
1002943

1003944
return new(resultLength);
@@ -1140,4 +1081,11 @@ static int ReadStringIndex(in LuaFunctionExecutionContext context, int index)
11401081
return (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, number));
11411082
#endif
11421083
}
1084+
1085+
static void AppendNumber(StringBuilder builder, double value)
1086+
{
1087+
Span<char> buffer = stackalloc char[32];
1088+
value.TryFormat(buffer, out var written, default, CultureInfo.InvariantCulture);
1089+
builder.Append(buffer[..written]);
1090+
}
11431091
}

0 commit comments

Comments
 (0)