Skip to content

← Collections, Text and First Iterators step 5 of 21

Easy Primitives

ASCII search without lowercase copies

Search strings while ignoring the case of ASCII letters. Implement two functions with different matching rules:

pub fn find_all_ci(haystack: &[String], needle: &str) -> Vec<usize>
pub fn any_contains_ci(haystack: &[String], needle: &str) -> bool

find_all_ci returns the indices of entries equal to needle, ignoring ASCII case. Preserve ascending index order and include every matching entry. any_contains_ci returns whether at least one entry contains needle as a substring under the same comparison rule.

Haystack Needle Indices Any substring match?
["Alpha", "ALPHA", "xxalphaxx"] "alpha" [0, 1] true
["xxalphaxx"] "alpha" [] true
["ab", ""] "" [1] true
[] "" [] false
["Ä"] "ä" [] false

An empty needle occurs in every string, including an empty string. An empty haystack contains no strings, so any_contains_ci returns false for every needle. Neither function should change its inputs.

Compare without making lowercase copies

Use str::eq_ignore_ascii_case for equality. It compares without allocating a lowercase String. Only ASCII letters A through Z are matched to their lowercase equivalents; all other bytes must match exactly.

The starter's to_lowercase() comparisons have two problems: they create temporary strings, and they use different semantics. For example, Unicode lowercasing makes "Ä" and "ä" equal, while this exercise requires them to remain different. Replacing those calls with to_ascii_lowercase() fixes that semantic difference but still creates unnecessary copies.

ASCII matching is useful when a format defines ASCII names or tokens. It is not a general solution for comparing human names or international text. Unicode lowercasing, Unicode case folding and locale-sensitive matching are different operations. Rust's to_lowercase() performs lowercasing, not full case folding or locale-specific comparison.

Check your understanding Why does searching ["straße", "STRASSE"] for "STRASSE" return only index 1? Show answer

ASCII matching leaves ß unchanged, so straße does not equal STRASSE. Both strings occupy seven UTF-8 bytes, but their bytes differ even after folding ASCII letters. Full Unicode case folding can map ß to ss; that is outside this exercise's contract.

Lowercasing can also change a string's length: İ lowercases to i followed by a combining dot. Offsets in a transformed copy therefore need not identify the same positions in the original text.

Scan byte windows for substring matches

str::contains is case-sensitive. For this exercise, compare each byte window of the needle's length using the byte-slice eq_ignore_ascii_case method. Handle an empty needle first because windows(0) panics.

Here is a complete helper for a single string:

fn contains_ascii_case(text: &str, needle: &str) -> bool {
    let needle = needle.as_bytes();
    needle.is_empty()
        || text.as_bytes().windows(needle.len())
            .any(|window| window.eq_ignore_ascii_case(needle))
}

fn main() {
    assert!(contains_ascii_case("aBc", "BC"));
    assert!(contains_ascii_case("", ""));
    assert!(!contains_ascii_case("Ä", "ä"));
}

Apply the comparison to haystack entries with iter().any(...). The single string's empty-needle rule then naturally gives false for an empty haystack. A needle longer than an entry has no matching window.

Byte slices can start inside a UTF-8 character without panicking. This helper never constructs a str from those slices. A valid UTF-8 needle cannot match a window starting at a continuation byte, and ASCII folding does not change non-ASCII bytes. It therefore preserves the required substring semantics.

This direct scan can compare up to the needle's length at each candidate position. Avoiding allocations does not make it a linear-time search algorithm for every input or guarantee a particular speedup.

The allocation budget

Do not allocate transformed copies of the strings. find_all_ci may allocate storage for its returned indices; any_contains_ci needs no heap allocation.

One generated fixture has 200,000 strings and exactly three equality matches. Around the two function calls, the harness counts allocator calls, including reallocations. It permits at most two for find_all_ci and zero for any_contains_ci. Building the input and serializing the result happen outside these measurements. This is a budget for that workload, not a universal bound on growing a result vector containing arbitrarily many matches.

Clippy's manual_ignore_case_cmp can suggest eq_ignore_ascii_case for comparisons built from ASCII case-conversion methods. Unicode lowercasing has different semantics, so do not rely on that lint to catch the starter's allocation problem. Functional tests and allocation checks serve different purposes here.