Skip to content

Searching without splitting

Level: 101 → 201 · working knowledge

One line: Four questions about a piece of text — is it there, where is it, does it begin like this, how many — answered by methods that all take the same argument, because Pattern is one trait and a char, a &str, a list of characters and a closure are four spellings of it.

Walking a String cuts text into pieces. This page asks questions about it without cutting: the methods that return a bool, an Option<usize> or a count and leave the string exactly as it was.

One number on this page is worth more care than the rest. find reports a byte offset — which is precisely what a slice wants, and precisely not what a person means by "the third letter".

Verified output of searching_a_string.rs — regenerated by tools/run_examples.py, never hand-typed.

1. Four questions about one line
   "2026-09-06 WARN disk usage 91% on /dev/sda1"
   contains("WARN")      true
   find("WARN")          Some(11)
   starts_with("2026")   true
   matches('/').count()  2
   contains is find(..).is_some() under a name that reads: true

2. One argument, four shapes -- this is the Pattern trait
   char                  Some(11)
   &str                  Some(11)
   [char] (any one of)   Some(29)
   FnMut(char) -> bool   Some(0)
   The same four shapes go to the other families unchanged:
   split(char::is_whitespace)     ["a", "b", "c"]
   trim_matches(['[', ']'])       "warn"
   replace(char::is_numeric, "#") "sda#"

3. find returns a BYTE offset, not a character index
   "żółw" is 7 bytes and 4 chars
   find('w')  = 6   <- bytes
   as a character index that is 3
   the byte offset is the one that slices: &s[..6] = "żół"
   and &s[6..] = "w"
   Feed the character index to a slice instead and one of two
   things happens. It lands inside a letter and panics:
   &s[..3]  -> panicked
   Or it lands on a boundary and is quietly wrong -- two characters
   asked for, one delivered: &s[..2] = "ż"

4. Option, not -1
   find("ERROR") = None
   absent -- and there is no offset lying around to misuse
   Where a search returns -1, `&line[i..]` is the waiting bug.
   Here the -1 has no way to exist: usize has no negative value and
   the answer is not a usize until you have unwrapped it.

5. starts_with, and the method you probably wanted instead
   starts_with("/dev/")   true
   strip_prefix("/dev/")  Some("sda1")
   strip_suffix("1")      Some("/dev/sda")
   strip_prefix answers the question AND hands back the rest, so the
   length of the prefix is never written down a second time.

6. Backwards
   "/usr/local/share/doc"
   find('/')         Some(0)
   rfind('/')        Some(16)
   rsplit_once('/')  Some(("/usr/local/share", "doc"))

7. Two things the search does not do
   It does not fold case:   false
   lowercase both sides:    true
   The empty pattern is everywhere: find("") = Some(0), "".contains("") = true

Which question are you asking

the question the method what comes back
is it in there at all contains bool
where is the first one find Option<usize> — a byte offset
where is the last one rfind Option<usize>
does it begin, or end, like this starts_with · ends_with bool
…and hand me the rest strip_prefix · strip_suffix Option<&str>
how many are there matches().count() usize
where are all of them match_indices an iterator of (usize, &str)

contains is find(..).is_some() wearing a name that reads. Reach for it whenever the offset is not going to be used — including as a guard in front of something expensive, which is the job it does on Replacing part of a string.

The argument is a Pattern, and everything takes it

Every method in that table, plus the split family, the trim family and replace, accepts the same four shapes, because they all take a P: Pattern:

fn main() {
    let line = "sda1 sdb2";
    println!("{:?}", line.find('s'));                 // a char
    println!("{:?}", line.find("sdb"));               // a substring
    println!("{:?}", line.find(['1', '2']));          // any one of these chars
    println!("{:?}", line.find(char::is_numeric));    // any char the closure likes
}

The str method reference has the four shapes as a table with a worked example each. Two things about them are worth carrying around. A list of characters means any one of them, not that sequence — the most common misreading, and the reason find(["1", "2"]) is not what you want. And a closure is the shape that searches for a category rather than a value: char::is_whitespace, or |c: char| c.is_ascii_punctuation().

The trait you can use everywhere and name nowhere

Pattern is still unstable, so it is available on every call and unavailable in every signature you write yourself:

error[E0658]: use of unstable library feature `pattern`: API not fully fleshed out and ready to be stabilized
 --> pat.rs:3:14
  |
