skim 5.1.0

Fuzzy Finder in rust!
Documentation
use crate::fuzzy_matcher::MatchIndices;
use regex::Regex;
use unicode_normalization::UnicodeNormalization;

/// Normalize a string and return a mapping from normalized char indices to original char indices.
///
/// Returns (`normalized_string`, mapping) where mapping[i] gives the original char index
/// for the i-th character in the normalized string.
pub fn normalize_with_char_mapping(s: &str) -> (String, Vec<usize>) {
    let mut normalized = String::new();
    let mut mapping = Vec::new();

    for (orig_char_idx, orig_char) in s.chars().enumerate() {
        // Decompose this character into NFD form
        for decomposed_char in orig_char.nfd() {
            if !unicode_normalization::char::is_combining_mark(decomposed_char) {
                normalized.push(decomposed_char);
                mapping.push(orig_char_idx);
            }
        }
    }

    (normalized, mapping)
}

/// Map character indices from normalized string back to original string.
///
/// Given indices into a normalized string and the char mapping from `normalize_with_char_mapping`,
/// returns the corresponding indices in the original string.
pub fn map_char_indices_to_original(normalized_indices: &[usize], char_mapping: &[usize]) -> MatchIndices {
    normalized_indices
        .iter()
        .filter_map(|&idx| char_mapping.get(idx).copied())
        .collect()
}

/// Normalize a string and return a mapping from normalized byte positions to original byte positions.
///
/// Returns (`normalized_string`, `byte_mapping`) where `byte_mapping`[i] gives the original byte position
/// for the i-th byte in the normalized string.
pub fn normalize_with_byte_mapping(s: &str) -> (String, Vec<usize>) {
    let mut normalized = String::new();
    let mut byte_mapping = Vec::new();

    for (orig_byte_pos, orig_char) in s.char_indices() {
        // Decompose this character into NFD form
        for decomposed_char in orig_char.nfd() {
            if !unicode_normalization::char::is_combining_mark(decomposed_char) {
                let char_start = normalized.len();
                normalized.push(decomposed_char);
                // Map each byte of the decomposed char to the original byte position
                for _ in char_start..normalized.len() {
                    byte_mapping.push(orig_byte_pos);
                }
            }
        }
    }

    (normalized, byte_mapping)
}

/// Map a byte range from normalized string back to original string.
///
/// Given a (start, end) byte range in a normalized string and the byte mapping,
/// returns the corresponding (start, end) byte range in the original string.
pub fn map_byte_range_to_original(
    normalized_start: usize,
    normalized_end: usize,
    byte_mapping: &[usize],
    original_str: &str,
) -> (usize, usize) {
    if byte_mapping.is_empty() || normalized_start >= byte_mapping.len() {
        return (0, 0);
    }

    let orig_start = byte_mapping[normalized_start];

    // For the end, we need to find the end of the original character
    // that contains the last byte of the normalized range
    let orig_end = if normalized_end > 0 && normalized_end <= byte_mapping.len() {
        let last_byte_orig_pos = byte_mapping[normalized_end - 1];
        // Find the end of the character at this position in the original string
        original_str[last_byte_orig_pos..]
            .chars()
            .next()
            .map_or(original_str.len(), |c| last_byte_orig_pos + c.len_utf8())
    } else if normalized_end >= byte_mapping.len() {
        original_str.len()
    } else {
        orig_start
    };

    (orig_start, orig_end)
}

pub fn regex_match(choice: &str, pattern: Option<&Regex>) -> Option<(usize, usize)> {
    let pat = pattern?;
    let mat = pat.find(choice)?;
    Some((mat.start(), mat.end()))
}

pub fn contains_upper(string: &str) -> bool {
    for ch in string.chars() {
        if ch.is_uppercase() {
            return true;
        }
    }
    false
}

#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
    use super::*;

    #[test]
    fn contains_upper_detects_case() {
        assert!(contains_upper("heLlo"));
        assert!(contains_upper("ABC"));
        assert!(!contains_upper("hello"));
        assert!(!contains_upper("123 -_"));
    }

    #[test]
    fn regex_match_finds_and_misses() {
        let re = Regex::new("ba.").unwrap();
        assert_eq!(regex_match("foobar", Some(&re)), Some((3, 6)));
        assert_eq!(regex_match("nope", Some(&re)), None);
        assert_eq!(regex_match("foobar", None), None);
    }

    #[test]
    fn byte_range_empty_mapping_returns_zero() {
        // Empty mapping or out-of-range start short-circuits to (0, 0).
        assert_eq!(map_byte_range_to_original(0, 2, &[], "abc"), (0, 0));
        assert_eq!(map_byte_range_to_original(5, 6, &[0, 1, 2], "abc"), (0, 0));
    }

    #[test]
    fn byte_range_end_past_mapping_uses_string_len() {
        // normalized_end beyond the mapping length clamps to the original length.
        assert_eq!(map_byte_range_to_original(0, 5, &[0, 1, 2], "abc"), (0, 3));
    }

    #[test]
    fn byte_range_zero_end_falls_back_to_start() {
        // normalized_end == 0 yields an empty range anchored at orig_start.
        assert_eq!(map_byte_range_to_original(1, 0, &[0, 1, 2], "abc"), (1, 1));
    }

    #[test]
    fn byte_range_maps_normal_range() {
        // A normal in-bounds range maps to the byte span of the original char.
        assert_eq!(map_byte_range_to_original(0, 2, &[0, 1, 2], "abc"), (0, 2));
    }
}