rrubocop 0.8.15

10x faster RuboCop drop-in. Same output format and configs, without maintaining RuboCop gems.
//! Layout/MultilineMethodCallIndentation.

mod chain;
mod dot_col;

use tree_sitter::{Node, Tree};

use crate::cop::layout::multiline_operation_indentation;
use crate::cop::layout::report;
use crate::cop::shared;
use crate::cop::{Cop, CopConfig};
use crate::correction::Correction;
use crate::diagnostic::Diagnostic;
use crate::parse::codemap::CodeMap;
use crate::parse::source::SourceFile;

pub struct MultilineMethodCallIndentation;

fn expected_col(source: &SourceFile, node: Node<'_>, recv: Node<'_>, style: &str, width: usize) -> usize {
    match style {
        "indented_relative_to_receiver" => shared::line_indent(source, recv.start_byte()) + width,
        "indented" => shared::line_indent(source, chain::chain_root(source, node).start_byte()) + width,
        "aligned" | _ => aligned_expected(source, node, width),
    }
}

fn aligned_expected(source: &SourceFile, node: Node<'_>, width: usize) -> usize {
    if let Some(col) = dot_col::dot_aligned_above(source, node) {
        return col;
    }
    if let Some(col) = dot_col::first_same_line_dot_col(source, node) {
        return col;
    }
    // RuboCop falls back to `indentation(chain) + width` when there is no
    // same-line first dot (`allow(...)\n  .to\n  .and_call_original`).
    multiline_operation_indentation::aligned_method_call_col(
        source,
        node,
        chain::chain_root(source, node),
        width,
    )
}

fn inside_paren_arg_list(node: Node<'_>) -> bool {
    let mut p = node.parent();
    while let Some(n) = p {
        if n.kind() == "argument_list" {
            return n.child(0).is_some_and(|c| !c.is_named() && c.kind() == "(");
        }
        if matches!(n.kind(), "program" | "method" | "singleton_method" | "class" | "module") {
            break;
        }
        p = n.parent();
    }
    false
}

fn check_call(
    cop: &dyn Cop,
    source: &SourceFile,
    n: Node<'_>,
    style: &str,
    width: usize,
    diagnostics: &mut Vec<Diagnostic>,
    corrections: &mut Option<&mut Vec<Correction>>,
) {
    let Some((method, expected, actual)) = call_indent_mismatch(source, n, style, width) else {
        return;
    };
    report::fix_indent(
        cop,
        source,
        method.start_byte(),
        format!(
            "Align method call receivers and their chained calls consistently (expected column {expected})."
        ),
        diagnostics,
        corrections,
        actual,
        expected,
    );
}

fn call_indent_mismatch<'a>(
    source: &SourceFile,
    n: Node<'a>,
    style: &str,
    width: usize,
) -> Option<(Node<'a>, usize, usize)> {
    let (recv, method) = leading_dot_parts(source, n)?;
    let expected = expected_col(source, n, recv, style, width);
    let actual = shared::line_indent(source, method.start_byte());
    (actual != expected).then_some((method, expected, actual))
}

fn leading_dot_parts<'a>(source: &SourceFile, n: Node<'a>) -> Option<(Node<'a>, Node<'a>)> {
    if n.kind() != "call" || !chain::has_dot(source, n) || inside_paren_arg_list(n) {
        return None;
    }
    let recv = n.child_by_field_name("receiver")?;
    let method = n.child_by_field_name("method")?;
    let (recv_end_line, _) = source.offset_to_line_col(recv.end_byte().saturating_sub(1));
    if recv_end_line == shared::node_line(source, method) {
        return None;
    }
    Some((recv, method))
}

impl Cop for MultilineMethodCallIndentation {
    fn name(&self) -> &'static str {
        "Layout/MultilineMethodCallIndentation"
    }
    fn supports_autocorrect(&self) -> bool {
        true
    }

    fn uses_source_phase(&self) -> bool {
        true
    }

    fn check_source(
        &self,
        source: &SourceFile,
        tree: &Tree,
        code_map: &CodeMap,
        config: &CopConfig,
        diagnostics: &mut Vec<Diagnostic>,
        mut corrections: Option<&mut Vec<Correction>>,
    ) {
        let _ = code_map;
        let width = config.get_usize("IndentationWidth", 2);
        let style = config.get_str("EnforcedStyle", "aligned");
        shared::for_each_descendant(tree.root_node(), |n| {
            check_call(self, source, n, style, width, diagnostics, &mut corrections);
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cop::CopConfig;
    crate::cop_fixture_tests!(MultilineMethodCallIndentation, "cops/layout/multiline_method_call_indentation");

    #[test]
    fn indented_rspec_chains() {
        let mut config = CopConfig::default();
        config.options.insert("EnforcedStyle".into(), serde_yml::Value::String("indented".into()));
        config.options.insert("IndentationWidth".into(), serde_yml::Value::Number(2.into()));
        crate::testutil::assert_cop_no_offenses_full_with_config(
            &MultilineMethodCallIndentation,
            b"expect { post(:create) }\n  .to change(A, :count).by(1)\n  .and(change(B, :count).by(1))\n",
            config,
        );
    }

    #[test]
    fn no_offense_indented_chain_fixture() {
        crate::testutil::assert_cop_no_offenses_full(
            &MultilineMethodCallIndentation,
            include_bytes!("../../../../tests/fixtures/cops/layout/multiline_method_call_indentation/no_offense_indented_chain.rb"),
        );
    }

    #[test]
    fn no_offense_dot_above_fixture() {
        crate::testutil::assert_cop_no_offenses_full(
            &MultilineMethodCallIndentation,
            include_bytes!("../../../../tests/fixtures/cops/layout/multiline_method_call_indentation/no_offense_dot_above.rb"),
        );
    }

    #[test]
    fn no_offense_comment_between_fixture() {
        crate::testutil::assert_cop_no_offenses_full(
            &MultilineMethodCallIndentation,
            include_bytes!("../../../../tests/fixtures/cops/layout/multiline_method_call_indentation/no_offense_comment_between.rb"),
        );
    }

    #[test]
    fn no_offense_multiline_args_chain_fixture() {
        crate::testutil::assert_cop_no_offenses_full(
            &MultilineMethodCallIndentation,
            include_bytes!("../../../../tests/fixtures/cops/layout/multiline_method_call_indentation/no_offense_multiline_args_chain.rb"),
        );
    }
}