Skip to content

str: take search bounds through the character index - #8530

Merged
youknowone merged 2 commits into
RustPython:mainfrom
youknowone:str-find-byte-bounds
Aug 15, 2026
Merged

str: take search bounds through the character index#8530
youknowone merged 2 commits into
RustPython:mainfrom
youknowone:str-find-byte-bounds

Conversation

@youknowone

@youknowone youknowone commented Aug 14, 2026

Copy link
Copy Markdown
Member

Follows #8522 and #8526, which gave PyStr a code-point index (Wtf8Index) and used it to drive re and to resolve subscripts and slices. The same index answers the remaining quadratic in the string type: the search methods.

What was happening

find, rfind, index, rindex, count, startswith and endswith all take character bounds. They resolved them through AnyStr::get_chars, which walks the payload to both bounds, and find then reported its hit with _to_char_idx, which counts the code points in front of it -- the // FIXME: two traversals of str is expensive that sat above it.

So a sweep over a subject's own indices walked the subject once per call. AnyStr sees only the payload, which does not say whether it is ASCII, so the ASCII path walked too.

What this does

Resolve the bounds with StrData::char_range_to_bytes, the converter #8526 added for slices -- so the search methods inherit its short-walk case too, and s.startswith(x, 1, -1) does not build a table over the whole string. Map the hit back with Wtf8Index::char_index_at_byte, added here as the table's inverse. The table is keyed by character index, so the inverse is a search rather than a lookup, but a narrow one: a character is one to four bytes, which brackets the answer to a band around the byte offset before the first comparison, and the walk that follows is at most an entry's worth of steps. It is also amortised -- the callers that need it have already built the table resolving their own bounds.

AnyStr::get_chars keeps its byte-string callers, where a character range is already a byte range; the FIXME is replaced by a note saying so.

Measurements

n=8000, sweeping the bound over the subject, best of 3, the two binaries interleaved:

sweep subject before after
s.find(x, i) '가나다라' * n/4 18.00 ms 0.15 ms 120x
s.startswith(x, i) '가나다라' * n/4 16.75 ms 0.12 ms 140x
s.count(x, i) '가나다라' * n/4 22.78 ms 4.02 ms 5.7x
s.find(x, i) 'abcd' * n/4 3.48 ms 0.14 ms 25x

The per-doubling factor is the clearer statement: find and startswith go from ×4 (quadratic) to ×2 (linear). count stays ×3 because counting is linear in the range it is handed -- CPython is the same shape there; what drops is the constant.

The first commit is an independent fix

str.count with an empty needle answered in encoded positions rather than characters, because find_iter over the range's bytes reports one hit per byte boundary:

>>> '가나다'.count('')
10          # CPython: 4
>>> '가나다'.count('', 1, 2)
4           # CPython: 2

This is on main today, independent of the rest of the PR; it is fixed first, in the shape main has, so it can be read on its own. bytes.count keeps the byte-position answer, which is correct there. The snippet assertions added with it fail on main and pass after.

Verification

  • A differential of find/rfind/index/rindex/count/startswith/endswith against CPython over 356048 shapes -- 16 subjects (empty, ASCII, 2/3/4-byte, lone surrogates, embedded NUL, and lengths on and around the 64-character group boundary) x 11 needles (absent, ASCII, non-ASCII, multi-character, a lone surrogate) x 17 x 17 bounds (None, in range, past the end, negative, far negative). main differs from CPython on 1375 of them, all of them the empty-needle count; this branch differs on 0.
  • The re differential from _sre: drive a non-ASCII str subject through a character index on the string #8522 (2731 lines) and the subscript/slice differential from str: resolve subscripts and slices through the code point index #8526 (44331 shapes): 0 diffs.
  • test_str, test_bytes, test_re, test_string, test_userstring: SUCCESS. (test_unicode fails identically on main -- it does not reach its tests on either.)
  • cargo clippy --all-targets, cargo fmt --check, cargo test -p rustpython-common clean.

Summary by CodeRabbit

  • Bug Fixes

    • Improved Unicode handling for string searches, indexing, counting, and prefix/suffix checks.
    • Corrected character-position results when operations use byte ranges or supplementary-plane characters.
    • Fixed empty-string counting across ASCII, Unicode, and bounded ranges.
  • Performance

    • Optimized string operations by reducing unnecessary character-by-character traversal while preserving character-based results.
    • Improved handling of character positions in Unicode text, including lookups at the end of strings.