3 | fn occurs<P: Pattern>(haystack: &str, pat: P) -> bool {
  |              ^^^^^^^
  |
  = note: see issue #27721 <https://github.com/rust-lang/rust/issues/27721> for more information

That is why your own text helpers end up less flexible than std's. Three stable shapes stand in for it, and the kata below writes all three: take the concrete type you actually need, take an impl FnMut(char) -> bool, or take no pattern at all and let the caller pass the offset.

find hands you a byte offset

"żółw".find('w') is 6, not 3. The first three letters cost two bytes each.

That is the right number for the thing you are most likely to do next, which is slice: &s[..6] is "żół". It is the wrong number to show a person, and converting is one call — s[..at].chars().count() — which is O(n) and therefore not what std chose to hand you by default.

Getting it backwards fails in one of two ways, and only one of them is loud:

  • the character index lands inside a letter and the slice panics — &s[..3] is inside ó
  • the character index lands on a boundary and the slice is quietly short — &s[..2] asks for two characters and returns one

In ASCII the two numbers are equal, so every test you are likely to write agrees with both — which is how a wrong one ships. See String slices for the panic in full, and is_char_boundary for the question to ask first.

starts_with, then the method you probably wanted

fn main() {
    let path = "/dev/sda1";

    // Writes "/dev/" once, as a length, and once as text. Keep both right.
    if path.starts_with("/dev/") {
        println!("{:?}", &path[5..]);          // "sda1"
    }

    // Writes it once.
    println!("{:?}", path.strip_prefix("/dev/"));   // Some("sda1")
}

You rarely want only the bool that starts_with gives. strip_prefix answers the same question and returns the remainder, so there is no separate constant to keep in step — and it composes, because the Option it returns is what ? and if let are for. Same for strip_suffix against ends_with.

What is not here

There is no regex in std. The pattern shapes above cover a fixed string, a set of characters and a character class; anything that needs alternation, repetition, anchors or capture groups is the regex crate, deliberately, because a regex engine is a compiler. Six searches, and the two that want a regex is the kata that walks the line: it builds a field splitter, an offset finder and an address matcher out of std alone, then names the two jobs where hand-rolling stops being reasonable.

Searching also does not fold case and does not normalize. "WARN".contains("warn") is false; lowercasing both sides fixes the first, and nothing in std fixes the second — é written as one scalar will not match é written as e plus a combining accent. Comparing and sorting text is where that belongs.

If you are coming from another language

Python. "x" in s is s.contains("x"), and s.find(..) exists in both — with two differences that both bite silently. Python's returns a character index where Rust's returns a byte offset ("żółw".find("w") is 3 in Python, 6 in Rust), and Python's reports failure as -1 where Rust's returns None. The -1 is the more dangerous of the two, because it is a valid index: s[find(x):] on a miss quietly returns the last character instead of raising — measured on the Python side ↗, where a search for a word that is not in the string hands back 'm' and nothing raises, nothing logs. Rust's Option cannot be used before it has been unwrapped, which is the same protection Python offers only if you remember to reach for .index() and catch ValueError. str.removeprefix (3.9+) is strip_prefix(..).unwrap_or(s): it hands the string back unchanged rather than telling you it did nothing.

ABAP. FIND 'x' IN lv_text reports through sy-subrc, and MATCH OFFSET counts characters, because ABAP text is UTF-16 internally rather than UTF-8. So the ABAP habit — get an offset, then lv_text+off(len) — transfers in shape but not in units, and a substring expression written from a Rust byte offset addresses the wrong place the first time a non-ASCII character appears earlier in the string. CONTAINS( ) in a logical expression is contains; IF sy-subrc = 0 after a FIND is the same question asked the long way. (Not machine-checked — CI cannot run ABAP.)

Practice

Two numbers, one prefix, and the trait you are not allowed to name.

Write char_index_of(haystack, needle) -> Option<usize> returning the character index of the first match. Run it beside find on an ASCII string and on a string with Polish letters in it, and print whether the two agree — the point being that every ASCII test you write will say they do.

Then take if path.starts_with("/dev/") { &path[4..] }, work out what it returns and why, and replace it with the one-line version that has no constant to get wrong.

Finally, try to write your own helper generic over the pattern — fn occurs<P: Pattern>(haystack: &str, pat: P) -> bool — and read the error. Then write the three stable helpers that stand in for it, and say which one you would put in a shared module.

Solution

searching_a_string_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: the offset that is not an index, `strip_prefix` as the honest
//! form of `starts_with`, and the three helpers you write once you know you
//! cannot name `Pattern`.
//!
//!   rustc --edition 2024 searching_a_string_kata.rs -o /tmp/sask && /tmp/sask

/// The *character* index of the first match, for a message a person reads.
/// `find` reports bytes; converting costs a walk of everything before the
/// match, which is why std hands you the cheap number and lets you choose.
fn char_index_of(haystack: &str, needle: &str) -> Option<usize> {
    haystack.find(needle).map(|at| haystack[..at].chars().count())
}

/// Three shapes that stand in for a `P: Pattern` you are not allowed to write.
mod helpers {
    /// 1. Take the concrete shape the caller actually uses.
    pub fn after_str<'a>(text: &'a str, marker: &str) -> Option<&'a str> {
        text.find(marker).map(|at| &text[at + marker.len()..])
    }

    /// 2. Take a closure -- that covers the char-predicate shape, which is the
    ///    one worth being generic over.
    pub fn from_first(text: &str, pred: impl FnMut(char) -> bool) -> Option<&str> {
        text.find(pred).map(|at| &text[at..])
    }

    /// 3. Do not take a pattern at all. Take the answer, and let the caller
    ///    pick the search.
    pub fn from_offset(text: &str, at: usize) -> &str {
        &text[at..]
    }
}

