gruff-rs 0.4.0

Rust static analyzer and quality linter for CI: dead-code, complexity, security, secrets, and architecture rules with deterministic SARIF/JSON output and baseline support.
use super::*;

static TEST_ASSERTION_REGEX: OnceLock<Regex> = OnceLock::new();
static SLEEP_IN_TEST_REGEX: OnceLock<Regex> = OnceLock::new();
static CONDITIONAL_LOGIC_REGEX: OnceLock<Regex> = OnceLock::new();
static UNWRAP_IN_TEST_REGEX: OnceLock<Regex> = OnceLock::new();
static ASSERTION_MACRO_START_REGEX: OnceLock<Regex> = OnceLock::new();
static SHOULD_PANIC_ATTR_REGEX: OnceLock<Regex> = OnceLock::new();
static SHOULD_PANIC_EXPECTED_REGEX: OnceLock<Regex> = OnceLock::new();

const TEST_ASSERTION_PATTERN: &str = r"(?:\b(?:assert!|assert_eq!|assert_ne!|matches!|panic!)|\bassert_[A-Za-z0-9_]*!\s*|\bassert_[A-Za-z0-9_]*(?:\s*::\s*<[^;\n()]+>)?)\s*\(|\.\s*expect\s*\(";

const TEST_CHECKS: &[RegexRule] = &[
    RegexRule {
        rule_id: "test-quality.sleep-in-test",
        regex: &SLEEP_IN_TEST_REGEX,
        pattern: r"(std::thread::sleep|tokio::time::sleep)",
        message: "Test sleeps instead of synchronising on behaviour.",
    },
    RegexRule {
        rule_id: "test-quality.conditional-logic",
        regex: &CONDITIONAL_LOGIC_REGEX,
        pattern: r"\b(if|match)\b",
        message: "Test contains conditional logic.",
    },
    RegexRule {
        rule_id: "test-quality.unwrap-in-test",
        regex: &UNWRAP_IN_TEST_REGEX,
        pattern: r"\.unwrap\(\)",
        message: "Test uses unwrap(), which can hide setup intent.",
    },
];

pub(crate) fn analyse_test_block(
    file: &SourceFile,
    block: &FunctionBlock,
    config: &Config,
    findings: &mut Vec<Finding>,
) {
    if path_is_calibration_fixture(&file.display_path) {
        return;
    }
    analyse_ignored_test(file, block, findings);
    analyse_test_size(file, block, config, findings);
    analyse_should_panic_without_expected(file, block, findings);
    // Mask both string/char literals and comments so every per-test regex
    // check (assertions, sleep, conditional, unwrap) sees real code only - a
    // commented-out `sleep`/`if`/`.unwrap()`/`assert_eq!` must not fire.
    let searchable_body =
        strip_rust_comments_after_string_mask(&strip_rust_string_literals(&block.body));
    analyse_test_assertions(file, block, &searchable_body, findings);
    analyse_test_regex_checks(file, block, &searchable_body, findings);
}

/// Tests marked `#[should_panic]` without `expected = "..."` cannot
/// distinguish the intended panic from an unrelated panic that masks a
/// real bug. Fires only when the attribute appears without any
/// `expected` arg.
pub(crate) fn analyse_should_panic_without_expected(
    file: &SourceFile,
    block: &FunctionBlock,
    findings: &mut Vec<Finding>,
) {
    if !block.is_test {
        return;
    }
    let code = body_without_doc_comments(&block.body);
    let has_should_panic =
        static_regex(&SHOULD_PANIC_ATTR_REGEX, r"#\s*\[\s*should_panic\b").is_match(&code);
    if !has_should_panic {
        return;
    }
    let has_expected = static_regex(
        &SHOULD_PANIC_EXPECTED_REGEX,
        r"#\s*\[\s*should_panic\s*\([^)]*\bexpected\s*=",
    )
    .is_match(&code);
    if has_expected {
        return;
    }
    findings.push(block_finding_with_extras(
        BlockFindingDescriptor {
            rule_id: "test-quality.should-panic-without-expected",
            message: format!(
                "Test `{}` uses #[should_panic] without an `expected = \"...\"` clause.",
                block.name
            ),
            file,
            block,
            severity: Severity::Advisory,
            pillar: Pillar::TestQuality,
        },
        BlockFindingExtras {
            confidence: Confidence::High,
            remediation: Some(
                "Add `expected = \"message substring\"` so the test fails when an unrelated panic occurs."
                    .to_string(),
            ),
            metadata: json!({}),
        },
    ));
}

pub(crate) fn analyse_ignored_test(
    file: &SourceFile,
    block: &FunctionBlock,
    findings: &mut Vec<Finding>,
) {
    if block.ignore_without_reason {
        findings.push(block_finding(BlockFindingDescriptor {
            rule_id: "test-quality.ignored-without-reason",
            message: format!(
                "Ignored test `{}` does not explain why it is skipped.",
                block.name
            ),
            file,
            block,
            severity: Severity::Advisory,
            pillar: Pillar::TestQuality,
        }));
    }
}

