Skip to main content

reference_query/lang/ruby/
mod.rs

1//! Ruby plugin — the first language.
2//!
3//! Extracts classes, modules, and methods (instance and singleton) via
4//! Tree-sitter. `parent` carries the enclosing qualified name so a method
5//! renders as `Foo::Bar#baz` and a nested class as `Foo::Bar`.
6
7use tree_sitter::Node;
8
9use crate::core::{Kind, Symbol};
10use crate::lang::{Ctx, LanguagePlugin, extract_with, qualify};
11
12const LANGUAGE: &str = "ruby";
13
14pub struct Ruby;
15
16impl LanguagePlugin for Ruby {
17    fn language(&self) -> &'static str {
18        LANGUAGE
19    }
20
21    fn extensions(&self) -> &[&str] {
22        &["rb"]
23    }
24
25    fn extract(&self, file: &str, source: &str) -> Vec<Symbol> {
26        extract_with(
27            LANGUAGE,
28            tree_sitter_ruby::LANGUAGE.into(),
29            file,
30            source,
31            |ctx, root, out| walk(ctx, root, None, "public", out),
32        )
33    }
34}
35
36/// Recursively collect definitions. `parent` is the enclosing qualified name;
37/// `vis` is the access section in effect (a bare `private`/`protected`/`public`
38/// marker flips it for everything after, including through wrapping nodes like
39/// `private def foo`).
40fn walk(ctx: &Ctx, node: Node, parent: Option<&str>, vis: &'static str, out: &mut Vec<Symbol>) {
41    let mut vis = vis;
42    let mut cursor = node.walk();
43    for child in node.children(&mut cursor) {
44        match child.kind() {
45            "class" | "module" => {
46                let kind = if child.kind() == "class" {
47                    Kind::Class
48                } else {
49                    Kind::Module
50                };
51                if let Some(name) = ctx.field_text(child, "name") {
52                    // a compact definition (`class A::B::C`) names the leaf `C`
53                    // with `A::B` folded into the parent — same shape as the
54                    // nested `module A; module B; class C` form, so the class
55                    // is found by its leaf name either way
56                    let (leaf, prefix) = split_qualified(&name);
57                    let effective_parent = match prefix {
58                        Some(p) => Some(qualify(parent, p, "::")),
59                        None => parent.map(str::to_string),
60                    };
61                    let mut s = ctx.symbol(leaf, kind, child, effective_parent.as_deref());
62                    s.visibility = Some("public");
63                    out.push(s);
64                    let qualified = qualify(effective_parent.as_deref(), leaf, "::");
65                    // a fresh body starts a fresh (public) access section
66                    walk(ctx, child, Some(&qualified), "public", out);
67                } else {
68                    walk(ctx, child, parent, vis, out);
69                }
70            }
71            "method" | "singleton_method" => {
72                if let Some(name) = ctx.field_text(child, "name") {
73                    let mut s = ctx.symbol(&name, Kind::Method, child, parent);
74                    // `private` sections don't apply to `def self.x`
75                    s.visibility = Some(if child.kind() == "singleton_method" {
76                        "public"
77                    } else {
78                        vis
79                    });
80                    out.push(s);
81                }
82                // method bodies rarely hold further definitions; don't recurse.
83            }
84            // a bare access marker flips the section for what follows
85            "identifier" => match ctx.node_text(child).as_deref() {
86                Some("private") => vis = "private",
87                Some("protected") => vis = "protected",
88                Some("public") => vis = "public",
89                _ => {}
90            },
91            "call" => {
92                // metaprogramming: `attr_accessor :x`, `has_many :users`, … are
93                // calls that *define* methods Tree-sitter can't see as defs.
94                // Emit the literal names, pointing at the macro's line.
95                dsl_symbols(ctx, child, parent, vis, out);
96                // still recurse: a call can wrap real definitions
97                // (`private def foo` — its `private` identifier flips `vis`
98                // on the way down — or `Class.new do … end`)
99                walk(ctx, child, parent, vis, out);
100            }
101            _ => walk(ctx, child, parent, vis, out),
102        }
103    }
104}
105
106/// How many of a DSL macro's arguments name methods it defines.
107enum DslArgs {
108    /// Every literal argument (`attr_accessor :a, :b`, `delegate :x, :y, to:`).
109    All,
110    /// Only the first (`define_method(:x)`, `scope :active`, `has_many :users`).
111    First,
112}
113
114/// The method-defining macro vocabulary: Ruby core plus the everyday Rails
115/// surface. Deliberately small — literal, high-confidence definitions only.
116///
117/// `field` earns its place the same way `has_many` does: it's the declaration
118/// that defines the member, across several schema DSLs (graphql-ruby, Mongoid,
119/// dry-types). Without it, a field declared `field :email, String` has no
120/// definition to navigate to at all — the receiver form (`f.field :email`, a
121/// form builder) is already excluded, which is where the name would otherwise
122/// be ambiguous.
123fn dsl_args(method: &str) -> Option<DslArgs> {
124    match method {
125        "attr_accessor" | "attr_reader" | "attr_writer" | "delegate" => Some(DslArgs::All),
126        "define_method" | "alias_method" | "scope" | "has_many" | "has_one" | "belongs_to"
127        | "field" => Some(DslArgs::First),
128        _ => None,
129    }
130}
131
132/// Emit method symbols for a metaprogramming call: `attr_accessor :balance`
133/// defines `balance` even though no `def` exists. Only *literal* symbol/string
134/// arguments count — a computed name (`define_method(name)`) is unresolvable
135/// statically, so it's skipped rather than guessed. Keyword arguments
136/// (`delegate …, to: :owner`) are `pair` nodes and naturally excluded.
137fn dsl_symbols(
138    ctx: &Ctx,
139    call: Node,
140    parent: Option<&str>,
141    vis: &'static str,
142    out: &mut Vec<Symbol>,
143) {
144    if call.child_by_field_name("receiver").is_some() {
145        return; // `Foo.attr_accessor` isn't the macro form we index
146    }
147    let Some(method) = ctx.field_text(call, "method") else {
148        return;
149    };
150    let Some(args) = dsl_args(&method) else {
151        return;
152    };
153    let Some(arg_list) = call.child_by_field_name("arguments") else {
154        return;
155    };
156    let mut cursor = arg_list.walk();
157    for arg in arg_list.children(&mut cursor) {
158        if !arg.is_named() {
159            continue; // parens and commas
160        }
161        if let Some(name) = literal_name(ctx, arg)
162            && !name.is_empty()
163        {
164            let mut s = ctx.symbol(&name, Kind::Method, call, parent);
165            s.visibility = Some(vis);
166            out.push(s);
167        }
168        if matches!(args, DslArgs::First) {
169            break; // later args are options (`scope :active, -> {…}`), not names
170        }
171    }
172}
173
174/// The name a literal `:symbol` or `"string"` argument carries, if any.
175fn literal_name(ctx: &Ctx, node: Node) -> Option<String> {
176    match node.kind() {
177        "simple_symbol" => ctx
178            .node_text(node)
179            .map(|t| t.trim_start_matches(':').to_string()),
180        "string" => ctx
181            .node_text(node)
182            .map(|t| t.trim_matches(|c| c == '"' || c == '\'').to_string()),
183        _ => None,
184    }
185}
186
187/// Split a possibly compact-qualified definition name (`A::B::C`) into its leaf
188/// (`C`) and namespace prefix (`A::B`). A plain name has no prefix; a leading
189/// `::` (absolute `::Foo`) yields no prefix either.
190fn split_qualified(name: &str) -> (&str, Option<&str>) {
191    match name.rfind("::") {
192        Some(i) => {
193            let prefix = &name[..i];
194            (&name[i + 2..], (!prefix.is_empty()).then_some(prefix))
195        }
196        None => (name, None),
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn extract(source: &str) -> Vec<Symbol> {
205        Ruby.extract("test.rb", source)
206    }
207
208    fn find<'a>(syms: &'a [Symbol], name: &str) -> &'a Symbol {
209        syms.iter()
210            .find(|s| s.name == name)
211            .unwrap_or_else(|| panic!("no symbol named {name} in {syms:?}"))
212    }
213
214    #[test]
215    fn extracts_class_module_and_methods_with_nesting() {
216        let src = r#"
217module Billing
218  class RefundProcessor
219    def perform
220    end
221
222    def self.build
223    end
224  end
225end
226"#;
227        let syms = extract(src);
228
229        let module = find(&syms, "Billing");
230        assert_eq!(module.kind, Kind::Module);
231        assert_eq!(module.parent, None);
232        assert_eq!(module.line, 2);
233        // end_line spans the whole body to the matching `end`
234        assert_eq!(module.end_line, 10);
235
236        let class = find(&syms, "RefundProcessor");
237        assert_eq!(class.kind, Kind::Class);
238        assert_eq!(class.parent.as_deref(), Some("Billing"));
239
240        let perform = find(&syms, "perform");
241        assert_eq!(perform.kind, Kind::Method);
242        assert_eq!(perform.parent.as_deref(), Some("Billing::RefundProcessor"));
243        // the method body is lines 4..=5 (`def perform` through its `end`)
244        assert_eq!((perform.line, perform.end_line), (4, 5));
245
246        // singleton method (def self.build) is captured too
247        let build = find(&syms, "build");
248        assert_eq!(build.kind, Kind::Method);
249        assert_eq!(build.parent.as_deref(), Some("Billing::RefundProcessor"));
250    }
251
252    #[test]
253    fn compact_namespace_is_split_into_leaf_and_parent() {
254        // `class A::B::C` names the leaf `C`, with `A::B` folded into the parent —
255        // so it's found by its leaf name just like the nested form, and a method
256        // inside it still qualifies fully
257        let src = "class My::Module::EmployeesController\n  def index\n  end\nend\n";
258        let syms = extract(src);
259
260        let class = find(&syms, "EmployeesController");
261        assert_eq!(class.kind, Kind::Class);
262        assert_eq!(class.parent.as_deref(), Some("My::Module"));
263
264        let index = find(&syms, "index");
265        assert_eq!(
266            index.parent.as_deref(),
267            Some("My::Module::EmployeesController")
268        );
269    }
270
271    #[test]
272    fn metaprogramming_macros_define_methods() {
273        let src = r#"
274class Account
275  attr_accessor :balance, :currency
276  attr_reader "label"
277  has_many :transactions, dependent: :destroy
278  scope :active, -> { where(active: true) }
279  delegate :name, :email, to: :owner, prefix: true
280  define_method(:refresh!) { reload }
281  alias_method :bal, :balance
282end
283"#;
284        let syms = extract(src);
285
286        for name in [
287            "balance",
288            "currency",
289            "label",
290            "transactions",
291            "active",
292            "name",
293            "email",
294            "refresh!",
295            "bal",
296        ] {
297            let s = find(&syms, name);
298            assert_eq!(s.kind, Kind::Method, "{name} is a method");
299            assert_eq!(s.parent.as_deref(), Some("Account"), "{name} in Account");
300        }
301
302        // option arguments never become symbols
303        for non_name in ["destroy", "owner", "dependent", "to", "prefix", "where"] {
304            assert!(
305                !syms.iter().any(|s| s.name == non_name),
306                "{non_name} is an option, not a defined method: {syms:?}"
307            );
308        }
309    }
310
311    #[test]
312    fn schema_dsl_field_declarations_define_methods() {
313        let src = r#"
314module Types
315  class UserType < Types::BaseObject
316    field :id, ID, null: false
317    field :email, String, null: true
318    field :posts, [Types::PostType], null: false do
319      argument :first, Integer, required: false
320    end
321
322    def posts(first: nil)
323      object.posts.limit(first)
324    end
325  end
326end
327"#;
328        let syms = extract(src);
329
330        for name in ["id", "email", "posts"] {
331            let s = find(&syms, name);
332            assert_eq!(s.kind, Kind::Method, "{name} is a method");
333            assert_eq!(
334                s.parent.as_deref(),
335                Some("Types::UserType"),
336                "{name} in UserType"
337            );
338        }
339        // the block form declares `posts` once as a field and once as a real
340        // `def`; both are definitions of the same member, and both are indexed
341        assert_eq!(
342            syms.iter().filter(|s| s.name == "posts").count(),
343            2,
344            "{syms:?}"
345        );
346        // type arguments and options are not members
347        for non_name in ["ID", "String", "null", "required", "first"] {
348            assert!(
349                !syms.iter().any(|s| s.name == non_name),
350                "{non_name} is not a defined method: {syms:?}"
351            );
352        }
353    }
354
355    #[test]
356    fn computed_and_received_macro_names_are_skipped() {
357        let src = r#"
358class Widget
359  define_method(dynamic_name) { }
360  Other.attr_accessor :not_ours
361  form.field :not_ours_either
362end
363"#;
364        let syms = extract(src);
365        // only the class itself — no guessed names, no receiver-form macros
366        assert_eq!(syms.len(), 1, "{syms:?}");
367        assert_eq!(syms[0].name, "Widget");
368    }
369
370    #[test]
371    fn a_def_wrapped_in_a_visibility_call_is_still_found() {
372        let src = "class Widget\n  private def hidden\n  end\nend\n";
373        let syms = extract(src);
374        let hidden = find(&syms, "hidden");
375        assert_eq!(hidden.kind, Kind::Method);
376        assert_eq!(hidden.parent.as_deref(), Some("Widget"));
377        assert_eq!(hidden.visibility, Some("private"));
378    }
379
380    #[test]
381    fn access_sections_set_visibility() {
382        let src = r#"
383class Widget
384  def open_api
385  end
386
387  private
388
389  def internal
390  end
391  attr_reader :secret
392
393  public
394
395  def reopened
396  end
397end
398"#;
399        let syms = extract(src);
400        assert_eq!(find(&syms, "open_api").visibility, Some("public"));
401        assert_eq!(find(&syms, "internal").visibility, Some("private"));
402        // a macro under `private` defines private methods too
403        assert_eq!(find(&syms, "secret").visibility, Some("private"));
404        assert_eq!(find(&syms, "reopened").visibility, Some("public"));
405    }
406
407    #[test]
408    fn empty_and_unparseable_yield_no_symbols() {
409        assert!(extract("").is_empty());
410        assert!(extract("# just a comment\n").is_empty());
411    }
412
413    #[test]
414    fn language_tag_is_set() {
415        let syms = extract("class Foo\nend\n");
416        assert_eq!(syms[0].language, "ruby");
417    }
418}