fn main() {
    println!("1. The two numbers, and why the bug hides");
    for (text, needle) in [("disk sda1 full", "full"), ("dysk żółw pełny", "pełny")] {
        let bytes = text.find(needle);
        let chars = char_index_of(text, needle);
        let same = if bytes == chars { "agree" } else { "DIFFER" };
        println!("   {:<20} find {:?}  chars {:?}  {}", format!("{text:?}"), bytes, chars, same);
    }
    println!("   Every ASCII test agrees, so a wrong choice ships. The first row of");
    println!("   real data with a two-byte letter in it is where you find out.");
    println!("   Rule of thumb: bytes for slicing, chars for telling a person.");

    println!("\n2. starts_with, then a constant you have to keep right");
    let path = "/dev/sda1";
    let by_hand = if path.starts_with("/dev/") { &path[4..] } else { path };
    let by_strip = path.strip_prefix("/dev/").unwrap_or(path);
    println!("   &path[4..]             {by_hand:?}   <- \"/dev/\" is 5 bytes, not 4");
    println!("   strip_prefix(\"/dev/\")  {by_strip:?}");
    println!("   The two lines carry the same string; only one of them carries it");
    println!("   twice. strip_prefix has no length to disagree with.");

    println!("\n3. The trait you can use but cannot name");
    println!("   fn occurs<P: Pattern>(..) is E0658 on stable, so:");
    println!("   after_str(\"a=1;b=2\", \"b=\")            {:?}",
        helpers::after_str("a=1;b=2", "b="));
    println!("   from_first(\"timeout = 30s\", numeric)  {:?}",
        helpers::from_first("timeout = 30s", char::is_numeric));
    let text = "timeout = 30s";
    println!("   from_offset(text, rfind(' ') + 1)     {:?}",
        text.rfind(' ').map(|at| helpers::from_offset(text, at + 1)));
    println!("   The third is the one to reach for when the caller already knows");
    println!("   where to look: it is the only one of the three that cannot search");
    println!("   for the wrong thing.");
}

Verified output of searching_a_string_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

1. The two numbers, and why the bug hides
   "disk sda1 full"     find Some(10)  chars Some(10)  agree
   "dysk żółw pełny"    find Some(13)  chars Some(10)  DIFFER
   Every ASCII test agrees, so a wrong choice ships. The first row of
   real data with a two-byte letter in it is where you find out.
   Rule of thumb: bytes for slicing, chars for telling a person.

2. starts_with, then a constant you have to keep right
   &path[4..]             "/sda1"   <- "/dev/" is 5 bytes, not 4
   strip_prefix("/dev/")  "sda1"
   The two lines carry the same string; only one of them carries it
   twice. strip_prefix has no length to disagree with.

3. The trait you can use but cannot name
   fn occurs<P: Pattern>(..) is E0658 on stable, so:
   after_str("a=1;b=2", "b=")            Some("2")
   from_first("timeout = 30s", numeric)  Some("30s")
   from_offset(text, rfind(' ') + 1)     Some("30s")
   The third is the one to reach for when the caller already knows
   where to look: it is the only one of the three that cannot search
   for the wrong thing.

