holys3 0.1.0

Indexed regex search for local files and private S3 buckets
//! Pattern composition: rg-style -e/-F/-w/-i/-S transforms applied BEFORE
//! the gram planner and the verifier, so both see the identical final regex.

/// `-F` escapes, `-w` wraps in word boundaries, multiple patterns OR, `-i`
/// prepends `(?i)`. The result may be an invalid regex — `Regex::new`/`plan`
/// report that with the real error.
pub(crate) fn compose_pattern(
    patterns: &[String],
    fixed_strings: bool,
    word_regexp: bool,
    insensitive: bool,
) -> String {
    let transformed: Vec<String> = patterns
        .iter()
        .map(|p| {
            let p = if fixed_strings {
                regex::escape(p)
            } else {
                p.clone()
            };
            if word_regexp {
                // rg uses HALF word boundaries: full \b mis-anchors when the
                // pattern's first/last char is a non-word char (`foo(`, `->`)
                format!(r"\b{{start-half}}(?:{p})\b{{end-half}}")
            } else {
                p
            }
        })
        .collect();
    let joined = if transformed.len() == 1 {
        transformed.into_iter().next().expect("one pattern")
    } else {
        transformed
            .iter()
            .map(|p| format!("(?:{p})"))
            .collect::<Vec<_>>()
            .join("|")
    };
    // (?m): rg matches line-by-line, so ^ and $ anchor at EVERY line
    // boundary, not just the start and end of the object
    if insensitive {
        format!("(?mi){joined}")
    } else {
        format!("(?m){joined}")
    }
}

/// rg's line-terminator discipline, applied to the COMPOSED pattern: strip
/// \n out of every character class that matches it (this is what makes
/// `[^x]` and `(?s).` line-oriented — a class spanning \n would swallow
/// following lines into one phantom match), then reject any literal \n
/// left (a match can never span the line-oriented unit of output).
pub(crate) fn sanitize_line_terminators(pattern: &str) -> anyhow::Result<String> {
    use regex_syntax::hir::{Class, Hir, HirKind, Literal};
    fn strip(hir: &Hir) -> anyhow::Result<Hir> {
        Ok(match hir.kind() {
            HirKind::Literal(Literal(bytes)) => {
                anyhow::ensure!(
                    !bytes.contains(&b'\n'),
                    "the literal '\\n' is not allowed in a regex"
                );
                hir.clone()
            }
            HirKind::Class(Class::Bytes(class)) => {
                let mut class = class.clone();
                let mut newline =
                    regex_syntax::hir::ClassBytes::new([regex_syntax::hir::ClassBytesRange::new(
                        b'\n', b'\n',
                    )]);
                newline.negate();
                class.intersect(&newline);
                Hir::class(Class::Bytes(class))
            }
            HirKind::Class(Class::Unicode(class)) => {
                let mut class = class.clone();
                let mut newline = regex_syntax::hir::ClassUnicode::new([
                    regex_syntax::hir::ClassUnicodeRange::new('\n', '\n'),
                ]);
                newline.negate();
                class.intersect(&newline);
                Hir::class(Class::Unicode(class))
            }
            HirKind::Repetition(rep) => {
                let mut rep = rep.clone();
                rep.sub = Box::new(strip(&rep.sub)?);
                Hir::repetition(rep)
            }
            HirKind::Capture(cap) => {
                let mut cap = cap.clone();
                cap.sub = Box::new(strip(&cap.sub)?);
                Hir::capture(cap)
            }
            HirKind::Concat(subs) => {
                Hir::concat(subs.iter().map(strip).collect::<anyhow::Result<_>>()?)
            }
            HirKind::Alternation(subs) => {
                Hir::alternation(subs.iter().map(strip).collect::<anyhow::Result<_>>()?)
            }
            HirKind::Empty | HirKind::Look(_) => hir.clone(),
        })
    }
    let hir = regex_syntax::ParserBuilder::new()
        .utf8(false)
        .build()
        .parse(pattern)?;
    Ok(strip(&hir)?.to_string())
}

