ripr 0.8.0

Find static mutation-exposure gaps before expensive mutation testing
Documentation
use crate::analysis::facts::CallFact;

pub(crate) fn extract_call_facts(body: &str, start_line: usize) -> Vec<CallFact> {
    let mut calls = Vec::new();
    for (offset, line) in body.lines().enumerate() {
        let scan_line = call_scan_line(line);
        let bytes = scan_line.as_bytes();
        let mut i = 0usize;
        while i < bytes.len() {
            if bytes[i] == b'('
                && let Some((start, end)) = call_name_bounds_before_paren(&scan_line, i)
            {
                let name = &scan_line[start..end];
                if is_call_name(name) {
                    calls.push(CallFact {
                        line: start_line + offset,
                        name: name.to_string(),
                        text: line.trim().to_string(),
                    });
                }
            }
            i += 1;
        }
    }
    calls.sort_by(|a, b| a.line.cmp(&b.line).then(a.name.cmp(&b.name)));
    calls.dedup_by(|a, b| a.line == b.line && a.name == b.name && a.text == b.text);
    calls
}

fn call_name_bounds_before_paren(line: &str, paren_index: usize) -> Option<(usize, usize)> {
    let bytes = line.as_bytes();
    let mut end = paren_index;
    while end > 0 && bytes[end - 1].is_ascii_whitespace() {
        end -= 1;
    }
    if end == 0 {
        return None;
    }
    if bytes[end - 1] == b'>' {
        end = turbofish_start(line, end)?;
    }
    let mut start = end;
    while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') {
        start -= 1;
    }
    (start < end).then_some((start, end))
}

fn turbofish_start(line: &str, end: usize) -> Option<usize> {
    let bytes = line.as_bytes();
    let mut depth = 0usize;
    let mut i = end;
    while i > 0 {
        i -= 1;
        match bytes[i] {
            b'>' => depth += 1,
            b'<' => {
                depth = depth.checked_sub(1)?;
                if depth == 0 {
                    return (i >= 2 && bytes[i - 1] == b':' && bytes[i - 2] == b':')
                        .then_some(i - 2);
                }
            }
            _ => {}
        }
    }
    None
}

fn call_scan_line(line: &str) -> String {
    let mut out = String::with_capacity(line.len());
    let mut chars = line.chars().peekable();
    let mut in_string = false;
    let mut escaped = false;
    while let Some(ch) = chars.next() {
        if in_string {
            if escaped {
                escaped = false;
            } else if ch == '\\' {
                escaped = true;
            } else if ch == '"' {
                in_string = false;
            }
            out.push(' ');
            continue;
        }
        if ch == '/' && chars.peek().is_some_and(|next| *next == '/') {
            break;
        }
        if ch == '"' {
            in_string = true;
            out.push(' ');
        } else {
            out.push(ch);
        }
    }
    out
}

fn is_call_name(name: &str) -> bool {
    !matches!(
        name,
        "if" | "while"
            | "match"
            | "for"
            | "loop"
            | "assert"
            | "assert_eq"
            | "assert_ne"
            | "assert_matches"
    )
}

#[cfg(test)]
fn call_names(calls: &[CallFact]) -> Vec<&str> {
    calls.iter().map(|call| call.name.as_str()).collect()
}

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

    #[test]
    fn given_control_flow_and_assertion_like_calls_when_extracting_then_skips_non_function_names() {
        let calls = extract_call_facts(
            r#"if(condition) {}
while(condition) {}
assert_eq(actual, expected);
real_call(1);
"#,
            10,
        );

        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].line, 13);
        assert_eq!(calls[0].name, "real_call");
        assert_eq!(calls[0].text, "real_call(1);");
    }

    #[test]
    fn call_extraction_ignores_comment_and_string_mentions() {
        let calls = extract_call_facts(
            r#"// fake_call()
let note = "device_labels(";
real_call(1);
"#,
            20,
        );

        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].line, 22);
        assert_eq!(calls[0].name, "real_call");
        assert_eq!(calls[0].text, "real_call(1);");
    }

    #[test]
    fn call_extraction_recognizes_turbofish_function_calls() {
        let calls = extract_call_facts(
            r#"
let rendered = render_pipeline::<String>("alpha");
let value = TypeName::new::<usize>();
let parsed = crate::parser::parse::<u64>("42");
let spaced = render_pipeline ("beta");
"#,
            30,
        );

        assert_eq!(
            call_names(&calls),
            vec!["render_pipeline", "new", "parse", "render_pipeline"]
        );
        assert_eq!(calls[0].line, 31);
        assert_eq!(
            calls[0].text,
            "let rendered = render_pipeline::<String>(\"alpha\");"
        );
        assert_eq!(calls[3].line, 34);
        assert_eq!(calls[3].text, "let spaced = render_pipeline (\"beta\");");
    }
}