The longest common prefix, by letters. Write longest_common_prefix to pass the five tests below, the empty slice included. Then write a second version that returns a &str borrowed from the words instead of a new String — it needs one lifetime parameter, and the question is which of the two references in the signature it belongs to. Finally run both on ["café", "cafè"]: the words share four bytes and only three letters, and &"café"[..4] panics.

// rustc --edition 2024 --test common_prefix.rs -o t && ./t
fn longest_common_prefix(strings: &[&str]) -> String {
    todo!()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_longest_common_prefix() {
        assert_eq!(longest_common_prefix(&["flower", "flow", "flight"]), "fl");
        assert_eq!(longest_common_prefix(&["dog", "racecar", "car"]), "");
        assert_eq!(longest_common_prefix(&["interspecies", "interstellar", "interstate"]), "inters");
        assert_eq!(longest_common_prefix(&[]), "");
        assert_eq!(longest_common_prefix(&["single"]), "single");
    }
}
Solution

common_prefix_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: the longest common prefix — by characters, since two words
//! can share a first byte and not a first letter.
//!
//!   rustc --edition 2024 common_prefix_kata.rs -o /tmp/cpk && /tmp/cpk
//!   rustc --edition 2024 --test common_prefix_kata.rs -o /tmp/cpkt && /tmp/cpkt

/// Adam's signature, which hands back an owned `String`.
fn longest_common_prefix(strings: &[&str]) -> String {
    borrowed_prefix(strings).to_owned()
}

/// The same answer without allocating: a slice of the first word. `'a` says
/// the result borrows from the words themselves — not from the array that
/// holds them, which may be a temporary.
fn borrowed_prefix<'a>(strings: &[&'a str]) -> &'a str {
    let Some((&first, rest)) = strings.split_first() else {
        return "";
    };
    let mut end = first.len();
    for s in rest {
        end = first[..end]
            .char_indices()
            .zip(s.chars())
            .take_while(|&((_, a), b)| a == b)
            .last()
            .map_or(0, |((i, a), _)| i + a.len_utf8());
    }
    &first[..end]
}

/// The tempting version: count equal leading BYTES.
fn shared_bytes(a: &str, b: &str) -> usize {
    a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_longest_common_prefix() {
        assert_eq!(longest_common_prefix(&["flower", "flow", "flight"]), "fl");
        assert_eq!(longest_common_prefix(&["dog", "racecar", "car"]), "");
        assert_eq!(longest_common_prefix(&["interspecies", "interstellar", "interstate"]), "inters");
        assert_eq!(longest_common_prefix(&[]), "");
        assert_eq!(longest_common_prefix(&["single"]), "single");
    }
}

fn main() {
    println!("1. Adam's five cases");
    let cases: [(&[&str], &str); 5] = [
        (&["flower", "flow", "flight"], "fl"),
        (&["dog", "racecar", "car"], ""),
        (&["interspecies", "interstellar", "interstate"], "inters"),
        (&[], ""),
        (&["single"], "single"),
    ];
    for (words, want) in cases {
        let got = longest_common_prefix(words);
        assert_eq!(got, want);
        println!("   {:<47} -> {got:?}", format!("{words:?}"));
    }

    println!();
    println!("2. The borrowed version allocates nothing");
    let words = ["interspecies", "interstellar", "interstate"];
    let p = borrowed_prefix(&words);
    println!("   borrowed_prefix -> {p:?}");
    println!("   points into words[0]: {}", p.as_ptr() == words[0].as_ptr());
    println!("   One lifetime parameter is what lets a function hand back a view into");
    println!("   its caller's data instead of a copy of it.");

    println!();
    println!("3. Bytes are the wrong unit");
    for (a, b) in [("café", "cafè"), ("é", "è"), ("Łódź", "Łomża")] {
        let n = shared_bytes(a, b);
        println!(
            "   {a:?} / {b:?}: shared bytes {n}, a.get(..{n}) = {:?}, by characters {:?}",
            a.get(..n),
            borrowed_prefix(&[a, b])
        );
    }
    println!("   é is C3 A9 and è is C3 A8: the first byte agrees and the letter does");
    println!("   not. Slicing at that count is the panic `get` turns into None.");
    println!("   Where the shared bytes happen to end on a boundary, as with Ł, the two");
    println!("   answers agree, which is why a byte version passes every ASCII test.");
}

