rrubocop 0.8.15

10x faster RuboCop drop-in. Same output format and configs, without maintaining RuboCop gems.
//! Read-recording constructs: unary operators (`defined?` taints), calls
//! (safe-nav site collection), and bare identifiers (reads or vcalls).

use tree_sitter::Node;

use super::builder::Builder;
use super::{IntroKind, Read, ScopeId, ScopeKind};

impl Builder<'_> {
    /// Returns true when `kind` is a read-shaped construct.
    pub(super) fn walk_read(
        &mut self,
        n: Node,
        kind: &str,
        scope: ScopeId,
        under_defined: bool,
    ) -> bool {
        match kind {
            "unary" => {
                self.walk_unary(n, scope, under_defined);
                true
            }
            "call" => {
                self.walk_call(n, scope, under_defined);
                true
            }
            "identifier" => {
                self.walk_identifier(n, scope, under_defined);
                true
            }
            // RuboCop VariableForce `zsuper`: bare `super` forwards method
            // arguments (including when nested in a block).
            "super" => {
                mark_zsuper_reads(self, scope, n.start_byte(), under_defined);
                true
            }
            // Ruby 3 shorthand hash (`foo(user:)`): a pair with no value
            // is a variable reference wearing a label.
            "pair" if n.child_by_field_name("value").is_none() => {
                self.walk_shorthand_pair(n, scope, under_defined);
                true
            }
            _ => false,
        }
    }

    /// Record the shorthand key as a read of the identically named
    /// local (or a vcall when no such local exists -- Ruby would call
    /// the method).
    fn walk_shorthand_pair(&mut self, n: Node, scope: ScopeId, under_defined: bool) {
        let Some(key) = n.child_by_field_name("key") else {
            return;
        };
        if key.kind() != "hash_key_symbol" {
            return;
        }
        let name = key.utf8_text(self.src).unwrap_or("").to_string();
        // Two read positions across the key: UsedOnce demands exactly one
        // read, and a shorthand read can never be inlined away (`42:` is
        // not valid Ruby), so it must never qualify as the single use.
        let bytes = [key.start_byte(), key.end_byte()];
        if self.lookup(scope, bytes[0], &name).is_none() {
            self.vcall_sites.push(bytes[0]);
            return;
        }
        if name.starts_with('_') {
            return;
        }
        for byte in bytes {
            self.record_read(
                scope,
                &name,
                Read {
                    byte,
                    under_defined,
                },
            );
        }
    }

    fn walk_unary(&mut self, n: Node, scope: ScopeId, under_defined: bool) {
        let op_node = n.child_by_field_name("operator");
        let ud = under_defined || op_node.is_some_and(|o| self.text(o) == "defined?");
        let mut cursor = n.walk();
        for child in n.children(&mut cursor) {
            if op_node.map(|o| o.id()) == Some(child.id()) {
                continue;
            }
            self.walk(child, scope, ud);
        }
    }

    fn walk_call(&mut self, n: Node, scope: ScopeId, under_defined: bool) {
        // never treat the @method slot as a variable read
        let method_slot = n.child_by_field_name("method");
        self.note_csend_site(n, scope);
        // RuboCop VariableForce: `binding` / `binding()` uses every local in scope.
        if is_binding_call(n, method_slot, self.src) {
            mark_all_reads(self, scope, n.start_byte(), under_defined);
        }
        let mut cursor = n.walk();
        for child in n.children(&mut cursor) {
            if method_slot.map(|m| m.id()) == Some(child.id()) {
                continue;
            }
            self.walk(child, scope, under_defined);
        }
    }

    /// Safe-navigation on a local receiver: recorded for the ABC
    /// repeated-csend discount.
    fn note_csend_site(&mut self, n: Node, scope: ScopeId) {
        if n
            .child_by_field_name("operator")
            .is_some_and(|o| self.text(o) == "&.")
            && let Some(recv) = n.child_by_field_name("receiver")
            && recv.kind() == "identifier"
        {
            let name = self.text(recv);
            if self.lookup(scope, recv.start_byte(), name).is_some() {
                self.csend_sites
                    .push((recv.start_byte(), name.into(), scope));
            }
        }
    }

    fn walk_identifier(&mut self, n: Node, scope: ScopeId, under_defined: bool) {
        let name = self.text(n).to_string();
        // Parser gem uses dedicated `__FILE__`/`__LINE__`/`__ENCODING__` nodes
        // (not sends). tree-sitter-ruby often emits them as `identifier`; they
        // must not become ABC vcall branches.
        if is_magic_file_ident(&name) {
            return;
        }
        let r = Read {
            byte: n.start_byte(),
            under_defined,
        };
        if self.lookup(scope, r.byte, &name).is_some() {
            record_named_read(self, scope, &name, r);
        } else {
            record_unresolved_vcall(self, scope, &name, n.start_byte(), under_defined);
        }
    }
}

fn is_magic_file_ident(name: &str) -> bool {
    matches!(name, "__FILE__" | "__LINE__" | "__ENCODING__")
}

fn record_named_read(b: &mut Builder<'_>, scope: ScopeId, name: &str, r: Read) {
    if !name.starts_with('_') {
        b.record_read(scope, name, r);
    }
}

fn record_unresolved_vcall(
    b: &mut Builder<'_>,
    scope: ScopeId,
    name: &str,
    byte: usize,
    under_defined: bool,
) {
    // unresolved bare identifier == zero-arity method call
    if name == "binding" {
        mark_all_reads(b, scope, byte, under_defined);
    }
    b.vcall_sites.push(byte);
}

fn is_binding_call(n: Node<'_>, method_slot: Option<Node<'_>>, src: &[u8]) -> bool {
    let Some(m) = method_slot else {
        return false;
    };
    if n.child_by_field_name("receiver").is_some() {
        return false;
    }
    m.utf8_text(src).unwrap_or("") == "binding"
}

fn mark_reads(
    b: &mut Builder<'_>,
    scope: ScopeId,
    byte: usize,
    under_defined: bool,
    keep: impl Fn(&str, &super::Entry) -> bool,
) {
    let names: Vec<Box<str>> = b.scopes[scope]
        .entries
        .iter()
        .filter(|(n, e)| keep(n, e))
        .map(|(n, _)| n.clone())
        .collect();
    for name in names {
        b.record_read(
            scope,
            &name,
            Read {
                byte,
                under_defined,
            },
        );
    }
}

fn mark_all_reads(b: &mut Builder<'_>, scope: ScopeId, byte: usize, under_defined: bool) {
    mark_reads(b, scope, byte, under_defined, |n, _| !n.starts_with('_'));
}

/// RuboCop `process_zero_arity_super`: only method arguments, via enclosing method.
fn mark_zsuper_reads(b: &mut Builder<'_>, scope: ScopeId, byte: usize, under_defined: bool) {
    let Some(method) = method_scope(b, scope) else {
        return;
    };
    mark_reads(b, method, byte, under_defined, |n, e| {
        e.intro_kind == IntroKind::Param && !n.starts_with('_')
    });
}

fn method_scope(b: &Builder<'_>, mut scope: ScopeId) -> Option<ScopeId> {
    loop {
        if b.scopes[scope].kind == ScopeKind::Method {
            return Some(scope);
        }
        scope = b.scopes[scope].parent?;
    }
}