From b122a93916909297472ad09eb1ac4efa6b9df996 Mon Sep 17 00:00:00 2001 From: AC Date: Mon, 12 Oct 2026 11:40:16 +0800 Subject: [PATCH] py: normalize bounds for string find and count --- py/string.go | 42 +++++++++++++++---------- py/string_search_bounds_test.go | 56 +++++++++++++++++++++++++++++++++ py/tests/string.py | 8 +++++ 3 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 py/string_search_bounds_test.go diff --git a/py/string.go b/py/string.go index f88d6524..9d6cee3b 100644 --- a/py/string.go +++ b/py/string.go @@ -619,6 +619,26 @@ func (s String) M__contains__(item Object) (Object, error) { return NewBool(strings.Contains(string(s), string(needle))), nil } +// stringSearchBounds adjusts negative indices without clamping a start +// beyond the end, which must not match even an empty substring. +func stringSearchBounds(start, end, length int) (int, int) { + if start < 0 { + start += length + if start < 0 { + start = 0 + } + } + if end < 0 { + end += length + if end < 0 { + end = 0 + } + } else if end > length { + end = length + } + return start, end +} + func (s String) Count(args Tuple) (Object, error) { var ( pysub Object @@ -636,14 +656,9 @@ func (s String) Count(args Tuple) (Object, error) { end = int(pyend.(Int)) size = s.len() ) - if beg > size { - beg = size - } - if end < 0 { - end = size - } - if end > size { - end = size + beg, end = stringSearchBounds(beg, end, size) + if beg > end { + return Int(0), nil } var ( @@ -670,14 +685,9 @@ func (s String) find(args Tuple) (Object, error) { end = int(pyend.(Int)) size = s.len() ) - if beg > size { - beg = size - } - if end < 0 { - end = size - } - if end > size { - end = size + beg, end = stringSearchBounds(beg, end, size) + if beg > end { + return Int(-1), nil } var ( diff --git a/py/string_search_bounds_test.go b/py/string_search_bounds_test.go new file mode 100644 index 00000000..a5cc642d --- /dev/null +++ b/py/string_search_bounds_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 The go-python Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package py + +import ( + "fmt" + "testing" +) + +func TestStringSearchBounds(t *testing.T) { + // Expected results were checked against CPython. + for _, tc := range []struct { + s, sub string + start, end, find, count int + }{ + {"abcabc", "a", -3, 6, 3, 1}, + {"abcabc", "a", 0, -1, 0, 2}, + {"abcabc", "c", 0, -1, 2, 1}, + {"abcabc", "a", -100, 100, 0, 2}, + {"abcabc", "a", 0, -100, -1, 0}, + {"abcabc", "", 7, 100, -1, 0}, + {"abcabc", "", 4, 2, -1, 0}, + {"abcabc", "", 6, 6, 6, 1}, + {"abcabc", "", 0, -1, 0, 6}, + {"é世界é", "é", -1, 4, 3, 1}, + {"é世界é", "界", 0, -1, 2, 1}, + {"é世界é", "", -3, -1, 1, 3}, + {"", "", 0, 0, 0, 1}, + {"", "", 1, 1, -1, 0}, + } { + for _, method := range []string{"find", "count"} { + t.Run(fmt.Sprintf("%s/%q/%q/%d:%d", method, tc.s, tc.sub, tc.start, tc.end), func(t *testing.T) { + defer func() { + if p := recover(); p != nil { + t.Errorf("unexpected panic: %v", p) + } + }() + args := Tuple{String(tc.sub), Int(tc.start), Int(tc.end)} + var got Object + var err error + want := tc.find + if method == "find" { + got, err = String(tc.s).find(args) + } else { + got, err = String(tc.s).Count(args) + want = tc.count + } + if err != nil || got != Int(want) { + t.Errorf("got %v, %v; want %d", got, err, want) + } + }) + } + } +} diff --git a/py/tests/string.py b/py/tests/string.py index d71e8645..20806e00 100644 --- a/py/tests/string.py +++ b/py/tests/string.py @@ -955,3 +955,11 @@ def __index__(self): doc="finished" + +# Search bounds count Unicode characters and accept negative indices. +assert 'abcabc'.find('a', -3) == 3 +assert 'abcabc'.count('c', 0, -1) == 1 +assert 'abc'.find('', 4) == -1 +assert 'abc'.count('', 4) == 0 +assert 'é世界é'.find('é', -1) == 3 +assert 'é世界é'.count('', -3, -1) == 3