From 5b39cc136a9b3668097c1b00ac1e7e9ffb0fe1f7 Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:55:53 +0900 Subject: [PATCH] fix: include end position in Lua pattern searches --- src/Lua/Standard/StringLibrary.cs | 19 +-- tests/Lua.Tests/PatternEndPositionTests.cs | 142 +++++++++++++++++++++ tests/Lua.Tests/PatternMatchingTests.cs | 2 +- 3 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 tests/Lua.Tests/PatternEndPositionTests.cs diff --git a/src/Lua/Standard/StringLibrary.cs b/src/Lua/Standard/StringLibrary.cs index c6c6c7b1..e18932a4 100644 --- a/src/Lua/Standard/StringLibrary.cs +++ b/src/Lua/Standard/StringLibrary.cs @@ -513,12 +513,8 @@ CancellationToken cancellationToken var anchor = pattern.Length > 0 && pattern[0] == '^'; var pIdx = anchor ? 1 : 0; - // For empty patterns, we need to match at every position including after the last character - var sEndIdx = - s.Length - + (pattern.Length == 0 || (anchor && pattern.Length == 1) ? 1 : 0); - - for (var sIdx = start; sIdx < sEndIdx; sIdx++) + // Include the end position so patterns can match an empty string there. + for (var sIdx = start; sIdx <= s.Length; sIdx++) { // Reset match state for each attempt matchState.Level = 0; @@ -604,9 +600,8 @@ CancellationToken cancellationToken var anchor = pattern.Length > 0 && pattern[0] == '^'; var sIdx = 0; - // For empty patterns, we need to match at every position including after the last character - var sEndIdx = s.Length + (pattern.Length == 0 || (anchor && pattern.Length == 1) ? 1 : 0); - while (sIdx < sEndIdx && replaceCount < n) + // Include the end position so patterns can match an empty string there. + while (sIdx <= s.Length && replaceCount < n) { // Reset match state for each attempt matchState.Level = 0; @@ -898,10 +893,8 @@ bool find var anchor = pattern.Length > 0 && pattern[0] == '^'; var pIdx = anchor ? 1 : 0; - // For empty patterns, we need to match at every position including after the last character - var sEndIdx = s.Length + (pattern.Length == 0 ? 1 : 0); - - for (var sIdx = init; sIdx < sEndIdx; sIdx++) + // Include the end position so patterns can match an empty string there. + for (var sIdx = init; sIdx <= s.Length; sIdx++) { // Reset match state for each attempt matchState.Level = 0; diff --git a/tests/Lua.Tests/PatternEndPositionTests.cs b/tests/Lua.Tests/PatternEndPositionTests.cs new file mode 100644 index 00000000..33f6f56d --- /dev/null +++ b/tests/Lua.Tests/PatternEndPositionTests.cs @@ -0,0 +1,142 @@ +using Lua.Standard; + +namespace Lua.Tests; + +public class PatternEndPositionTests +{ + [TestCase("abc", "$", 1, 4, 3)] + [TestCase("abc", "$", -1, 4, 3)] + [TestCase("abc", "a*", 4, 4, 3)] + [TestCase("abc", ".*", 4, 4, 3)] + [TestCase("", "$", 1, 1, 0)] + [TestCase("", "^$", 1, 1, 0)] + [TestCase("", "^", 1, 1, 0)] + [TestCase("", "a*", 1, 1, 0)] + [TestCase("", "a?", 1, 1, 0)] + [TestCase("", "a-", 1, 1, 0)] + public async Task FindEmptyMatch(string source, string pattern, int init, int start, int end) + { + using var state = LuaState.Create(); + state.OpenStringLibrary(); + + var result = await state.DoStringAsync( + $"return string.find('{source}', '{pattern}', {init})" + ); + + Assert.That(result, Is.EqualTo(new LuaValue[] { start, end })); + } + + [TestCase("abc", "$", 1)] + [TestCase("abc", "a*", 4)] + [TestCase("abc", ".*", 4)] + [TestCase("", "$", 1)] + [TestCase("", "^$", 1)] + [TestCase("", "^", 1)] + [TestCase("", "a*", 1)] + [TestCase("", "a?", 1)] + [TestCase("", "a-", 1)] + public async Task MatchEmptyString(string source, string pattern, int init) + { + using var state = LuaState.Create(); + state.OpenStringLibrary(); + + var result = await state.DoStringAsync( + $"return string.match('{source}', '{pattern}', {init})" + ); + + Assert.That(result, Is.EqualTo(new LuaValue[] { "" })); + } + + [TestCase("abc", 4)] + [TestCase("", 1)] + public async Task PositionCaptureAtEnd(string source, int init) + { + using var state = LuaState.Create(); + state.OpenStringLibrary(); + + var result = await state.DoStringAsync( + $""" + local start, finish, position = string.find('{source}', '()', {init}) + return start, finish, position, string.match('{source}', '()', {init}) + """ + ); + + Assert.That(result, Is.EqualTo(new LuaValue[] { init, init - 1, init, init })); + } + + [TestCase("abc", "a+", 4)] + [TestCase("abc", "a*", 5)] + [TestCase("", "a+", 1)] + [TestCase("", "^a", 1)] + [TestCase("", "a*", 2)] + public async Task RejectNonemptyMatchOrStartBeyondEnd(string source, string pattern, int init) + { + using var state = LuaState.Create(); + state.OpenStringLibrary(); + + var result = await state.DoStringAsync( + $""" + return string.find('{source}', '{pattern}', {init}), + string.match('{source}', '{pattern}', {init}) + """ + ); + + Assert.That(result, Is.EqualTo(new LuaValue[] { LuaValue.Nil, LuaValue.Nil })); + } + + [TestCase("", "$", "", 1)] + [TestCase("a", "$", "", 1)] + [TestCase("", "a*", "", 1)] + [TestCase("a", "a*", "a,", 2)] + [TestCase("abc", "a*", "a,,,", 4)] + [TestCase("", "()", "1", 1)] + [TestCase("abc", "()", "1,2,3,4", 4)] + [TestCase("abc", "", ",,,", 4)] + public async Task GMatchIncludesEnd(string source, string pattern, string expected, int count) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + + var result = await state.DoStringAsync( + $$""" + local source = '{{source}}' + local matches = {} + for value in string.gmatch(source, '{{pattern}}') do + matches[#matches + 1] = value + if #matches > #source + 1 then error('iterator did not terminate') end + end + return table.concat(matches, ','), #matches + """ + ); + + Assert.That(result, Is.EqualTo(new LuaValue[] { expected, count })); + } + + [TestCase("abc", "$", 4, "abcX", 1)] + [TestCase("", "$", 1, "X", 1)] + [TestCase("", "^$", 1, "X", 1)] + [TestCase("", "a*", 1, "X", 1)] + [TestCase("abc", "()", 4, "XaXbXcX", 4)] + [TestCase("a", "a*", 2, "XX", 2)] + [TestCase("a", "a*", 1, "X", 1)] + [TestCase("abc", "$", 0, "abc", 0)] + [TestCase("", "a+", 1, "", 0)] + [TestCase("abc", "", 4, "XaXbXcX", 4)] + public async Task GSubIncludesEnd( + string source, + string pattern, + int limit, + string expected, + int count + ) + { + using var state = LuaState.Create(); + state.OpenStringLibrary(); + + var result = await state.DoStringAsync( + $"return string.gsub('{source}', '{pattern}', 'X', {limit})" + ); + + Assert.That(result, Is.EqualTo(new LuaValue[] { expected, count })); + } +} diff --git a/tests/Lua.Tests/PatternMatchingTests.cs b/tests/Lua.Tests/PatternMatchingTests.cs index 1bd7e5e1..798ca1c8 100644 --- a/tests/Lua.Tests/PatternMatchingTests.cs +++ b/tests/Lua.Tests/PatternMatchingTests.cs @@ -374,7 +374,7 @@ public async Task Test_StringGMatch_EmptyMatches() return count " ); - Assert.That(result[0].Read(), Is.EqualTo(3)); + Assert.That(result[0].Read(), Is.EqualTo(4)); // Includes the empty match after 'c'. } [Test]