#[must_use]
pub fn find_word_bounded(haystack_lower: &str, needle_lower: &str) -> Vec<usize> {
if needle_lower.is_empty() {
return Vec::new();
}
let mut hits = Vec::new();
let mut start = 0;
while let Some(found) = haystack_lower[start..].find(needle_lower) {
let abs = start + found;
if is_word_boundary(haystack_lower, abs, abs + needle_lower.len()) {
hits.push(abs);
}
start = abs + needle_lower.len();
if start > haystack_lower.len() {
break;
}
}
hits
}
#[must_use]
pub fn count_word_bounded(haystack_lower: &str, needle_lower: &str) -> usize {
if needle_lower.is_empty() {
return 0;
}
let mut count = 0;
let mut start = 0;
while let Some(found) = haystack_lower[start..].find(needle_lower) {
let abs = start + found;
if is_word_boundary(haystack_lower, abs, abs + needle_lower.len()) {
count += 1;
}
start = abs + needle_lower.len();
if start > haystack_lower.len() {
break;
}
}
count
}
#[must_use]
pub fn line_column_at(text: &str, byte_offset: usize) -> (u32, u32) {
let mut capped = byte_offset.min(text.len());
while capped > 0 && !text.is_char_boundary(capped) {
capped -= 1;
}
let prefix = &text[..capped];
#[allow(clippy::naive_bytecount)]
let line_offset =
u32::try_from(prefix.bytes().filter(|&b| b == b'\n').count()).unwrap_or(u32::MAX);
let current_line_start = prefix.rfind('\n').map_or(0, |pos| pos + 1);
let column =
u32::try_from(text[current_line_start..capped].chars().count() + 1).unwrap_or(u32::MAX);
(line_offset, column)
}
fn is_word_boundary(s: &str, start: usize, end: usize) -> bool {
let before_ok = start == 0 || !s[..start].chars().next_back().is_some_and(is_word_char);
let after_ok = end >= s.len() || !s[end..].chars().next().is_some_and(is_word_char);
before_ok && after_ok
}
fn is_word_char(c: char) -> bool {
c.is_alphabetic() || c == '\''
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_needle_yields_no_hits() {
assert!(find_word_bounded("hello world", "").is_empty());
assert_eq!(count_word_bounded("hello world", ""), 0);
}
#[test]
fn word_bounded_match_ignores_substrings() {
assert_eq!(find_word_bounded("category of cat", "cat"), vec![12]);
assert_eq!(count_word_bounded("category of cat", "cat"), 1);
}
#[test]
fn line_column_at_handles_multi_byte_prefix() {
let text = "é, test";
let (line, col) = line_column_at(text, 4); assert_eq!(line, 0);
assert_eq!(col, 4);
}
#[test]
fn line_column_at_snaps_to_char_boundary() {
let text = "é";
let (_, col) = line_column_at(text, 1);
assert_eq!(col, 1);
}
#[test]
fn line_column_at_counts_lines() {
let text = "first line\nsecond line\nthird";
let (line, col) = line_column_at(text, 23); assert_eq!(line, 2);
assert_eq!(col, 1);
}
}