pub(crate) fn analyse_test_size(
    file: &SourceFile,
    block: &FunctionBlock,
    config: &Config,
    findings: &mut Vec<Finding>,
) {
    if path_is_test_infrastructure(&file.display_path) {
        return;
    }
    let rule_id = "test-quality.long-test";
    let threshold = config.threshold(rule_id, 120.0) as usize;
    let effective_lines = long_test_effective_line_count(block);
    if effective_lines > threshold {
        findings.push(block_finding_with_metadata(
            BlockFindingDescriptor {
                rule_id,
                message: format!(
                    "Test `{}` has {effective_lines} lines from its first assertion onward, above the threshold of {threshold}.",
                    block.name
                ),
                file,
                block,
                severity: config.severity(rule_id, Severity::Advisory),
                pillar: Pillar::TestQuality,
            },
            json!({
                "lines": effective_lines,
                "totalLines": block.line_count,
                "measured": effective_lines,
                "threshold": threshold,
                "unit": "lines",
                "direction": "above"
            }),
        ));
    }
}

fn long_test_effective_line_count(block: &FunctionBlock) -> usize {
    let searchable =
        strip_rust_comments_after_string_mask(&strip_rust_string_literals(&block.body));
    let Some(first_assertion) = searchable
        .lines()
        .position(|line| test_assertion_regex().is_match(line))
    else {
        return block.line_count;
    };
    block.body.lines().count().saturating_sub(first_assertion)
}

pub(crate) fn analyse_test_assertions(
    file: &SourceFile,
    block: &FunctionBlock,
    searchable_body: &str,
    findings: &mut Vec<Finding>,
) {
    if has_trivial_assertion(searchable_body) {
        findings.push(block_finding(BlockFindingDescriptor {
            rule_id: "test-quality.trivial-assertion",
            message: format!(
                "Test `{}` asserts a value the code already fixes, not behavior. Assert a computed, parsed, or returned result instead of a literal or a binding's own initializer.",
                block.name
            ),
            file,
            block,
            severity: Severity::Warning,
            pillar: Pillar::TestQuality,
        }));
    }
}

fn test_assertion_regex() -> &'static Regex {
    static_regex(&TEST_ASSERTION_REGEX, TEST_ASSERTION_PATTERN)
}

pub(crate) fn analyse_test_regex_checks(
    file: &SourceFile,
    block: &FunctionBlock,
    searchable_body: &str,
    findings: &mut Vec<Finding>,
) {
    for rule in TEST_CHECKS {
        if !static_regex(rule.regex, rule.pattern).is_match(searchable_body) {
            continue;
        }
        if is_test_rule_exempt(rule.rule_id, searchable_body) {
            continue;
        }
        findings.push(block_finding(BlockFindingDescriptor {
            rule_id: rule.rule_id,
            message: rule.message.into(),
            file,
            block,
            severity: Severity::Advisory,
            pillar: Pillar::TestQuality,
        }));
    }
}

/// Returns true when a per-test regex rule should stay silent because
/// the matched pattern is a recognised idiom. `conditional-logic` skips
/// `cfg!(...)` platform branches; `unwrap-in-test` skips unwraps that
/// are directly inside assertion macro calls, where the unwrapped value
/// is the subject under test rather than hidden setup.
fn is_test_rule_exempt(rule_id: &str, body: &str) -> bool {
    match rule_id {
        "test-quality.conditional-logic" => conditional_is_platform_gate(body),
        "test-quality.unwrap-in-test" => body_contains_only_assertion_subject_unwraps(body),
        _ => false,
    }
}

fn conditional_is_platform_gate(body: &str) -> bool {
    static CFG_GATE_REGEX: OnceLock<Regex> = OnceLock::new();
    let cfg_gate = static_regex(&CFG_GATE_REGEX, r"\bif\s+cfg!\s*\(");
    cfg_gate.is_match(body)
}

fn body_contains_only_assertion_subject_unwraps(body: &str) -> bool {
    let unwrap_call = static_regex(&UNWRAP_IN_TEST_REGEX, r"\.unwrap\(\)");
    let unwrap_positions: Vec<usize> = unwrap_call
        .find_iter(body)
        .map(|found| found.start())
        .collect();
    if unwrap_positions.is_empty() {
        return false;
    }

    let assertion_ranges = assertion_macro_ranges(body);
    !assertion_ranges.is_empty()
        && unwrap_positions.iter().all(|unwrap_position| {
            assertion_ranges
                .iter()
                .any(|(start, end)| *start <= *unwrap_position && *unwrap_position <= *end)
                && unwrap_receiver_is_call_result(body, *unwrap_position)
        })
}

fn unwrap_receiver_is_call_result(body: &str, unwrap_position: usize) -> bool {
    body[..unwrap_position]
        .chars()
        .rev()
        .find(|character| !character.is_whitespace())
        == Some(')')
}

fn assertion_macro_ranges(body: &str) -> Vec<(usize, usize)> {
    let assertion_start = static_regex(
        &ASSERTION_MACRO_START_REGEX,
        r"\b(?:assert|assert_eq|assert_ne|matches|assert_matches|assert_[A-Za-z0-9_]*)!\s*\(",
    );
    assertion_start
        .find_iter(body)
        .filter_map(|found| {
            let open_index = body[..found.end()].rfind('(')?;
            let close_index = matching_close_paren(body, open_index)?;
            Some((found.start(), close_index))
        })
        .collect()
}

fn matching_close_paren(body: &str, open_index: usize) -> Option<usize> {
    let bytes = body.as_bytes();
    if bytes.get(open_index) != Some(&b'(') {
        return None;
    }
    let mut depth = 1usize;
    for (offset, byte) in bytes[open_index + 1..].iter().enumerate() {
        match byte {
            b'(' => depth += 1,
            b')' => {
                depth = depth.saturating_sub(1);
                if depth == 0 {
                    return Some(open_index + offset + 1);
                }
            }
            _ => {}
        }
    }
    None
}