Verified output of common_prefix_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

1. Adam's five cases
   ["flower", "flow", "flight"]                    -> "fl"
   ["dog", "racecar", "car"]                       -> ""
   ["interspecies", "interstellar", "interstate"]  -> "inters"
   []                                              -> ""
   ["single"]                                      -> "single"

2. The borrowed version allocates nothing
   borrowed_prefix -> "inters"
   points into words[0]: true
   One lifetime parameter is what lets a function hand back a view into
   its caller's data instead of a copy of it.

3. Bytes are the wrong unit
   "café" / "cafè": shared bytes 4, a.get(..4) = None, by characters "caf"
   "é" / "è": shared bytes 1, a.get(..1) = None, by characters ""
   "Łódź" / "Łomża": shared bytes 2, a.get(..2) = Some("Ł"), by characters "Ł"
   é is C3 A9 and è is C3 A8: the first byte agrees and the letter does
   not. Slicing at that count is the panic `get` turns into None.
   Where the shared bytes happen to end on a boundary, as with Ł, the two
   answers agree, which is why a byte version passes every ASCII test.

A regex engine with two operators. Write is_match(s, pattern) for patterns where . matches any one character and * means zero or more of the item before it. Slice patterns over &[char] make it short — an arm like [c, '*', rest @ ..] states the star rule in its own shape. Then run the same matcher over bytes instead of characters and try "é" against "." and against "..": the answers swap, which is the whole case for collecting a Vec<char> before you match anything.

// rustc --edition 2024 --test tiny_regex.rs -o t && ./t
fn is_match(s: &str, pattern: &str) -> bool {
    todo!()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_match() {
        assert!(is_match("aa", "a*"));
        assert!(is_match("ab", ".*"));
        assert!(is_match("aab", "c*a*b"));
        assert!(!is_match("mississippi", "mis*is*p*."));
        assert!(is_match("", "a*"));
        assert!(!is_match("", "a"));
    }
}
Solution

tiny_regex_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: `.` and `*` in a dozen lines, read with slice patterns —
//! and why `.` has to mean one character rather than one byte.
//!
//!   rustc --edition 2024 tiny_regex_kata.rs -o /tmp/trk && /tmp/trk
//!   rustc --edition 2024 --test tiny_regex_kata.rs -o /tmp/trkt && /tmp/trkt

fn is_match(s: &str, pattern: &str) -> bool {
    let s: Vec<char> = s.chars().collect();
    let p: Vec<char> = pattern.chars().collect();
    here(&s, &p, '.', '*')
}

