1use 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
36fn 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 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 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 s.visibility = Some(if child.kind() == "singleton_method" {
76 "public"
77 } else {
78 vis
79 });
80 out.push(s);
81 }
82 }
84 "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 dsl_symbols(ctx, child, parent, vis, out);
96 walk(ctx, child, parent, vis, out);
100 }
101 _ => walk(ctx, child, parent, vis, out),
102 }
103 }
104}
105
106enum DslArgs {
108 All,
110 First,
112}
113
114fn 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
132fn 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; }
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; }
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; }
171 }
172}
173
174fn 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
187fn 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 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 assert_eq!((perform.line, perform.end_line), (4, 5));
245
246 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 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 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 assert_eq!(
342 syms.iter().filter(|s| s.name == "posts").count(),
343 2,
344 "{syms:?}"
345 );
346 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 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 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}