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}