cuke-dedup 0.1.0

Static analysis for duplicate and reusable Cucumber step definitions
Documentation
use super::suppression::find_suppression;
use crate::config::Config;
use crate::model::{
    FeatureStep, Finding, FindingEvidence, MatcherKind, Rule, Severity, StepDefinition,
};
use regex::Regex;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock;

static FALLBACK_PLACEHOLDER: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\{([^{}]+)\}").expect("static placeholder regex"));

struct CompiledMatcher {
    regex: Option<Regex>,
    authoritative: bool,
}

pub(super) fn analyze_feature_usage(
    definitions: &[StepDefinition],
    steps: &[FeatureStep],
    config: &Config,
    findings: &mut Vec<Finding>,
) -> BTreeSet<usize> {
    let compiled: Vec<_> = definitions.iter().map(compile_matcher).collect();
    // Definitions with syntax unsupported by Rust's regex engine cannot be proven unused.
    // Treat them as indeterminate instead of emitting a guaranteed false positive.
    let mut used: BTreeSet<_> = compiled
        .iter()
        .enumerate()
        .filter_map(|(index, matcher)| matcher.regex.is_none().then_some(index))
        .collect();
    let mut ambiguities: BTreeMap<_, (&FeatureStep, BTreeSet<usize>, usize)> = BTreeMap::new();
    for step in steps {
        let matches: Vec<_> = compiled
            .iter()
            .enumerate()
            .filter_map(|(index, matcher)| {
                matcher
                    .regex
                    .as_ref()
                    .filter(|matcher| matcher.is_match(&step.text))
                    .map(|_| index)
            })
            .collect();
        used.extend(matches.iter().copied());
        let authoritative_matches: BTreeSet<_> = matches
            .iter()
            .copied()
            .filter(|index| compiled[*index].authoritative)
            .collect();
        if authoritative_matches.len() > 1 {
            let ambiguity_key = (
                step.location.path.clone(),
                step.location.line,
                step.location.column,
                authoritative_matches.clone(),
            );
            let entry = ambiguities
                .entry(ambiguity_key)
                .or_insert_with(|| (step, BTreeSet::new(), 0));
            entry.1.extend(authoritative_matches);
            entry.2 += 1;
        }
    }
    let severity = config.severity(Rule::AmbiguousStep);
    if severity != Severity::Off {
        for (_, (step, matches, expansion_count)) in ambiguities {
            let matched_definitions: Vec<_> =
                matches.iter().map(|index| &definitions[*index]).collect();
            let matcher_list = matched_definitions
                .iter()
                .map(|definition| format!("`{}`", definition.matcher))
                .collect::<Vec<_>>()
                .join(", ");
            let message = if expansion_count == 1 {
                format!(
                    "Feature step `{}` matches {} definitions",
                    step.text,
                    matches.len()
                )
            } else {
                format!(
                    "Scenario outline step has {expansion_count} ambiguous expansions matching {} definitions",
                    matches.len()
                )
            };
            findings.push(Finding {
                rule: Rule::AmbiguousStep,
                severity,
                message,
                primary: step.location.clone(),
                related: matched_definitions
                    .iter()
                    .map(|definition| definition.location.clone())
                    .collect(),
                evidence: FindingEvidence {
                    matcher_similarity: None,
                    handler_similarity: None,
                    matcher_difference: matcher_list,
                    handler_evidence:
                        "More than one definition accepts at least one concrete feature step"
                            .to_owned(),
                    comparison: None,
                },
                suggested_action: "Make the matchers mutually exclusive".to_owned(),
                suppression: find_suppression(config, Rule::AmbiguousStep, &matched_definitions),
            });
        }
    }
    used
}

pub(super) fn analyze_unused(
    definitions: &[StepDefinition],
    used: &BTreeSet<usize>,
    config: &Config,
    findings: &mut Vec<Finding>,
) {
    let severity = config.severity(Rule::UnusedDefinition);
    if severity == Severity::Off {
        return;
    }
    for (index, definition) in definitions.iter().enumerate() {
        if used.contains(&index) {
            continue;
        }
        findings.push(Finding {
            rule: Rule::UnusedDefinition,
            severity,
            message: format!(
                "Step definition `{}` is not used by the feature corpus",
                definition.matcher
            ),
            primary: definition.location.clone(),
            related: Vec::new(),
            evidence: FindingEvidence {
                matcher_similarity: None,
                handler_similarity: None,
                matcher_difference: "No discovered feature step matched this definition".to_owned(),
                handler_evidence: String::new(),
                comparison: None,
            },
            suggested_action: "Remove the definition or add the missing feature usage".to_owned(),
            suppression: find_suppression(config, Rule::UnusedDefinition, &[definition]),
        });
    }
}