/// Resolve the -i/-S/-s trio (clap guarantees at most one is set).
pub(crate) fn is_insensitive(ignore_case: bool, smart_case: bool, patterns: &[String]) -> bool {
    if ignore_case {
        return true;
    }
    if smart_case {
        return smart_case_insensitive(patterns);
    }
    false
}

/// rg smart case: insensitive iff the patterns contain at least one literal
/// character and none of the literal characters are uppercase. Classes like
/// `\pL` are not literals.
fn smart_case_insensitive(patterns: &[String]) -> bool {
    let mut literals = Vec::new();
    for pattern in patterns {
        let Ok(hir) = regex_syntax::parse(pattern) else {
            return false; // compile will surface the real error
        };
        collect_literal_chars(&hir, &mut literals);
    }
    !literals.is_empty() && !literals.iter().any(|c| c.is_uppercase())
}

fn collect_literal_chars(hir: &regex_syntax::hir::Hir, out: &mut Vec<char>) {
    use regex_syntax::hir::HirKind;
    match hir.kind() {
        HirKind::Literal(lit) => out.extend(String::from_utf8_lossy(&lit.0).chars()),
        HirKind::Concat(children) | HirKind::Alternation(children) => {
            for child in children {
                collect_literal_chars(child, out);
            }
        }
        HirKind::Capture(capture) => collect_literal_chars(&capture.sub, out),
        HirKind::Repetition(rep) => collect_literal_chars(&rep.sub, out),
        HirKind::Empty | HirKind::Class(_) | HirKind::Look(_) => {}
    }
}

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

    fn one(p: &str) -> Vec<String> {
        vec![p.to_owned()]
    }

    #[test]
    fn compose_matrix() {
        assert_eq!(
            compose_pattern(&one("a.b"), true, false, false),
            r"(?m)a\.b"
        );
        assert_eq!(
            compose_pattern(&one("foo"), false, true, false),
            r"(?m)\b{start-half}(?:foo)\b{end-half}"
        );
        assert_eq!(
            compose_pattern(&["a".into(), "b".into()], false, false, false),
            "(?m)(?:a)|(?:b)"
        );
        assert_eq!(compose_pattern(&one("foo"), false, false, true), "(?mi)foo");
        assert_eq!(
            compose_pattern(&["a.".into(), "b".into()], true, true, true),
            r"(?mi)(?:\b{start-half}(?:a\.)\b{end-half})|(?:\b{start-half}(?:b)\b{end-half})"
        );
    }

    #[test]
    fn sanitize_strips_newline_from_classes_and_rejects_literals() {
        let re =
            |p: &str| regex::bytes::Regex::new(&sanitize_line_terminators(p).unwrap()).unwrap();
        // [^x] must not swallow the newline
        assert!(!re("[^x]").is_match(b"\n"));
        // (?s). is a class containing \n: stripped back to line-oriented
        assert!(!re("(?s).").is_match(b"\n"));
        assert!(re("(?s)a.b").is_match(b"axb"));
        assert!(!re("(?s)a.b").is_match(b"a\nb"));
        // \D and \W contain \n
        assert!(!re(r"\D").is_match(b"\n"));
        // literal newlines are rejected, in every spelling
        assert!(sanitize_line_terminators("a\nb").is_err());
        assert!(sanitize_line_terminators(r"a\nb").is_err());
        assert!(sanitize_line_terminators(r"a\x0Ab").is_err());
        // plain patterns round-trip
        assert!(re("(?m)^needle$").is_match(b"x\nneedle\ny"));
    }

    #[test]
    fn smart_case_rules() {
        assert!(is_insensitive(false, true, &one("foo")));
        assert!(!is_insensitive(false, true, &one("Foo")));
        assert!(is_insensitive(false, true, &one(r"foo\pL")));
        assert!(!is_insensitive(false, true, &one(r"Foo\pL")));
        assert!(!is_insensitive(false, true, &one(r"\d+"))); // no literals
        assert!(!is_insensitive(false, true, &one("(")));
        assert!(is_insensitive(true, false, &one("FOO")));
        assert!(!is_insensitive(false, false, &one("foo")));
    }
}