What a single call pays

The table costs a pass over the buffer, so the fair question is what happens to a string that is searched once and never again. Measured on a fresh subject per call, so nothing is amortised, n=1,000,000:

one call on a fresh string main this branch
s.find(miss, len//2) 2508 µs 2314 µs
s.find(miss) (no bounds) 2571 µs 193 µs
s.count(x, len//2) 3172 µs 2808 µs
s[len//2] 2137 µs 2329 µs

A bounded search is a wash: building the table costs about what the walk it replaces cost. An unbounded search is 13x, and that is the common form -- s.find(x) is 0..len, which char_range_to_bytes answers by walking zero steps at each end, so the call becomes a plain byte search with no conversion at all. On main it walked the whole string through get_chars before searching it.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: c6286935-869c-404a-a444-d384f19a74c9

📥 Commits

Reviewing files that changed from the base of the PR and between 2274cef and 465a11a.

📒 Files selected for processing (5)
  • crates/common/src/str.rs
  • crates/common/src/wtf8_index.rs
  • crates/vm/src/anystr.rs
  • crates/vm/src/builtins/str.rs
  • extra_tests/snippets/builtin_str.py

📝 Walkthrough

Walkthrough

The change adds reverse byte-to-character indexing for string data. PyStr uses byte-range searches for startswith, endswith, find, rfind, index, rindex, and count. Tests cover Unicode and empty-needle behavior.

Changes

Unicode string indexing

Layer / File(s) Summary
Reverse index lookup
crates/common/src/str.rs, crates/common/src/wtf8_index.rs
StrData and Wtf8Index now convert character-boundary byte offsets to character indices. Tests cover each code point and the buffer end.
PyStr range and search operations
crates/vm/src/anystr.rs, crates/vm/src/builtins/str.rs, extra_tests/snippets/builtin_str.py
PyStr now converts character ranges to byte ranges, searches bytes directly, and converts results back to character indices. Empty-needle counting tests cover ASCII, Unicode, bounded ranges, empty ranges, and emoji.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 465a1

This change optimizes string search bounds and corrects empty-needle counting while preserving the documented behavior across the reported test matrix; no actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant PyStr
  participant StrData
  participant Wtf8Index
  PyStr->>StrData: Convert character boundary to byte position
  StrData->>Wtf8Index: Resolve non-ASCII byte boundary
  Wtf8Index-->>StrData: Return character index
  StrData-->>PyStr: Return converted index
  PyStr->>PyStr: Search the byte range
Loading

Possibly related PRs

Suggested reviewers: shaharnaveh

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: using character indices to handle string search bounds.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

str.count with an empty needle ran find_iter over the range's bytes, which
reports one position per encoded byte boundary rather than one per
character: '가나다'.count('') answered 10 instead of 4. Count the range's
code points instead; bytes.count keeps the byte-position answer.

Assisted-by: Claude
find, rfind, index, rindex, count, startswith and endswith resolved their
start/stop bounds with AnyStr::get_chars, which walks the payload to both
bounds, and find reported its hit by counting the code points in front of
it -- so a sweep over a subject's own indices walked it once per call.
Resolve the bounds with StrData::char_range_to_bytes instead, which is the
converter the subscript and slice paths already use, and map the hit back
with Wtf8Index::char_index_at_byte, added here as the table's inverse: a
bracketed search over the groups, then at most an entry's worth of steps.

The payload alone does not say whether it is ASCII, so get_chars walked
there too; that path is now off it as well.

n=8000, sweeping the bound over the subject, best of 3, interleaved:

                     '가나다라'*n/4            'abcd'*n/4
    find          18.00ms ->  0.15ms      3.48ms -> 0.14ms
    startswith    16.75ms ->  0.12ms
    count         22.78ms ->  4.02ms

count stays linear in the range it is given, as it is in CPython.

Assisted-by: Claude
@youknowone
youknowone force-pushed the str-find-byte-bounds branch from 3511d1a to 465a11a Compare August 14, 2026 19:45
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

@youknowone I will review the complete pull request, including files that were unchanged in incremental diffs.

✅ Action performed

Full review finished.

@youknowone
youknowone merged commit 5919be9 into RustPython:main Aug 15, 2026
51 of 52 checks passed
@youknowone
youknowone deleted the str-find-byte-bounds branch August 15, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant