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