rrubocop 0.8.15

10x faster RuboCop drop-in. Same output format and configs, without maintaining RuboCop gems.
//! RSpec/MultipleMemoizedHelpers — too many unique `let`/`subject` names.

use std::collections::HashSet;

use tree_sitter::Node;

use crate::cop::rspec::helpers::{
    bare_rspec_call, block_body, call_block, first_sym_arg, is_example, is_group, RSPEC_INCLUDE,
};
use crate::cop::shared::{call_method_name, call_receiver, method_node, node_bytes};
use crate::cop::{Cop, CopConfig};
use crate::diagnostic::Diagnostic;
use crate::parse::source::SourceFile;

pub struct MultipleMemoizedHelpers;

fn is_let(name: &[u8]) -> bool {
    name == b"let" || name == b"let!"
}

fn is_subject(name: &[u8]) -> bool {
    name == b"subject" || name == b"subject!"
}

fn is_include(name: &[u8]) -> bool {
    matches!(
        name,
        b"it_behaves_like" | b"it_should_behave_like" | b"include_examples" | b"include_context"
    )
}

fn is_rspec_bare(source: &SourceFile, node: Node<'_>) -> bool {
    match call_receiver(node) {
        None => true,
        Some(r) => r.kind() == "constant" && node_bytes(source, r) == b"RSpec",
    }
}

fn helper_name(source: &SourceFile, call: Node<'_>, method: &[u8]) -> Option<Vec<u8>> {
    if is_subject(method) {
        return Some(first_sym_arg(source, call).unwrap_or(b"subject").to_vec());
    }
    if is_let(method) {
        return first_sym_arg(source, call).map(|n| n.to_vec());
    }
    None
}

fn should_count(method: &[u8], allow_subject: bool) -> bool {
    is_let(method) || (!allow_subject && is_subject(method))
}

fn scope_boundary(method: &[u8]) -> bool {
    is_group(method) || is_include(method) || is_example(method)
}

fn record_helper(source: &SourceFile, node: Node<'_>, method: &[u8], out: &mut HashSet<Vec<u8>>) {
    match helper_name(source, node, method) {
        Some(name) => {
            out.insert(name);
        }
        None if is_let(method) => {
            out.insert(b"__unknown_variable__".to_vec());
        }
        None => {}
    }
}

fn walk_names(source: &SourceFile, node: Node<'_>, allow_subject: bool, out: &mut HashSet<Vec<u8>>) {
    if matches!(node.kind(), "call" | "command") {
        let method = call_method_name(source, node);
        if is_rspec_bare(source, node) && call_block(node).is_some() {
            if let Some(m) = method {
                if scope_boundary(m) {
                    return;
                }
                if should_count(m, allow_subject) {
                    record_helper(source, node, m, out);
                }
            }
        }
    }
    let mut cur = node.walk();
    for child in node.children(&mut cur) {
        walk_names(source, child, allow_subject, out);
    }
}

fn ancestor_names(
    source: &SourceFile,
    mut node: Node<'_>,
    allow_subject: bool,
    out: &mut HashSet<Vec<u8>>,
) {
    while let Some(parent) = node.parent() {
        node = parent;
        if !matches!(node.kind(), "call" | "command") {
            continue;
        }
        let Some(method) = bare_rspec_call(source, node) else {
            continue;
        };
        if !is_group(method) {
            continue;
        }
        if let Some(body) = call_block(node).and_then(block_body) {
            walk_names(source, body, allow_subject, out);
        }
    }
}

fn report_too_many(
    cop: &MultipleMemoizedHelpers,
    source: &SourceFile,
    node: Node<'_>,
    total: usize,
    max: usize,
    diagnostics: &mut Vec<Diagnostic>,
) {
    let (line, col) = source.offset_to_line_col(method_node(node).unwrap_or(node).start_byte());
    diagnostics.push(cop.diagnostic(
        source,
        line,
        col,
        format!("Example group has too many memoized helpers [{total}/{max}]"),
    ));
}

fn group_helper_total(
    source: &SourceFile,
    node: Node<'_>,
    body: Node<'_>,
    allow_subject: bool,
) -> usize {
    let mut names = HashSet::new();
    walk_names(source, body, allow_subject, &mut names);
    ancestor_names(source, node, allow_subject, &mut names);
    names.len()
}

impl Cop for MultipleMemoizedHelpers {
    fn name(&self) -> &'static str {
        "RSpec/MultipleMemoizedHelpers"
    }

    fn default_include(&self) -> &'static [&'static str] {
        RSPEC_INCLUDE
    }

    fn interested_node_kinds(&self) -> &'static [&'static str] {
        &["call", "command"]
    }

    fn check_node(
        &self,
        source: &SourceFile,
        node: Node<'_>,
        config: &CopConfig,
        diagnostics: &mut Vec<Diagnostic>,
        _corrections: Option<&mut Vec<crate::correction::Correction>>,
    ) {
        let Some(method) = bare_rspec_call(source, node) else {
            return;
        };
        if !is_group(method) {
            return;
        }
        let Some(body) = call_block(node).and_then(block_body) else {
            return;
        };
        let max = config.get_usize("Max", 5);
        let total = group_helper_total(source, node, body, config.get_bool("AllowSubject", true));
        if total > max {
            report_too_many(self, source, node, total, max, diagnostics);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    crate::cop_fixture_tests!(MultipleMemoizedHelpers, "cops/rspec/multiple_memoized_helpers");
}