Skip to main content

guard_core_engine/patterns/
chars_util.rs

1//! Character helpers shared by the structural matchers. The reference walks
2//! Python strings by code point; these helpers provide the same semantics
3//! over Rust byte offsets.
4
5/// Python `\w` membership (Unicode word character or underscore).
6#[must_use]
7pub fn py_is_word(c: char) -> bool {
8    c.is_alphanumeric() || c == '_'
9}
10
11/// The character starting at (or spanning) `byte_idx`, if the index is a char
12/// boundary.
13#[must_use]
14pub fn char_at(s: &str, byte_idx: usize) -> Option<(usize, char)> {
15    s[byte_idx..].chars().next().map(|c| (byte_idx, c))
16}
17
18/// The character ending just before `byte_idx`, if `byte_idx` is a boundary.
19#[must_use]
20pub fn char_before(s: &str, byte_idx: usize) -> Option<(usize, char)> {
21    s[..byte_idx].char_indices().next_back()
22}
23
24/// Walk to the start of the run of `pred` characters ending at `byte_idx`
25/// (exclusive), like the reference's backward `while` loops.
26#[must_use]
27pub fn walk_back_while<F>(s: &str, mut byte_idx: usize, mut pred: F) -> usize
28where
29    F: FnMut(char) -> bool,
30{
31    while byte_idx > 0
32        && let Some((i, c)) = char_before(s, byte_idx)
33        && pred(c)
34    {
35        byte_idx = i;
36    }
37    byte_idx
38}
39
40/// Walk forward from `byte_idx` over `pred` characters, returning the end.
41#[must_use]
42pub fn walk_forward_while<F>(s: &str, mut byte_idx: usize, mut pred: F) -> usize
43where
44    F: FnMut(char) -> bool,
45{
46    while let Some((i, c)) = char_at(s, byte_idx)
47        && pred(c)
48    {
49        byte_idx = i + c.len_utf8();
50    }
51    byte_idx
52}
53
54/// Python `str.find(sub, start, end)`.
55///
56/// `from` arrives as a byte offset that Python computes in code-point space
57/// (callers do arithmetic like `pos + 1` or `barrier + 1 - len(opening)`).
58/// A mid-char `from` snaps forward to the next boundary, which is exactly
59/// where Python's next code point starts; an ASCII-delimiter match can never
60/// begin inside a multi-byte char, so no match is skipped.
61#[must_use]
62pub fn str_find_from(s: &str, needle: &str, from: usize) -> Option<usize> {
63    if from > s.len() {
64        return None;
65    }
66    let mut from = from;
67    while from < s.len() && !s.is_char_boundary(from) {
68        from += 1;
69    }
70    s[from..].find(needle).map(|i| i + from)
71}
72
73/// Python `str.rfind(sub, start, end)`: last occurrence within `[start, end)`.
74#[must_use]
75pub fn str_rfind_in(s: &str, needle: &str, start: usize, end: usize) -> Option<usize> {
76    let limit = end.min(s.len());
77    if needle.is_empty() || start > limit {
78        return None;
79    }
80    let capped = &s[..limit];
81    capped[start..]
82        .rfind(needle)
83        .map(|i| i + start)
84        .filter(|i| i + needle.len() <= limit)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn walks_respect_char_boundaries() {
93        let s = "a\u{00e9}b";
94        assert_eq!(walk_back_while(s, s.len(), |c| c != '\u{00e9}'), 3);
95        assert_eq!(walk_forward_while(s, 0, |c| c == 'a'), 1);
96    }
97
98    #[test]
99    fn rfind_within_bounds() {
100        assert_eq!(str_rfind_in("a.b.c", ".", 0, 3), Some(1));
101        assert_eq!(str_rfind_in("a.b.c", ".", 2, 5), Some(3));
102        assert_eq!(str_rfind_in("abc", "z", 0, 3), None);
103    }
104}
105
106#[cfg(test)]
107mod coverage_tests {
108    use super::*;
109
110    #[test]
111    fn find_from_rejects_out_of_bounds_and_snaps_forward() {
112        // past the end
113        assert_eq!(str_find_from("abc", "b", 9), None);
114        // a mid-char offset snaps to the next boundary
115        let s = "a\u{00e9}b";
116        assert_eq!(str_find_from(s, "b", 2), Some(3));
117    }
118
119    #[test]
120    fn rfind_in_rejects_empty_needles_and_bad_bounds() {
121        assert_eq!(str_rfind_in("abc", "", 0, 3), None);
122        assert_eq!(str_rfind_in("abc", "b", 9, 9), None);
123    }
124}