Skip to main content

big_code_analysis_ast/checker/
ruby.rs

1//! `Checker` implementation for Ruby.
2#![allow(clippy::wildcard_imports, clippy::enum_glob_use)]
3
4use super::*;
5use crate::lang_helpers::doc_comment::{node_bytes, ruby_directive_opener, ruby_magic_comment};
6
7/// Whether `node` — a `Block` / `DoBlock` — is the body of a stabby
8/// lambda (`->(z) { … }` / `->(z) do … end`), which parses as a `Lambda`
9/// node that CONTAINS the block. The `Lambda` wrapper already counts as
10/// the closure (#465) and opens the function space (#1257), so
11/// classifying the body again would double-count one lambda as two
12/// closures, or open a phantom nested Function space. The keyword forms
13/// `lambda { }` / `proc { }` and iterator blocks (`[1].each { |x| x }`)
14/// parse as a `Call` carrying the block argument — the parent is not a
15/// `Lambda` — so they still classify exactly once.
16///
17/// One helper shared by [`Checker::is_closure`],
18/// [`Checker::is_func_space_with_code`], and the cognitive lambda-nesting
19/// arm (`crate::metrics::cognitive`'s Ruby impl), so the closure count,
20/// the space tree, and the nesting surcharge cannot drift apart
21/// (`.claude/rules/grammar-dispatch.md` §6).
22///
23/// The parent comes off the caller's chain: all consumers run per node
24/// from a walk, and `Node::parent` costs `O(depth)` because
25/// `tree_sitter` resolves it by descending from the root (#1088).
26#[must_use]
27pub fn is_stabby_lambda_body<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>) -> bool {
28    ancestors
29        .parent(node)
30        .is_some_and(|parent| parent.kind_id() == Ruby::Lambda)
31}
32
33/// The statement a comment run directly above `node` documents: `node`
34/// itself, or the statement a wrapper stands for. A statement modifier
35/// (`X = 1 unless defined?(X)`, `def m; end if c`) documents its
36/// statement, as YARD reads it. The grammar parks a doc comment that
37/// precedes the first statement of a `class` / `module` body or of a
38/// brace block outside the `body_statement` / `block_body` wrapper, so
39/// the wrapper stands in for its first child; `BodyStatement2` is the
40/// hidden `_body_statement`, kept as a defensive arm, and `dloc.rs` pins
41/// that the grammar never emits it. A loop rather than recursion: a chain
42/// of modifiers as long as the file must not exhaust the walker's stack
43/// (#1597, as for Lua's dotted targets). `None` for an empty wrapper.
44fn ruby_documented_core<'a>(node: &Node<'a>) -> Option<Node<'a>> {
45    let mut node = *node;
46    loop {
47        node = match node.kind_id().into() {
48            Ruby::IfModifier | Ruby::UnlessModifier => node.child_by_field_name("body")?,
49            Ruby::BodyStatement | Ruby::BodyStatement2 | Ruby::BlockBody => node.child(0)?,
50            _ => return Some(node),
51        };
52    }
53}
54
55/// Whether `node`, unwrapped by [`ruby_documented_core`], is a statement
56/// YARD documents from the comment run directly above it: a `def`,
57/// `class` or `module`, a `class << Const`, a constant or class-variable
58/// assignment, a method alias (`alias` or `alias_method`), an `attr` /
59/// `attr_*` call or a visibility-wrapped `def`.
60fn ruby_documented_statement(node: &Node, code: &[u8]) -> bool {
61    match node.kind_id().into() {
62        Ruby::Method | Ruby::SingletonMethod | Ruby::Class | Ruby::Module => true,
63        // `Alias` is the `alias new old` statement (305), which YARD's alias
64        // handler documents; `Alias2` (106) is the keyword token inside it,
65        // never a statement, and is left out on purpose. A global-variable
66        // alias (`alias $new $old`) names no method, and YARD takes none.
67        Ruby::Alias => node
68            .child_by_field_name("name")
69            .is_none_or(|name| name.kind_id() != Ruby::GlobalVariable as u16),
70        // YARD registers a class for `class << Const`, but nothing for
71        // `class << self` or another object, so a comment above one of
72        // those documents nothing.
73        Ruby::SingletonClass => node.child_by_field_name("value").is_some_and(|value| {
74            matches!(
75                value.kind_id().into(),
76                Ruby::Constant | Ruby::ScopeResolution | Ruby::ScopeResolution2
77            )
78        }),
79        // YARD's constant handler documents `MAX = 10` and the scoped
80        // `Foo::BAR = 1`, and its class-variable handler `@@count = 0`; an
81        // assignment to a local or an instance variable documents nothing.
82        // `Assignment2` and `ScopeResolution2` are the grammar's aliased
83        // productions of the same rules (grammar-dispatch rule 1).
84        Ruby::Assignment | Ruby::Assignment2 => {
85            node.child_by_field_name("left").is_some_and(|left| {
86                matches!(
87                    left.kind_id().into(),
88                    Ruby::Constant
89                        | Ruby::ScopeResolution
90                        | Ruby::ScopeResolution2
91                        | Ruby::ClassVariable
92                )
93            })
94        }
95        _ if RubyCode::is_call(node) => ruby_documented_call(node, code),
96        _ => false,
97    }
98}
99
100/// Whether the call `node` is one YARD documents from the comment run
101/// above it: an attribute macro, `alias_method`, or a visibility-wrapped
102/// `def` (`private def m`). YARD's visibility handlers take only a call
103/// with no receiver, so `self.private def m` or `@cfg.private def m`
104/// documents nothing; its attribute handler ignores the receiver. A bare
105/// `private` on its own row is an identifier, not a call, and ends the
106/// run; `private :m` re-scopes a method documented at its `def`, so a
107/// comment above it is not documentation either.
108fn ruby_documented_call(node: &Node, code: &[u8]) -> bool {
109    let Some(method) = node.child_by_field_name("method") else {
110        return false;
111    };
112    match node_bytes(&method, code) {
113        // `alias_method :new, :old` is the call form of `alias`, which the
114        // same YARD handler documents.
115        b"attr" | b"attr_reader" | b"attr_writer" | b"attr_accessor" | b"alias_method" => true,
116        b"private"
117        | b"protected"
118        | b"public"
119        | b"module_function"
120        | b"private_class_method"
121        | b"public_class_method" => {
122            node.child_by_field_name("receiver").is_none()
123                && node
124                    .child_by_field_name("arguments")
125                    .and_then(|arguments| arguments.children().find(Node::is_named))
126                    .is_some_and(|argument| {
127                        matches!(
128                            argument.kind_id().into(),
129                            Ruby::Method | Ruby::SingletonMethod
130                        )
131                    })
132        }
133        _ => false,
134    }
135}
136
137/// The first statement of a `then` wrapper (`Then`, 243 — `Then2`, 55, is
138/// the keyword token, never a wrapper), or `None` for any other node.
139fn ruby_then_first_statement<'a>(node: &Node<'a>) -> Option<Node<'a>> {
140    (node.kind_id() == Ruby::Then)
141        .then(|| node.children().find(Node::is_named))
142        .flatten()
143}
144
145/// Whether YARD reads a documented statement where it sits; `is_def` says
146/// the statement is a bare `def`, the one YARD also reads in a method
147/// body. YARD's processor runs a handler only on a statement some handler
148/// claims, and descends only into the namespace bodies it opens — the
149/// file, a class, a module, a singleton class — the branches of an
150/// `if` / `unless` / `elsif` there, and a method body, where only the
151/// handler for `def` runs: YARD documents a method nested in another, and
152/// never a constant, alias, attribute, visibility call, singleton class or
153/// conditional there. No handler claims a `case`, a `begin` or a loop, so
154/// nothing in their bodies is documented. Unlike YARD, a statement in a
155/// call's block (`included do`, an RSpec `describe` block) is read.
156/// `scope` is the statement itself, or the `then` wrapper standing for
157/// it; `ancestors` is `scope`'s chain.
158fn ruby_yard_reads<'a>(is_def: bool, scope: &Node<'a>, ancestors: Ancestors<'a, '_>) -> bool {
159    // Read upward, the rule is: the nearest namespace admits the
160    // statement; a method body met first rejects it if it needs a
161    // namespace; a singleton class or conditional on the way makes it
162    // need one; and a `case`, `begin` or loop rejects it outright, with
163    // any namespace opened inside one. Folded down instead, so it is
164    // memoised per ancestor (#1597), the state at each depth carries the
165    // upward answer for both starting needs.
166    const DEF_READS: u8 = 0b01;
167    const NAMESPACED_READS: u8 = 0b10;
168    const BOTH: u8 = DEF_READS | NAMESPACED_READS;
169    let reads = ancestors.fold_down(
170        scope,
171        0,
172        FoldLane::DocScope,
173        BOTH,
174        |above, ancestor, _, _| match ancestor.kind_id().into() {
175            Ruby::Program => BOTH,
176            // Each opens a namespace only where one already reads it. For a
177            // class or module — Ruby rejects one in a method body — that
178            // closes it only under a `case`, `begin` or loop, whose bodies
179            // YARD never parses; a singleton class or conditional in a
180            // method body closes as before.
181            Ruby::Class
182            | Ruby::Module
183            | Ruby::SingletonClass
184            | Ruby::If
185            | Ruby::Unless
186            | Ruby::Elsif => {
187                if above & NAMESPACED_READS == 0 {
188                    0
189                } else {
190                    BOTH
191                }
192            }
193            Ruby::Method | Ruby::SingletonMethod => above & DEF_READS,
194            Ruby::Case | Ruby::CaseMatch | Ruby::Begin | Ruby::While | Ruby::Until | Ruby::For => 0,
195            _ => above,
196        },
197    );
198    let need = if is_def { DEF_READS } else { NAMESPACED_READS };
199    reads & need != 0
200}
201
202impl Checker for RubyCode {
203    fn is_comment(node: &Node) -> bool {
204        node.kind_id() == Ruby::Comment
205    }
206
207    fn is_doc_target<'a>(node: &Node<'a>, code: &[u8], ancestors: Ancestors<'a, '_>) -> bool {
208        // The grammar parks a comment that precedes the first statement of
209        // an `if`, `elsif` or `unless` body ahead of the `then` wrapper, so
210        // the wrapper stands for that statement. `then` decides no scope,
211        // so its chain — the comment's — reads the same as the
212        // statement's. The statement test goes first, so the scope fold
213        // runs only for a candidate.
214        let statement = ruby_then_first_statement(node).unwrap_or(*node);
215        ruby_documented_core(&statement).is_some_and(|core| {
216            ruby_documented_statement(&core, code)
217                && ruby_yard_reads(
218                    matches!(core.kind_id().into(), Ruby::Method | Ruby::SingletonMethod),
219                    node,
220                    ancestors,
221                )
222        })
223    }
224
225    fn doc_target_start_row(node: &Node) -> usize {
226        ruby_then_first_statement(node).unwrap_or(*node).start_row()
227    }
228
229    fn is_doc_run_directive(node: &Node, code: &[u8]) -> bool {
230        ruby_magic_comment(node_bytes(node, code))
231    }
232
233    // RDoc's `##` and YARD's `@!` directives document a method that has no
234    // `def` to sit above (`define_method`, a DSL call), so the block counts
235    // whatever follows it — but only where the tools read it: a namespace
236    // body, never a method's.
237    fn is_doc_run_opener<'a>(
238        first: &Node<'a>,
239        _next: Option<&Node<'a>>,
240        code: &[u8],
241        ancestors: Ancestors<'a, '_>,
242    ) -> bool {
243        first.kind_id() == Ruby::Comment
244            && ruby_directive_opener(node_bytes(first, code))
245            && ruby_yard_reads(false, first, ancestors)
246    }
247
248    // Over-approximates for `Block` / `DoBlock`: a stabby lambda's own
249    // body block is NOT a space of its own (the wrapping `Lambda` is),
250    // but telling the two apart needs the parent and this spelling has
251    // no ancestor access. The walker promotes through the
252    // [`Checker::is_func_space_with_code`] override below, which
253    // carries the lambda-body gate (#1257).
254    fn is_func_space(node: &Node) -> bool {
255        matches!(
256            node.kind_id().into(),
257            Ruby::Program
258                | Ruby::Method
259                | Ruby::SingletonMethod
260                | Ruby::Lambda
261                | Ruby::Block
262                | Ruby::DoBlock
263                | Ruby::Class
264                | Ruby::SingletonClass
265                | Ruby::Module
266        )
267    }
268
269    // Derives from `is_func_space` so the kind set lives in one place;
270    // this override only subtracts the stabby-lambda body blocks the
271    // byte-less spelling over-approximates on.
272    fn is_func_space_with_code<'a>(
273        node: &Node<'a>,
274        _code: &[u8],
275        ancestors: Ancestors<'a, '_>,
276    ) -> bool {
277        Self::is_func_space(node)
278            && !(matches!(node.kind_id().into(), Ruby::Block | Ruby::DoBlock)
279                && is_stabby_lambda_body(node, ancestors))
280    }
281
282    fn is_func<'a>(node: &Node<'a>, _ancestors: Ancestors<'a, '_>) -> bool {
283        matches!(node.kind_id().into(), Ruby::Method | Ruby::SingletonMethod)
284    }
285
286    // Ruby is the one non-JS grammar whose closure test is not
287    // answerable from the node's own kind: a `Block` / `DoBlock` counts
288    // only when it is not a stabby lambda's own body (#465, see
289    // `is_stabby_lambda_body`).
290    fn is_closure<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>) -> bool {
291        match node.kind_id().into() {
292            Ruby::Lambda => true,
293            Ruby::Block | Ruby::DoBlock => !is_stabby_lambda_body(node, ancestors),
294            _ => false,
295        }
296    }
297
298    // tree-sitter-ruby 0.23.1 emits four aliased visible variants of the
299    // `call` rule (`Call`, `Call2`, `Call3`, `Call4`); `Call5` ("_call")
300    // is the hidden inner production and does not surface.
301    fn is_call(node: &Node) -> bool {
302        matches!(
303            node.kind_id().into(),
304            Ruby::Call | Ruby::Call2 | Ruby::Call3 | Ruby::Call4
305        )
306    }
307
308    fn is_non_arg(node: &Node) -> bool {
309        // `PIPE` is included because block parameter lists are delimited
310        // by `|` rather than parentheses (e.g. `[1,2,3].each { |x| … }`).
311        matches!(
312            node.kind_id().into(),
313            Ruby::LPAREN
314                | Ruby::LPAREN2
315                | Ruby::RPAREN
316                | Ruby::RPAREN2
317                | Ruby::COMMA
318                | Ruby::SEMI
319                | Ruby::PIPE
320        )
321    }
322
323    impl_simple_is_string!(
324        Ruby,
325        String,
326        ChainedString,
327        BareString,
328        Subshell,
329        Regex,
330        HeredocBody,
331        DelimitedSymbol,
332        SimpleSymbol,
333        StringArray,
334        SymbolArray,
335        Character,
336    );
337
338    // tree-sitter-ruby exposes `elsif` as its own named clause node, so the
339    // dedicated-clause-node strategy applies here (same as Lua/Bash/PHP).
340    impl_is_else_if_clause!(Ruby, Elsif);
341}