1use anyhow::{Context, Result};
16use streaming_iterator::StreamingIterator;
17use tree_sitter::{Parser, Query, QueryCursor};
18use crate::models::{Language, SearchResult, Span, SymbolKind, ImportType};
19use crate::parsers::{DependencyExtractor, ImportInfo};
20
21pub fn parse(path: &str, source: &str) -> Result<Vec<SearchResult>> {
23 let mut parser = Parser::new();
24 let language = tree_sitter_ruby::LANGUAGE;
25
26 parser
27 .set_language(&language.into())
28 .context("Failed to set Ruby language")?;
29
30 let tree = parser
31 .parse(source, None)
32 .context("Failed to parse Ruby source")?;
33
34 let root_node = tree.root_node();
35
36 let mut symbols = Vec::new();
37
38 symbols.extend(extract_modules(source, &root_node, &language.into())?);
40 symbols.extend(extract_classes(source, &root_node, &language.into())?);
41 symbols.extend(extract_methods(source, &root_node, &language.into())?);
42 symbols.extend(extract_singleton_methods(source, &root_node, &language.into())?);
43 symbols.extend(extract_constants(source, &root_node, &language.into())?);
44 symbols.extend(extract_instance_variables(source, &root_node, &language.into())?);
45 symbols.extend(extract_class_variables(source, &root_node, &language.into())?);
46 symbols.extend(extract_attr_accessors(source, &root_node, &language.into())?);
47 symbols.extend(extract_local_variables(source, &root_node, &language.into())?);
48
49 for symbol in &mut symbols {
51 symbol.path = path.to_string();
52 symbol.lang = Language::Ruby;
53 }
54
55 Ok(symbols)
56}
57
58fn extract_modules(
60 source: &str,
61 root: &tree_sitter::Node,
62 language: &tree_sitter::Language,
63) -> Result<Vec<SearchResult>> {
64 let query_str = r#"
65 (module
66 name: (constant) @name) @module
67 "#;
68
69 let query = Query::new(language, query_str)
70 .context("Failed to create module query")?;
71
72 extract_symbols(source, root, &query, SymbolKind::Module, None)
73}
74
75fn extract_classes(
77 source: &str,
78 root: &tree_sitter::Node,
79 language: &tree_sitter::Language,
80) -> Result<Vec<SearchResult>> {
81 let query_str = r#"
82 (class
83 name: (constant) @name) @class
84 "#;
85
86 let query = Query::new(language, query_str)
87 .context("Failed to create class query")?;
88
89 extract_symbols(source, root, &query, SymbolKind::Class, None)
90}
91
92fn extract_methods(
94 source: &str,
95 root: &tree_sitter::Node,
96 language: &tree_sitter::Language,
97) -> Result<Vec<SearchResult>> {
98 let query_str = r#"
99 (class
100 name: (constant) @class_name
101 (body_statement
102 (method
103 name: (_) @method_name))) @class
104
105 (module
106 name: (constant) @module_name
107 (body_statement
108 (method
109 name: (_) @method_name))) @module
110 "#;
111
112 let query = Query::new(language, query_str)
113 .context("Failed to create method query")?;
114
115 let mut cursor = QueryCursor::new();
116 let mut matches = cursor.matches(&query, *root, source.as_bytes());
117
118 let mut symbols = Vec::new();
119
120 while let Some(match_) = matches.next() {
121 let mut scope_name = None;
122 let mut scope_type = None;
123 let mut method_name = None;
124 let mut method_node = None;
125
126 for capture in match_.captures {
127 let capture_name: &str = &query.capture_names()[capture.index as usize];
128 match capture_name {
129 "class_name" => {
130 scope_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
131 scope_type = Some("class");
132 }
133 "module_name" => {
134 scope_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
135 scope_type = Some("module");
136 }
137 "method_name" => {
138 method_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
139 let mut current = capture.node;
141 while let Some(parent) = current.parent() {
142 if parent.kind() == "method" {
143 method_node = Some(parent);
144 break;
145 }
146 current = parent;
147 }
148 }
149 _ => {}
150 }
151 }
152
153 if let (Some(scope_name), Some(scope_type), Some(method_name), Some(node)) =
154 (scope_name, scope_type, method_name, method_node) {
155 let scope = format!("{} {}", scope_type, scope_name);
156 let span = node_to_span(&node);
157 let preview = extract_preview(source, &span);
158
159 symbols.push(SearchResult::new(
160 String::new(),
161 Language::Ruby,
162 SymbolKind::Method,
163 Some(method_name),
164 span,
165 Some(scope),
166 preview,
167 ));
168 }
169 }
170
171 Ok(symbols)
172}
173
174fn extract_singleton_methods(
176 source: &str,
177 root: &tree_sitter::Node,
178 language: &tree_sitter::Language,
179) -> Result<Vec<SearchResult>> {
180 let query_str = r#"
181 (singleton_method
182 object: (_) @class_name
183 name: (_) @method_name) @method
184 "#;
185
186 let query = Query::new(language, query_str)
187 .context("Failed to create singleton method query")?;
188
189 let mut cursor = QueryCursor::new();
190 let mut matches = cursor.matches(&query, *root, source.as_bytes());
191
192 let mut symbols = Vec::new();
193
194 while let Some(match_) = matches.next() {
195 let mut class_name = None;
196 let mut method_name = None;
197 let mut method_node = None;
198
199 for capture in match_.captures {
200 let capture_name: &str = &query.capture_names()[capture.index as usize];
201 match capture_name {
202 "class_name" => {
203 class_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
204 }
205 "method_name" => {
206 method_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
207 }
208 "method" => {
209 method_node = Some(capture.node);
210 }
211 _ => {}
212 }
213 }
214
215 if let (Some(class_name), Some(method_name), Some(node)) = (class_name, method_name, method_node) {
216 let scope = format!("class {}", class_name);
217 let span = node_to_span(&node);
218 let preview = extract_preview(source, &span);
219
220 symbols.push(SearchResult::new(
221 String::new(),
222 Language::Ruby,
223 SymbolKind::Method,
224 Some(format!("{}.{}", class_name, method_name)),
225 span,
226 Some(scope),
227 preview,
228 ));
229 }
230 }
231
232 Ok(symbols)
233}
234
235fn extract_constants(
237 source: &str,
238 root: &tree_sitter::Node,
239 language: &tree_sitter::Language,
240) -> Result<Vec<SearchResult>> {
241 let query_str = r#"
242 (assignment
243 left: (constant) @name
244 right: (_)) @const
245 "#;
246
247 let query = Query::new(language, query_str)
248 .context("Failed to create constant query")?;
249
250 extract_symbols(source, root, &query, SymbolKind::Constant, None)
251}
252
253fn extract_local_variables(
255 source: &str,
256 root: &tree_sitter::Node,
257 language: &tree_sitter::Language,
258) -> Result<Vec<SearchResult>> {
259 let query_str = r#"
260 (assignment
261 left: (identifier) @name) @assignment
262 "#;
263
264 let query = Query::new(language, query_str)
265 .context("Failed to create local variable query")?;
266
267 let mut cursor = QueryCursor::new();
268 let mut matches = cursor.matches(&query, *root, source.as_bytes());
269
270 let mut symbols = Vec::new();
271
272 while let Some(match_) = matches.next() {
273 let mut name = None;
274 let mut assignment_node = None;
275
276 for capture in match_.captures {
277 let capture_name: &str = &query.capture_names()[capture.index as usize];
278 match capture_name {
279 "name" => {
280 name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
281 }
282 "assignment" => {
283 assignment_node = Some(capture.node);
284 }
285 _ => {}
286 }
287 }
288
289 if let (Some(name), Some(node)) = (name, assignment_node) {
290 let mut is_in_method = false;
292 let mut current = node;
293
294 while let Some(parent) = current.parent() {
295 if parent.kind() == "method" || parent.kind() == "singleton_method" {
296 is_in_method = true;
297 break;
298 }
299 if parent.kind() == "program" || parent.kind() == "module" || parent.kind() == "class" {
301 break;
302 }
303 current = parent;
304 }
305
306 if is_in_method {
307 let span = node_to_span(&node);
308 let preview = extract_preview(source, &span);
309
310 symbols.push(SearchResult::new(
311 String::new(),
312 Language::Ruby,
313 SymbolKind::Variable,
314 Some(name),
315 span,
316 None,
317 preview,
318 ));
319 }
320 }
321 }
322
323 Ok(symbols)
324}
325
326fn extract_instance_variables(
328 source: &str,
329 root: &tree_sitter::Node,
330 language: &tree_sitter::Language,
331) -> Result<Vec<SearchResult>> {
332 let query_str = r#"
333 (instance_variable) @name
334 "#;
335
336 let query = Query::new(language, query_str)
337 .context("Failed to create instance variable query")?;
338
339 let mut cursor = QueryCursor::new();
340 let mut matches = cursor.matches(&query, *root, source.as_bytes());
341
342 let mut symbols = Vec::new();
343 let mut seen = std::collections::HashSet::new();
344
345 while let Some(match_) = matches.next() {
346 for capture in match_.captures {
347 let name_text = capture.node.utf8_text(source.as_bytes()).unwrap_or("");
348
349 if !seen.contains(name_text) {
351 seen.insert(name_text.to_string());
352
353 let span = node_to_span(&capture.node);
354 let preview = extract_preview(source, &span);
355
356 symbols.push(SearchResult::new(
357 String::new(),
358 Language::Ruby,
359 SymbolKind::Variable,
360 Some(name_text.to_string()),
361 span,
362 None,
363 preview,
364 ));
365 }
366 }
367 }
368
369 Ok(symbols)
370}
371
372fn extract_class_variables(
374 source: &str,
375 root: &tree_sitter::Node,
376 language: &tree_sitter::Language,
377) -> Result<Vec<SearchResult>> {
378 let query_str = r#"
379 (class_variable) @name
380 "#;
381
382 let query = Query::new(language, query_str)
383 .context("Failed to create class variable query")?;
384
385 let mut cursor = QueryCursor::new();
386 let mut matches = cursor.matches(&query, *root, source.as_bytes());
387
388 let mut symbols = Vec::new();
389 let mut seen = std::collections::HashSet::new();
390
391 while let Some(match_) = matches.next() {
392 for capture in match_.captures {
393 let name_text = capture.node.utf8_text(source.as_bytes()).unwrap_or("");
394
395 if !seen.contains(name_text) {
397 seen.insert(name_text.to_string());
398
399 let span = node_to_span(&capture.node);
400 let preview = extract_preview(source, &span);
401
402 symbols.push(SearchResult::new(
403 String::new(),
404 Language::Ruby,
405 SymbolKind::Variable,
406 Some(name_text.to_string()),
407 span,
408 None,
409 preview,
410 ));
411 }
412 }
413 }
414
415 Ok(symbols)
416}
417
418fn extract_attr_accessors(
420 source: &str,
421 root: &tree_sitter::Node,
422 language: &tree_sitter::Language,
423) -> Result<Vec<SearchResult>> {
424 let query_str = r#"
425 (call
426 method: (identifier) @method_type
427 arguments: (argument_list
428 (simple_symbol) @name))
429
430 (#match? @method_type "^(attr_reader|attr_writer|attr_accessor)$")
431 "#;
432
433 let query = Query::new(language, query_str)
434 .context("Failed to create attr accessor query")?;
435
436 let mut cursor = QueryCursor::new();
437 let mut matches = cursor.matches(&query, *root, source.as_bytes());
438
439 let mut symbols = Vec::new();
440
441 while let Some(match_) = matches.next() {
442 let mut method_type = None;
443 let mut name = None;
444 let mut call_node = None;
445
446 for capture in match_.captures {
447 let capture_name: &str = &query.capture_names()[capture.index as usize];
448 match capture_name {
449 "method_type" => {
450 method_type = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
451 }
452 "name" => {
453 let symbol_text = capture.node.utf8_text(source.as_bytes()).unwrap_or("");
454 name = Some(symbol_text.trim_start_matches(':').to_string());
456
457 let mut current = capture.node;
459 while let Some(parent) = current.parent() {
460 if parent.kind() == "call" {
461 call_node = Some(parent);
462 break;
463 }
464 current = parent;
465 }
466 }
467 _ => {}
468 }
469 }
470
471 if let (Some(_method_type), Some(name), Some(node)) = (method_type, name, call_node) {
472 let span = node_to_span(&node);
473 let preview = extract_preview(source, &span);
474
475 symbols.push(SearchResult::new(
476 String::new(),
477 Language::Ruby,
478 SymbolKind::Property,
479 Some(name),
480 span,
481 None,
482 preview,
483 ));
484 }
485 }
486
487 Ok(symbols)
488}
489
490fn extract_symbols(
492 source: &str,
493 root: &tree_sitter::Node,
494 query: &Query,
495 kind: SymbolKind,
496 scope: Option<String>,
497) -> Result<Vec<SearchResult>> {
498 let mut cursor = QueryCursor::new();
499 let mut matches = cursor.matches(query, *root, source.as_bytes());
500
501 let mut symbols = Vec::new();
502
503 while let Some(match_) = matches.next() {
504 let mut name = None;
506 let mut full_node = None;
507
508 for capture in match_.captures {
509 let capture_name: &str = &query.capture_names()[capture.index as usize];
510 if capture_name == "name" {
511 name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
512 } else {
513 full_node = Some(capture.node);
515 }
516 }
517
518 if let (Some(name), Some(node)) = (name, full_node) {
519 let span = node_to_span(&node);
520 let preview = extract_preview(source, &span);
521
522 symbols.push(SearchResult::new(
523 String::new(),
524 Language::Ruby,
525 kind.clone(),
526 Some(name),
527 span,
528 scope.clone(),
529 preview,
530 ));
531 }
532 }
533
534 Ok(symbols)
535}
536
537fn node_to_span(node: &tree_sitter::Node) -> Span {
539 let start = node.start_position();
540 let end = node.end_position();
541
542 Span::new(
543 start.row + 1, start.column,
545 end.row + 1,
546 end.column,
547 )
548}
549
550fn extract_preview(source: &str, span: &Span) -> String {
552 let lines: Vec<&str> = source.lines().collect();
553
554 let start_idx = (span.start_line - 1) as usize; let end_idx = (start_idx + 7).min(lines.len());
557
558 lines[start_idx..end_idx].join("\n")
559}
560
561pub struct RubyDependencyExtractor;
563
564impl DependencyExtractor for RubyDependencyExtractor {
565 fn extract_dependencies(source: &str) -> Result<Vec<ImportInfo>> {
566 let mut parser = Parser::new();
567 let language = tree_sitter_ruby::LANGUAGE;
568
569 parser
570 .set_language(&language.into())
571 .context("Failed to set Ruby language")?;
572
573 let tree = parser
574 .parse(source, None)
575 .context("Failed to parse Ruby source")?;
576
577 let root_node = tree.root_node();
578
579 let query_str = r#"
582 (call
583 method: (identifier) @method_name
584 arguments: (argument_list) @args) @call
585
586 (#match? @method_name "^(require|require_relative|load)$")
587 "#;
588
589 let query = Query::new(&language.into(), query_str)
590 .context("Failed to create Ruby require query")?;
591
592 let mut cursor = QueryCursor::new();
593 let mut matches = cursor.matches(&query, root_node, source.as_bytes());
594
595 let mut imports = Vec::new();
596 let mut seen = std::collections::HashSet::new(); while let Some(match_) = matches.next() {
599 let mut method_name = None;
600 let mut args_node = None;
601
602 for capture in match_.captures {
603 let capture_name: &str = &query.capture_names()[capture.index as usize];
604 match capture_name {
605 "method_name" => {
606 method_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
607 }
608 "args" => {
609 args_node = Some(capture.node);
610 }
611 _ => {}
612 }
613 }
614
615 if let (Some(method), Some(args)) = (method_name, args_node) {
616 if !matches!(method.as_str(), "require" | "require_relative" | "load") {
619 continue;
620 }
621
622 let mut cursor = args.walk();
625 for child in args.children(&mut cursor) {
626 match child.kind() {
627 "string" => {
628 let mut is_interpolated = false;
630 let mut child_cursor = child.walk();
631 for grandchild in child.children(&mut child_cursor) {
632 if grandchild.kind() == "interpolation" {
633 is_interpolated = true;
634 break;
635 }
636 }
637 if is_interpolated {
638 continue; }
640
641 let mut content = None;
643 let mut child_cursor = child.walk();
644 for grandchild in child.children(&mut child_cursor) {
645 if grandchild.kind() == "string_content" {
646 content = Some(grandchild.utf8_text(source.as_bytes()).unwrap_or("").to_string());
647 break;
648 }
649 }
650
651 if let Some(path) = content {
652 if path.is_empty() {
654 continue;
655 }
656
657 let line_number = child.start_position().row + 1;
658 let key = (path.clone(), line_number);
659
660 if seen.contains(&key) {
662 continue;
663 }
664 seen.insert(key);
665
666 let import_type = classify_ruby_import(&path, &method);
667
668 imports.push(ImportInfo {
669 imported_path: path,
670 line_number,
671 import_type,
672 imported_symbols: None,
673 });
674 }
675 }
676 "simple_symbol" => {
677 let mut path = child.utf8_text(source.as_bytes()).unwrap_or("").to_string();
678 if path.starts_with(':') {
680 path = path.trim_start_matches(':').to_string();
681 }
682
683 let line_number = child.start_position().row + 1;
684 let key = (path.clone(), line_number);
685
686 if seen.contains(&key) {
688 continue;
689 }
690 seen.insert(key);
691
692 let import_type = classify_ruby_import(&path, &method);
693
694 imports.push(ImportInfo {
695 imported_path: path,
696 line_number,
697 import_type,
698 imported_symbols: None,
699 });
700 }
701 _ => {}
704 }
705 }
706 }
707 }
708
709 Ok(imports)
710 }
711}
712
713#[derive(Debug, Clone)]
715pub struct RubyProject {
716 pub gem_name: String, pub project_root: String, pub abs_project_root: String, }
720
721pub fn find_all_gemspec_files(root: &std::path::Path) -> Result<Vec<std::path::PathBuf>> {
723 let mut gemspec_files = Vec::new();
724
725 let walker = ignore::WalkBuilder::new(root)
726 .follow_links(false)
727 .git_ignore(true)
728 .build();
729
730 for entry in walker {
731 let entry = entry?;
732 let path = entry.path();
733 if path.is_file() {
734 if path.extension().and_then(|s| s.to_str()) == Some("gemspec") {
735 gemspec_files.push(path.to_path_buf());
736 }
737 }
738 }
739
740 Ok(gemspec_files)
741}
742
743pub fn parse_all_ruby_projects(root: &std::path::Path) -> Result<Vec<RubyProject>> {
745 let gemspec_files = find_all_gemspec_files(root)?;
746 let mut projects = Vec::new();
747 let root_abs = root.canonicalize()?;
748
749 for gemspec_path in &gemspec_files {
750 if let Some(project_dir) = gemspec_path.parent() {
751 if let Some(gem_name) = parse_gemspec_name(gemspec_path) {
752 let project_abs = project_dir.canonicalize()?;
753 let project_rel = project_abs.strip_prefix(&root_abs)
754 .unwrap_or(project_dir)
755 .to_string_lossy()
756 .to_string();
757
758 projects.push(RubyProject {
759 gem_name: gem_name.clone(),
760 project_root: project_rel,
761 abs_project_root: project_abs.to_string_lossy().to_string(),
762 });
763 }
764 }
765 }
766
767 Ok(projects)
768}
769
770pub fn find_ruby_gem_names(root: &std::path::Path) -> Vec<String> {
773 parse_all_ruby_projects(root)
774 .unwrap_or_default()
775 .into_iter()
776 .map(|p| p.gem_name)
777 .collect()
778}
779
780fn parse_gemspec_name(gemspec_path: &std::path::Path) -> Option<String> {
782 let content = std::fs::read_to_string(gemspec_path).ok()?;
783
784 for line in content.lines() {
785 let trimmed = line.trim();
786
787 if (trimmed.starts_with("s.name") || trimmed.starts_with("spec.name"))
790 && trimmed.contains('=')
791 {
792 if let Some(equals_pos) = trimmed.find('=') {
794 let after_equals = &trimmed[equals_pos + 1..].trim();
795
796 for quote in ['"', '\''] {
798 if let Some(start) = after_equals.find(quote) {
799 if let Some(end) = after_equals[start + 1..].find(quote) {
800 let name = &after_equals[start + 1..start + 1 + end];
801 return Some(name.to_string());
802 }
803 }
804 }
805 }
806 }
807 }
808
809 None
810}
811
812fn gem_name_to_require_paths(gem_name: &str) -> Vec<String> {
815 let mut paths = Vec::new();
816
817 paths.push(gem_name.to_string());
819
820 if gem_name.contains('-') {
822 paths.push(gem_name.replace('-', "_"));
823 }
824
825 if gem_name.contains('_') {
827 paths.push(gem_name.replace('_', "-"));
828 }
829
830 paths
831}
832
833pub fn resolve_ruby_require_to_path(
836 require_path: &str,
837 projects: &[RubyProject],
838 current_file_path: Option<&str>,
839) -> Option<String> {
840 if require_path.starts_with("./") || require_path.starts_with("../") {
842 if let Some(current_file) = current_file_path {
843 if let Some(current_dir) = std::path::Path::new(current_file).parent() {
845 let resolved = current_dir.join(require_path);
846
847 let candidates = vec![
849 format!("{}.rb", resolved.display()),
850 resolved.display().to_string(),
851 ];
852
853 for candidate in candidates {
854 if let Ok(normalized) = std::path::Path::new(&candidate).canonicalize() {
856 return Some(normalized.display().to_string());
857 }
858 }
859 }
860 }
861 return None;
862 }
863
864 let first_component = require_path.split('/').next().unwrap_or(require_path);
867
868 for project in projects {
869 let gem_variants = gem_name_to_require_paths(&project.gem_name);
871
872 for variant in &gem_variants {
873 if first_component == variant {
874 let require_file_path = require_path.replace("::", "/");
876
877 let candidates = vec![
879 format!("{}/lib/{}.rb", project.project_root, require_file_path),
880 format!("{}/{}.rb", project.project_root, require_file_path),
881 ];
882
883 for candidate in candidates {
884 return Some(candidate);
885 }
886 }
887 }
888 }
889
890 None
891}
892
893pub fn reclassify_ruby_import(
896 import_path: &str,
897 gem_names: &[String],
898) -> ImportType {
899 if import_path.starts_with("./") || import_path.starts_with("../") {
901 return ImportType::Internal;
902 }
903
904 let first_component = import_path.split('/').next().unwrap_or(import_path);
906
907 for gem_name in gem_names {
909 for variant in gem_name_to_require_paths(gem_name) {
910 if first_component == variant {
911 return ImportType::Internal;
912 }
913 }
914 }
915
916 if is_ruby_stdlib(import_path) {
918 return ImportType::Stdlib;
919 }
920
921 ImportType::External
923}
924
925fn is_ruby_stdlib(path: &str) -> bool {
927 let stdlib_prefixes = [
928 "json", "csv", "yaml", "uri", "net/", "open-uri", "openssl",
929 "digest", "base64", "securerandom", "time", "date", "set",
930 "fileutils", "pathname", "tempfile", "logger", "benchmark",
931 "ostruct", "forwardable", "singleton", "observer", "delegate",
932 "abbrev", "cgi", "erb", "optparse", "shellwords", "stringio",
933 "strscan", "socket", "thread", "mutex_m", "monitor", "sync",
934 "timeout", "weakref", "English", "fiddle", "rbconfig",
935 ];
936
937 for prefix in &stdlib_prefixes {
938 if path == *prefix || path.starts_with(&format!("{}/", prefix)) {
939 return true;
940 }
941 }
942
943 false
944}
945
946fn classify_ruby_import(path: &str, method: &str) -> ImportType {
948 if method == "require_relative" {
950 return ImportType::Internal;
951 }
952
953 if is_ruby_stdlib(path) {
955 return ImportType::Stdlib;
956 }
957
958 if path.starts_with("./") || path.starts_with("../") {
960 return ImportType::Internal;
961 }
962
963 ImportType::External
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970
971 #[test]
972 fn test_parse_class() {
973 let source = r#"
974class User
975 attr_accessor :name, :email
976end
977 "#;
978
979 let symbols = parse("test.rb", source).unwrap();
980
981 let class_symbols: Vec<_> = symbols.iter()
982 .filter(|s| matches!(s.kind, SymbolKind::Class))
983 .collect();
984
985 assert_eq!(class_symbols.len(), 1);
986 assert_eq!(class_symbols[0].symbol.as_deref(), Some("User"));
987 }
988
989 #[test]
990 fn test_parse_module() {
991 let source = r#"
992module Authentication
993 def login
994 # implementation
995 end
996end
997 "#;
998
999 let symbols = parse("test.rb", source).unwrap();
1000
1001 let module_symbols: Vec<_> = symbols.iter()
1002 .filter(|s| matches!(s.kind, SymbolKind::Module))
1003 .collect();
1004
1005 assert_eq!(module_symbols.len(), 1);
1006 assert_eq!(module_symbols[0].symbol.as_deref(), Some("Authentication"));
1007 }
1008
1009 #[test]
1010 fn test_parse_methods() {
1011 let source = r#"
1012class Calculator
1013 def add(a, b)
1014 a + b
1015 end
1016
1017 def subtract(a, b)
1018 a - b
1019 end
1020end
1021 "#;
1022
1023 let symbols = parse("test.rb", source).unwrap();
1024
1025 let method_symbols: Vec<_> = symbols.iter()
1026 .filter(|s| matches!(s.kind, SymbolKind::Method))
1027 .collect();
1028
1029 assert_eq!(method_symbols.len(), 2);
1030 assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("add")));
1031 assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("subtract")));
1032
1033 for method in method_symbols {
1035 }
1037 }
1038
1039 #[test]
1040 fn test_parse_singleton_method() {
1041 let source = r#"
1042class User
1043 def self.create(attributes)
1044 new(attributes).save
1045 end
1046end
1047 "#;
1048
1049 let symbols = parse("test.rb", source).unwrap();
1050
1051 let method_symbols: Vec<_> = symbols.iter()
1052 .filter(|s| matches!(s.kind, SymbolKind::Method))
1053 .collect();
1054
1055 assert!(method_symbols.len() >= 1);
1056 assert!(method_symbols.iter().any(|s| s.symbol.as_deref().unwrap_or("").contains("create")));
1057 }
1058
1059 #[test]
1060 fn test_parse_constants() {
1061 let source = r#"
1062MAX_SIZE = 100
1063DEFAULT_TIMEOUT = 30
1064API_KEY = "secret123"
1065 "#;
1066
1067 let symbols = parse("test.rb", source).unwrap();
1068
1069 let const_symbols: Vec<_> = symbols.iter()
1070 .filter(|s| matches!(s.kind, SymbolKind::Constant))
1071 .collect();
1072
1073 assert_eq!(const_symbols.len(), 3);
1074 assert!(const_symbols.iter().any(|s| s.symbol.as_deref() == Some("MAX_SIZE")));
1075 assert!(const_symbols.iter().any(|s| s.symbol.as_deref() == Some("DEFAULT_TIMEOUT")));
1076 assert!(const_symbols.iter().any(|s| s.symbol.as_deref() == Some("API_KEY")));
1077 }
1078
1079 #[test]
1080 fn test_parse_nested_class() {
1081 let source = r#"
1082module MyApp
1083 class User
1084 def initialize(name)
1085 @name = name
1086 end
1087 end
1088end
1089 "#;
1090
1091 let symbols = parse("test.rb", source).unwrap();
1092
1093 let module_symbols: Vec<_> = symbols.iter()
1094 .filter(|s| matches!(s.kind, SymbolKind::Module))
1095 .collect();
1096
1097 let class_symbols: Vec<_> = symbols.iter()
1098 .filter(|s| matches!(s.kind, SymbolKind::Class))
1099 .collect();
1100
1101 assert_eq!(module_symbols.len(), 1);
1102 assert_eq!(class_symbols.len(), 1);
1103 assert_eq!(module_symbols[0].symbol.as_deref(), Some("MyApp"));
1104 assert_eq!(class_symbols[0].symbol.as_deref(), Some("User"));
1105 }
1106
1107 #[test]
1108 fn test_parse_rails_controller() {
1109 let source = r#"
1110class UsersController < ApplicationController
1111 before_action :authenticate_user!
1112
1113 def index
1114 @users = User.all
1115 end
1116
1117 def show
1118 @user = User.find(params[:id])
1119 end
1120
1121 def create
1122 @user = User.new(user_params)
1123 @user.save
1124 end
1125end
1126 "#;
1127
1128 let symbols = parse("test.rb", source).unwrap();
1129
1130 let class_symbols: Vec<_> = symbols.iter()
1131 .filter(|s| matches!(s.kind, SymbolKind::Class))
1132 .collect();
1133
1134 let method_symbols: Vec<_> = symbols.iter()
1135 .filter(|s| matches!(s.kind, SymbolKind::Method))
1136 .collect();
1137
1138 assert_eq!(class_symbols.len(), 1);
1139 assert_eq!(method_symbols.len(), 3);
1140 assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("index")));
1141 assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("show")));
1142 assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("create")));
1143 }
1144
1145 #[test]
1146 fn test_parse_mixed_symbols() {
1147 let source = r#"
1148MAX_RETRIES = 3
1149
1150module Authentication
1151 class Session
1152 def login(username, password)
1153 # implementation
1154 end
1155
1156 def self.destroy_all
1157 # implementation
1158 end
1159 end
1160end
1161 "#;
1162
1163 let symbols = parse("test.rb", source).unwrap();
1164
1165 assert!(symbols.len() >= 4);
1167
1168 let kinds: Vec<&SymbolKind> = symbols.iter().map(|s| &s.kind).collect();
1169 assert!(kinds.contains(&&SymbolKind::Constant));
1170 assert!(kinds.contains(&&SymbolKind::Module));
1171 assert!(kinds.contains(&&SymbolKind::Class));
1172 assert!(kinds.contains(&&SymbolKind::Method));
1173 }
1174
1175 #[test]
1176 fn test_local_variables_included() {
1177 let source = r#"
1178GLOBAL_CONSTANT = 100
1179
1180class Calculator
1181 def calculate(input)
1182 local_var = input * 2
1183 result = local_var + 10
1184 temp = result / 2
1185 temp
1186 end
1187
1188 def self.process(value)
1189 squared = value * value
1190 doubled = squared * 2
1191 doubled
1192 end
1193end
1194 "#;
1195
1196 let symbols = parse("test.rb", source).unwrap();
1197
1198 let variables: Vec<_> = symbols.iter()
1200 .filter(|s| matches!(s.kind, SymbolKind::Variable))
1201 .collect();
1202
1203 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("local_var")));
1205 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("result")));
1206 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("temp")));
1207 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("squared")));
1208 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("doubled")));
1209
1210 for var in variables {
1212 }
1214
1215 let var_names: Vec<_> = symbols.iter()
1217 .filter(|s| matches!(s.kind, SymbolKind::Variable))
1218 .filter_map(|s| s.symbol.as_deref())
1219 .collect();
1220 assert!(!var_names.contains(&"GLOBAL_CONSTANT"));
1221 }
1222
1223 #[test]
1224 fn test_instance_and_class_variables() {
1225 let source = r#"
1226class Counter
1227 @@total_count = 0
1228
1229 def initialize(name)
1230 @name = name
1231 @count = 0
1232 @@total_count += 1
1233 end
1234
1235 def increment
1236 @count += 1
1237 end
1238
1239 def self.get_total
1240 @@total_count
1241 end
1242end
1243 "#;
1244
1245 let symbols = parse("test.rb", source).unwrap();
1246
1247 let variables: Vec<_> = symbols.iter()
1249 .filter(|s| matches!(s.kind, SymbolKind::Variable))
1250 .collect();
1251
1252 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("@name")));
1254 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("@count")));
1255
1256 assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("@@total_count")));
1258 }
1259
1260 #[test]
1261 fn test_attr_accessors() {
1262 let source = r#"
1263class Person
1264 attr_reader :name, :age
1265 attr_writer :email
1266 attr_accessor :phone, :address
1267
1268 def initialize(name, age)
1269 @name = name
1270 @age = age
1271 end
1272end
1273 "#;
1274
1275 let symbols = parse("test.rb", source).unwrap();
1276
1277 let properties: Vec<_> = symbols.iter()
1279 .filter(|s| matches!(s.kind, SymbolKind::Property))
1280 .collect();
1281
1282 assert!(properties.iter().any(|p| p.symbol.as_deref() == Some("name")));
1284 assert!(properties.iter().any(|p| p.symbol.as_deref() == Some("age")));
1285 assert!(properties.iter().any(|p| p.symbol.as_deref() == Some("email")));
1286 assert!(properties.iter().any(|p| p.symbol.as_deref() == Some("phone")));
1287 assert!(properties.iter().any(|p| p.symbol.as_deref() == Some("address")));
1288
1289 assert_eq!(properties.len(), 5);
1290 }
1291
1292 #[test]
1293 fn test_extract_ruby_requires() {
1294 let source = r#"
1295 require 'json'
1296 require 'rails'
1297 require 'activerecord'
1298 require_relative '../models/user'
1299 require_relative './helpers/auth'
1300
1301 class UsersController
1302 def index
1303 # implementation
1304 end
1305 end
1306 "#;
1307
1308 let deps = RubyDependencyExtractor::extract_dependencies(source).unwrap();
1309
1310 assert_eq!(deps.len(), 5, "Should extract 5 require statements");
1311 assert!(deps.iter().any(|d| d.imported_path == "json"));
1312 assert!(deps.iter().any(|d| d.imported_path == "rails"));
1313 assert!(deps.iter().any(|d| d.imported_path == "activerecord"));
1314 assert!(deps.iter().any(|d| d.imported_path == "../models/user"));
1315 assert!(deps.iter().any(|d| d.imported_path == "./helpers/auth"));
1316
1317 let json_dep = deps.iter().find(|d| d.imported_path == "json").unwrap();
1319 assert!(matches!(json_dep.import_type, ImportType::Stdlib),
1320 "json should be classified as Stdlib");
1321
1322 let rails_dep = deps.iter().find(|d| d.imported_path == "rails").unwrap();
1324 assert!(matches!(rails_dep.import_type, ImportType::External),
1325 "rails should be classified as External");
1326
1327 let user_dep = deps.iter().find(|d| d.imported_path == "../models/user").unwrap();
1329 assert!(matches!(user_dep.import_type, ImportType::Internal),
1330 "require_relative should be classified as Internal");
1331 }
1332
1333 #[test]
1334 fn test_dynamic_requires_filtered() {
1335 let source = r##"
1336 require 'json'
1337 require 'rails'
1338 require_relative '../models/user'
1339
1340 # Dynamic requires - should be filtered out
1341 require variable
1342 require CONSTANT
1343 require File.join('path', 'to', 'file')
1344 require_relative File.dirname(__FILE__) + '/dynamic'
1345 load "#{Rails.root}/lib/dynamic.rb"
1346 "##;
1347
1348 let deps = RubyDependencyExtractor::extract_dependencies(source).unwrap();
1349
1350 assert_eq!(deps.len(), 3, "Should extract 3 static requires only");
1353
1354 assert!(deps.iter().any(|d| d.imported_path == "json"));
1355 assert!(deps.iter().any(|d| d.imported_path == "rails"));
1356 assert!(deps.iter().any(|d| d.imported_path == "../models/user"));
1357
1358 assert!(!deps.iter().any(|d| d.imported_path.contains("variable")));
1360 assert!(!deps.iter().any(|d| d.imported_path.contains("CONSTANT")));
1361 assert!(!deps.iter().any(|d| d.imported_path.contains("File")));
1362 assert!(!deps.iter().any(|d| d.imported_path.contains("Rails")));
1363 }
1364}
1365
1366#[cfg(test)]
1367mod monorepo_tests {
1368 use super::*;
1369
1370 #[test]
1371 fn test_resolve_ruby_require_lib_structure() {
1372 let projects = vec![
1373 RubyProject {
1374 gem_name: "activerecord".to_string(),
1375 project_root: "gems/activerecord".to_string(),
1376 abs_project_root: "/path/to/gems/activerecord".to_string(),
1377 },
1378 ];
1379
1380 let result = resolve_ruby_require_to_path(
1382 "activerecord/base",
1383 &projects,
1384 None,
1385 );
1386
1387 assert_eq!(result, Some("gems/activerecord/lib/activerecord/base.rb".to_string()));
1388 }
1389
1390 #[test]
1391 fn test_resolve_ruby_require_root_structure() {
1392 let projects = vec![
1393 RubyProject {
1394 gem_name: "my-gem".to_string(),
1395 project_root: "gems/my-gem".to_string(),
1396 abs_project_root: "/path/to/gems/my-gem".to_string(),
1397 },
1398 ];
1399
1400 let result = resolve_ruby_require_to_path(
1403 "my_gem/utils",
1404 &projects,
1405 None,
1406 );
1407
1408 assert_eq!(result, Some("gems/my-gem/lib/my_gem/utils.rb".to_string()));
1410 }
1411
1412 #[test]
1413 fn test_resolve_ruby_require_no_match() {
1414 let projects = vec![
1415 RubyProject {
1416 gem_name: "activerecord".to_string(),
1417 project_root: "gems/activerecord".to_string(),
1418 abs_project_root: "/path/to/gems/activerecord".to_string(),
1419 },
1420 ];
1421
1422 let result = resolve_ruby_require_to_path(
1424 "rails/application",
1425 &projects,
1426 None,
1427 );
1428
1429 assert_eq!(result, None);
1430 }
1431
1432 #[test]
1433 fn test_resolve_ruby_require_hyphen_underscore_conversion() {
1434 let projects = vec![
1435 RubyProject {
1436 gem_name: "active-record".to_string(),
1437 project_root: "gems/active-record".to_string(),
1438 abs_project_root: "/path/to/gems/active-record".to_string(),
1439 },
1440 ];
1441
1442 let result = resolve_ruby_require_to_path(
1444 "active_record/base",
1445 &projects,
1446 None,
1447 );
1448
1449 assert_eq!(result, Some("gems/active-record/lib/active_record/base.rb".to_string()));
1450 }
1451
1452 #[test]
1453 fn test_resolve_ruby_require_monorepo() {
1454 let projects = vec![
1455 RubyProject {
1456 gem_name: "activerecord".to_string(),
1457 project_root: "gems/activerecord".to_string(),
1458 abs_project_root: "/path/to/gems/activerecord".to_string(),
1459 },
1460 RubyProject {
1461 gem_name: "activesupport".to_string(),
1462 project_root: "gems/activesupport".to_string(),
1463 abs_project_root: "/path/to/gems/activesupport".to_string(),
1464 },
1465 RubyProject {
1466 gem_name: "actionpack".to_string(),
1467 project_root: "gems/actionpack".to_string(),
1468 abs_project_root: "/path/to/gems/actionpack".to_string(),
1469 },
1470 ];
1471
1472 let ar_result = resolve_ruby_require_to_path(
1474 "activerecord/base",
1475 &projects,
1476 None,
1477 );
1478 assert_eq!(ar_result, Some("gems/activerecord/lib/activerecord/base.rb".to_string()));
1479
1480 let as_result = resolve_ruby_require_to_path(
1481 "activesupport/core_ext",
1482 &projects,
1483 None,
1484 );
1485 assert_eq!(as_result, Some("gems/activesupport/lib/activesupport/core_ext.rb".to_string()));
1486
1487 let ap_result = resolve_ruby_require_to_path(
1488 "actionpack/controller",
1489 &projects,
1490 None,
1491 );
1492 assert_eq!(ap_result, Some("gems/actionpack/lib/actionpack/controller.rb".to_string()));
1493 }
1494}