weavatrix-scan 0.4.6

Deterministic, safe repository scanner for code intelligence
Documentation
use crate::glob;

#[derive(Debug, Clone)]
pub(super) enum RuleMatcher {
    Literal(bool),
    Prefix(String, bool),
    Suffix(String, bool),
    Glob {
        needle: Option<String>,
        prefix: Option<String>,
        suffix: Option<String>,
        case_insensitive: bool,
    },
}

impl RuleMatcher {
    pub(super) fn new(pattern: &str, case_insensitive: bool) -> Self {
        if pattern.contains('{') || pattern.contains("**") {
            Self::glob(pattern, case_insensitive)
        } else if !has_meta(pattern) {
            Self::Literal(case_insensitive)
        } else if let Some(suffix) = pattern.strip_prefix('*').filter(|value| !has_meta(value)) {
            Self::Suffix(suffix.to_owned(), case_insensitive)
        } else if let Some(prefix) = pattern.strip_suffix('*').filter(|value| !has_meta(value)) {
            Self::Prefix(prefix.to_owned(), case_insensitive)
        } else {
            Self::glob(pattern, case_insensitive)
        }
    }

    fn glob(pattern: &str, case_insensitive: bool) -> Self {
        Self::Glob {
            needle: (!pattern.contains('{') && !pattern.contains("**"))
                .then(|| longest_literal(pattern))
                .flatten(),
            prefix: edge_literal(pattern, true),
            suffix: edge_literal(pattern, false),
            case_insensitive,
        }
    }

    pub(super) const fn is_literal(&self) -> bool {
        matches!(self, Self::Literal(false))
    }

    pub(super) fn prefix_key(&self, pattern: &str) -> Option<u8> {
        match self {
            Self::Prefix(prefix, false) => prefix.bytes().next(),
            Self::Glob {
                prefix,
                case_insensitive: false,
                ..
            } => prefix.as_ref().and_then(|prefix| prefix.bytes().next()),
            Self::Literal(false) => pattern.bytes().next(),
            _ => None,
        }
    }

    pub(super) fn suffix_key(&self, pattern: &str) -> Option<u8> {
        match self {
            Self::Suffix(suffix, false) => suffix.bytes().next_back(),
            Self::Literal(false) => pattern.bytes().next_back(),
            _ => None,
        }
    }

    pub(super) fn matches(&self, pattern: &str, value: &str) -> bool {
        match self {
            Self::Literal(false) => pattern == value,
            Self::Literal(true) => pattern.eq_ignore_ascii_case(value),
            Self::Prefix(prefix, false) => value.starts_with(prefix),
            Self::Suffix(suffix, false) => value.ends_with(suffix),
            Self::Glob {
                needle,
                prefix,
                suffix,
                case_insensitive: false,
            } => {
                prefix
                    .as_ref()
                    .is_none_or(|prefix| value.starts_with(prefix))
                    && suffix.as_ref().is_none_or(|suffix| value.ends_with(suffix))
                    && needle.as_ref().is_none_or(|needle| value.contains(needle))
                    && glob::matches(pattern, value)
            }
            Self::Prefix(prefix, true) => value
                .get(..prefix.len())
                .is_some_and(|value| value.eq_ignore_ascii_case(prefix)),
            Self::Suffix(suffix, true) => value
                .get(value.len().saturating_sub(suffix.len())..)
                .is_some_and(|value| value.eq_ignore_ascii_case(suffix)),
            Self::Glob {
                needle,
                prefix,
                suffix,
                case_insensitive: true,
            } => {
                let value = value.to_ascii_lowercase();
                prefix
                    .as_ref()
                    .is_none_or(|prefix| value.starts_with(prefix))
                    && suffix.as_ref().is_none_or(|suffix| value.ends_with(suffix))
                    && needle.as_ref().is_none_or(|needle| value.contains(needle))
                    && glob::matches(pattern, &value)
            }
        }
    }
}

fn has_meta(value: &str) -> bool {
    value
        .bytes()
        .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b'{' | b'\\'))
}

fn longest_literal(pattern: &str) -> Option<String> {
    let mut longest = Vec::new();
    let mut current = Vec::new();
    let mut bytes = pattern.bytes();
    while let Some(byte) = bytes.next() {
        match byte {
            b'\\' => {
                if let Some(escaped) = bytes.next() {
                    current.push(escaped);
                }
            }
            b'[' => {
                retain_longest(&mut current, &mut longest);
                for member in bytes.by_ref() {
                    if member == b']' {
                        break;
                    }
                }
            }
            b'*' | b'?' | b'{' | b'}' | b',' => {
                retain_longest(&mut current, &mut longest);
            }
            literal => current.push(literal),
        }
    }
    retain_longest(&mut current, &mut longest);
    (longest.len() >= 2)
        .then(|| String::from_utf8(longest).ok())
        .flatten()
}

fn edge_literal(pattern: &str, prefix: bool) -> Option<String> {
    let mut edge = Vec::new();
    let mut current = Vec::new();
    let mut bytes = pattern.bytes();
    while let Some(byte) = bytes.next() {
        match byte {
            b'\\' => {
                if let Some(escaped) = bytes.next() {
                    current.push(escaped);
                }
            }
            b'[' => {
                if prefix {
                    break;
                }
                current.clear();
                for member in bytes.by_ref() {
                    if member == b']' {
                        break;
                    }
                }
            }
            b'*' | b'?' | b'{' | b'}' | b',' => {
                if prefix {
                    break;
                }
                current.clear();
            }
            literal => current.push(literal),
        }
    }
    if prefix {
        edge = current;
    } else {
        std::mem::swap(&mut edge, &mut current);
        if pattern.contains("**/") && edge.first() == Some(&b'/') {
            edge.remove(0);
        }
    }
    (!edge.is_empty())
        .then(|| String::from_utf8(edge).ok())
        .flatten()
}

fn retain_longest(current: &mut Vec<u8>, longest: &mut Vec<u8>) {
    if current.len() > longest.len() {
        std::mem::swap(current, longest);
    }
    current.clear();
}

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

    #[test]
    fn specializes_literal_prefix_suffix_and_complex_patterns() {
        assert!(RuleMatcher::new("Cargo.toml", false).is_literal());
        assert!(RuleMatcher::new("target*", false).matches("target*", "target-debug"));
        assert!(RuleMatcher::new("*.rs", false).matches("*.rs", "lib.rs"));
        let complex = RuleMatcher::new("report.[0-9]*.json", false);
        assert!(complex.matches("report.[0-9]*.json", "report.1.json"));
        assert!(!complex.matches("report.[0-9]*.json", "notes.json"));
    }
}