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, Parser};
8
9use crate::core::{Kind, Symbol};
10use crate::lang::LanguagePlugin;
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        let mut parser = Parser::new();
27        if parser
28            .set_language(&tree_sitter_ruby::LANGUAGE.into())
29            .is_err()
30        {
31            return Vec::new();
32        }
33        let Some(tree) = parser.parse(source, None) else {
34            return Vec::new();
35        };
36
37        let mut out = Vec::new();
38        let ctx = Ctx {
39            src: source.as_bytes(),
40            file,
41        };
42        ctx.walk(tree.root_node(), None, &mut out);
43        out
44    }
45}
46
47struct Ctx<'a> {
48    src: &'a [u8],
49    file: &'a str,
50}
51
52impl Ctx<'_> {
53    /// Recursively collect definitions. `parent` is the enclosing qualified name.
54    fn walk(&self, node: Node, parent: Option<&str>, out: &mut Vec<Symbol>) {
55        let mut cursor = node.walk();
56        for child in node.children(&mut cursor) {
57            match child.kind() {
58                "class" | "module" => {
59                    let kind = if child.kind() == "class" {
60                        Kind::Class
61                    } else {
62                        Kind::Module
63                    };
64                    if let Some(name) = self.field_text(child, "name") {
65                        // a compact definition (`class A::B::C`) names the leaf `C`
66                        // with `A::B` folded into the parent — same shape as the
67                        // nested `module A; module B; class C` form, so the class
68                        // is found by its leaf name either way
69                        let (leaf, prefix) = split_qualified(&name);
70                        let effective_parent = match prefix {
71                            Some(p) => Some(qualify(parent, p, "::")),
72                            None => parent.map(str::to_string),
73                        };
74                        out.push(self.symbol(leaf, kind, child, effective_parent.as_deref()));
75                        let qualified = qualify(effective_parent.as_deref(), leaf, "::");
76                        self.walk(child, Some(&qualified), out);
77                    } else {
78                        self.walk(child, parent, out);
79                    }
80                }
81                "method" | "singleton_method" => {
82                    if let Some(name) = self.field_text(child, "name") {
83                        out.push(self.symbol(&name, Kind::Method, child, parent));
84                    }
85                    // method bodies rarely hold further definitions; don't recurse.
86                }
87                _ => self.walk(child, parent, out),
88            }
89        }
90    }
91
92    fn field_text(&self, node: Node, field: &str) -> Option<String> {
93        node.child_by_field_name(field)
94            .and_then(|n| n.utf8_text(self.src).ok())
95            .map(str::to_string)
96    }
97
98    fn symbol(&self, name: &str, kind: Kind, node: Node, parent: Option<&str>) -> Symbol {
99        Symbol {
100            name: name.to_string(),
101            kind,
102            language: LANGUAGE.to_string(),
103            file: self.file.to_string(),
104            line: node.start_position().row as u32 + 1,
105            parent: parent.map(str::to_string),
106        }
107    }
108}
109
110/// Split a possibly compact-qualified definition name (`A::B::C`) into its leaf
111/// (`C`) and namespace prefix (`A::B`). A plain name has no prefix; a leading
112/// `::` (absolute `::Foo`) yields no prefix either.
113fn split_qualified(name: &str) -> (&str, Option<&str>) {
114    match name.rfind("::") {
115        Some(i) => {
116            let prefix = &name[..i];
117            (&name[i + 2..], (!prefix.is_empty()).then_some(prefix))
118        }
119        None => (name, None),
120    }
121}
122
123fn qualify(parent: Option<&str>, name: &str, sep: &str) -> String {
124    match parent {
125        Some(p) => format!("{p}{sep}{name}"),
126        None => name.to_string(),
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn extract(source: &str) -> Vec<Symbol> {
135        Ruby.extract("test.rb", source)
136    }
137
138    fn find<'a>(syms: &'a [Symbol], name: &str) -> &'a Symbol {
139        syms.iter()
140            .find(|s| s.name == name)
141            .unwrap_or_else(|| panic!("no symbol named {name} in {syms:?}"))
142    }
143
144    #[test]
145    fn extracts_class_module_and_methods_with_nesting() {
146        let src = r#"
147module Billing
148  class RefundProcessor
149    def perform
150    end
151
152    def self.build
153    end
154  end
155end
156"#;
157        let syms = extract(src);
158
159        let module = find(&syms, "Billing");
160        assert_eq!(module.kind, Kind::Module);
161        assert_eq!(module.parent, None);
162        assert_eq!(module.line, 2);
163
164        let class = find(&syms, "RefundProcessor");
165        assert_eq!(class.kind, Kind::Class);
166        assert_eq!(class.parent.as_deref(), Some("Billing"));
167
168        let perform = find(&syms, "perform");
169        assert_eq!(perform.kind, Kind::Method);
170        assert_eq!(perform.parent.as_deref(), Some("Billing::RefundProcessor"));
171
172        // singleton method (def self.build) is captured too
173        let build = find(&syms, "build");
174        assert_eq!(build.kind, Kind::Method);
175        assert_eq!(build.parent.as_deref(), Some("Billing::RefundProcessor"));
176    }
177
178    #[test]
179    fn compact_namespace_is_split_into_leaf_and_parent() {
180        // `class A::B::C` names the leaf `C`, with `A::B` folded into the parent —
181        // so it's found by its leaf name just like the nested form, and a method
182        // inside it still qualifies fully
183        let src = "class My::Module::EmployeesController\n  def index\n  end\nend\n";
184        let syms = extract(src);
185
186        let class = find(&syms, "EmployeesController");
187        assert_eq!(class.kind, Kind::Class);
188        assert_eq!(class.parent.as_deref(), Some("My::Module"));
189
190        let index = find(&syms, "index");
191        assert_eq!(
192            index.parent.as_deref(),
193            Some("My::Module::EmployeesController")
194        );
195    }
196
197    #[test]
198    fn empty_and_unparseable_yield_no_symbols() {
199        assert!(extract("").is_empty());
200        assert!(extract("# just a comment\n").is_empty());
201    }
202
203    #[test]
204    fn language_tag_is_set() {
205        let syms = extract("class Foo\nend\n");
206        assert_eq!(syms[0].language, "ruby");
207    }
208}