/// One rule per arm, chosen by the pattern's first two items. An item
/// followed by the star may match zero copies (skip both) or one more copy
/// (consume one input and stay put); anything else must match exactly one.
/// Generic over the unit, so the same matcher can be run on bytes.
fn here<T: PartialEq + Copy>(s: &[T], p: &[T], any: T, star: T) -> bool {
    let one = |c: T| !s.is_empty() && (c == any || c == s[0]);
    match p {
        [] => s.is_empty(),
        [c, st, rest @ ..] if *st == star => here(s, rest, any, star) || (one(*c) && here(&s[1..], p, any, star)),
        [c, rest @ ..] => one(*c) && here(&s[1..], rest, any, star),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_match() {
        assert!(is_match("aa", "a*"));
        assert!(is_match("ab", ".*"));
        assert!(is_match("aab", "c*a*b"));
        assert!(!is_match("mississippi", "mis*is*p*."));
        assert!(is_match("", "a*"));
        assert!(!is_match("", "a"));
    }
}

fn main() {
    println!("1. Adam's six cases");
    for (s, p, want) in [
        ("aa", "a*", true),
        ("ab", ".*", true),
        ("aab", "c*a*b", true),
        ("mississippi", "mis*is*p*.", false),
        ("", "a*", true),
        ("", "a", false),
    ] {
        let got = is_match(s, p);
        assert_eq!(got, want);
        println!("   {:<14} {:<13} {got}", format!("{s:?}"), format!("{p:?}"));
    }

    println!();
    println!("2. The unit decides what `.` means");
    println!("   {:<8} {:<9} {:>6} {:>6}", "text", "pattern", "chars", "bytes");
    for (s, p) in [("é", "."), ("é", ".."), ("日本", ".."), ("日本", "......")] {
        let chars = is_match(s, p);
        let bytes = here(s.as_bytes(), p.as_bytes(), b'.', b'*');
        println!("   {:<8} {:<9} {chars:>6} {bytes:>6}", format!("{s:?}"), format!("{p:?}"));
    }
    println!("   Over bytes, `.` is half an é and a third of 日. Collecting a Vec<char>");
    println!("   once, up front, is what makes the pattern mean what a reader reads.");

    println!();
    println!("3. What the slice patterns bought");
    println!("   [c, '*', rest @ ..] names the star case directly. The same logic over");
    println!("   indices has to check i + 1 < p.len() before it may even look at p[i + 1].");
}

Verified output of tiny_regex_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

1. Adam's six cases
   "aa"           "a*"          true
   "ab"           ".*"          true
   "aab"          "c*a*b"       true
   "mississippi"  "mis*is*p*."  false
   ""             "a*"          true
   ""             "a"           false

2. The unit decides what `.` means
   text     pattern    chars  bytes
   "é"      "."         true  false
   "é"      ".."       false   true
   "日本"     ".."        true  false
   "日本"     "......"   false   true
   Over bytes, `.` is half an é and a third of 日. Collecting a Vec<char>
   once, up front, is what makes the pattern mean what a reader reads.

3. What the slice patterns bought
   [c, '*', rest @ ..] names the star case directly. The same logic over
   indices has to check i + 1 < p.len() before it may even look at p[i + 1].

See also

  • STRINGS.md — the map: every string lesson, in reading order
  • Replacing part of a string — the other half of the Book's sentence: what to do once you have found it
  • Walking a String — the split family, which takes the same Pattern and hands back pieces
  • Inside a Split — what the pattern actually builds: a searcher, read field by field
  • String slices — why a byte offset is the number a slice wants
  • str methods — the reference: every search method, its signature and its edges
  • Four ways to find it ↗ — the same four questions from Python, sorted by how they fail rather than what they find: find returns -1 (truthy, and a valid index), index raises, partition returns a three-tuple that never raises, and the offset counts characters"żółw".find("w") is 3 there and 6 here
  • The Book, ch. 8.2 ↗ — the paragraph that sends you to contains and replace in the first place

Po polsku

Cztery pytania, jeden argument. contains pyta „czy jest", find — „gdzie", starts_with — „czy zaczyna się tak", a matches().count() — „ile razy"; wszystkie przyjmują to samo, bo Pattern to jedna cecha (trait) w czterech postaciach: znak, podłańcuch, lista znaków (czyli „którykolwiek z nich", a nie „ta sekwencja") i domknięcie. Ta trzecia postać myli najczęściej, a czwarta jest najbardziej niedoceniana: find(char::is_numeric) szuka kategorii, a nie wartości.

Najważniejsza pułapka jest liczbowa i polski tekst wpada w nią od razu. "żółw".find('w') to 6, a nie 3, bo ż, ó i ł zajmują po dwa bajty — i to jest dokładnie ta liczba, której potrzebuje wycinek (&s[..6] to "żół"), oraz dokładnie nie ta, którą pokazuje się użytkownikowi. Zamiana kosztuje jedno wywołanie: s[..at].chars().count(). Pomyłka kończy się na dwa sposoby i tylko jeden jest głośny — indeks znakowy 3 wypada w środku litery ó i wycinek panikuje, a indeks 2 wypada na granicy i po cichu zwraca jedną literę zamiast dwóch. Każdy test napisany po angielsku pokaże, że obie liczby są zgodne, więc błąd wychodzi dopiero na prawdziwych danych.

Dla przechodzących z ABAP-a jedna uwaga warta zapamiętania: FIND ... MATCH OFFSET liczy znaki, bo ABAP trzyma tekst w UTF-16, a Rust zwraca bajty. Nawyk „weź offset, potem lv_text+off(len)" przenosi się co do kształtu, ale nie co do jednostki. I jeszcze jedno, czego w std po prostu nie ma: wyrażeń regularnych — to crate regex, świadomie, bo silnik wyrażeń regularnych jest kompilatorem.

Szukaj po polsku: wyszukiwanie w łańcuchu znaków · przesunięcie w bajtach a numer znaku · rust str find Pattern · rust strip_prefix · rust regex crate