fn compile_matcher(definition: &StepDefinition) -> CompiledMatcher {
    match definition.matcher_kind {
        MatcherKind::RegularExpression => {
            let flags: String = definition
                .matcher_flags
                .chars()
                .filter(|flag| matches!(flag, 'i' | 'm' | 's' | 'u'))
                .collect();
            let expression = if flags.is_empty() {
                definition.matcher.clone()
            } else {
                format!("(?{flags}:{})", definition.matcher)
            };
            CompiledMatcher {
                regex: Regex::new(&expression).ok(),
                authoritative: true,
            }
        }
        MatcherKind::CucumberExpression => {
            if let Ok(regex) = cucumber_expressions::Expression::regex(&definition.matcher) {
                CompiledMatcher {
                    regex: Some(regex),
                    authoritative: true,
                }
            } else {
                // Unknown project-defined parameter types cannot be resolved without loading
                // runtime code. The permissive fallback is useful for avoiding false unused
                // reports, but cannot prove runtime ambiguity.
                CompiledMatcher {
                    regex: Regex::new(&fallback_cucumber_expression_regex(&definition.matcher))
                        .ok(),
                    authoritative: false,
                }
            }
        }
    }
}

fn fallback_cucumber_expression_regex(expression: &str) -> String {
    let mut output = String::from("^");
    let mut offset = 0;
    for capture in FALLBACK_PLACEHOLDER.captures_iter(expression) {
        let whole = capture.get(0).expect("whole match");
        output.push_str(&fallback_cucumber_literal_regex(
            &expression[offset..whole.start()],
        ));
        output.push_str(match &capture[1] {
            "int" => r"-?\d+",
            "float" => r"-?(?:\d+\.)?\d+",
            "word" => r"\S+",
            "string" => r#"(?:"[^"]*"|'[^']*')"#,
            _ => r".+",
        });
        offset = whole.end();
    }
    output.push_str(&fallback_cucumber_literal_regex(&expression[offset..]));
    output.push('$');
    output
}

fn fallback_cucumber_literal_regex(literal: &str) -> String {
    let mut output = String::new();
    let characters: Vec<_> = literal.chars().collect();
    let mut offset = 0;
    while offset < characters.len() {
        if characters[offset].is_whitespace() {
            let start = offset;
            while offset < characters.len() && characters[offset].is_whitespace() {
                offset += 1;
            }
            output.push_str(&regex::escape(
                &characters[start..offset].iter().collect::<String>(),
            ));
            continue;
        }
        let start = offset;
        while offset < characters.len() && !characters[offset].is_whitespace() {
            offset += 1;
        }
        output.push_str(&fallback_cucumber_token_regex(
            &characters[start..offset].iter().collect::<String>(),
        ));
    }
    output
}

fn fallback_cucumber_token_regex(token: &str) -> String {
    let alternatives: Vec<_> = token.split('/').collect();
    if alternatives.len() > 1 {
        return format!(
            "(?:{})",
            alternatives
                .iter()
                .map(|alternative| fallback_cucumber_token_regex(alternative))
                .collect::<Vec<_>>()
                .join("|")
        );
    }

    let mut output = String::new();
    let characters: Vec<_> = token.chars().collect();
    let mut offset = 0;
    while offset < characters.len() {
        if characters[offset] == '(' {
            if let Some(end) = characters[offset + 1..]
                .iter()
                .position(|character| *character == ')')
                .map(|index| offset + index + 1)
            {
                let optional: String = characters[offset + 1..end].iter().collect();
                output.push_str("(?:");
                output.push_str(&regex::escape(&optional));
                output.push_str(")?");
                offset = end + 1;
                continue;
            }
            output.push_str(r"\(");
            offset += 1;
            continue;
        }
        let start = offset;
        while offset < characters.len() && characters[offset] != '(' {
            offset += 1;
        }
        output.push_str(&regex::escape(
            &characters[start..offset].iter().collect::<String>(),
        ));
    }
    output
}