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) as usize; 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() {
813 if path.extension().and_then(|s| s.to_str()) == Some("gemspec") {
814 gemspec_files.push(path.to_path_buf());
815 }
816 }
817 }
818
819 Ok(gemspec_files)
820}
821
822pub fn parse_all_ruby_projects(root: &std::path::Path) -> Result<Vec<RubyProject>> {
824 let gemspec_files = find_all_gemspec_files(root)?;
825 let mut projects = Vec::new();
826 let root_abs = root.canonicalize()?;
827
828 for gemspec_path in &gemspec_files {
829 if let Some(project_dir) = gemspec_path.parent() {
830 if let Some(gem_name) = parse_gemspec_name(gemspec_path) {
831 let project_abs = project_dir.canonicalize()?;
832 let project_rel = project_abs
833 .strip_prefix(&root_abs)
834 .unwrap_or(project_dir)
835 .to_string_lossy()
836 .to_string();
837
838 projects.push(RubyProject {
839 gem_name: gem_name.clone(),
840 project_root: project_rel,
841 abs_project_root: project_abs.to_string_lossy().to_string(),
842 });
843 }
844 }
845 }
846
847 Ok(projects)
848}
849
850pub fn find_ruby_gem_names(root: &std::path::Path) -> Vec<String> {
853 parse_all_ruby_projects(root)
854 .unwrap_or_default()
855 .into_iter()
856 .map(|p| p.gem_name)
857 .collect()
858}
859
860fn parse_gemspec_name(gemspec_path: &std::path::Path) -> Option<String> {
862 let content = std::fs::read_to_string(gemspec_path).ok()?;
863
864 for line in content.lines() {
865 let trimmed = line.trim();
866
867 if (trimmed.starts_with("s.name") || trimmed.starts_with("spec.name"))
870 && trimmed.contains('=')
871 {
872 if let Some(equals_pos) = trimmed.find('=') {
874 let after_equals = &trimmed[equals_pos + 1..].trim();
875
876 for quote in ['"', '\''] {
878 if let Some(start) = after_equals.find(quote) {
879 if let Some(end) = after_equals[start + 1..].find(quote) {
880 let name = &after_equals[start + 1..start + 1 + end];
881 return Some(name.to_string());
882 }
883 }
884 }
885 }
886 }
887 }
888
889 None
890}
891
892fn gem_name_to_require_paths(gem_name: &str) -> Vec<String> {
895 let mut paths = Vec::new();
896
897 paths.push(gem_name.to_string());
899
900 if gem_name.contains('-') {
902 paths.push(gem_name.replace('-', "_"));
903 }
904
905 if gem_name.contains('_') {
907 paths.push(gem_name.replace('_', "-"));
908 }
909
910 paths
911}
912
913pub fn resolve_ruby_require_to_path(
916 require_path: &str,
917 projects: &[RubyProject],
918 current_file_path: Option<&str>,
919) -> Option<String> {
920 if require_path.starts_with("./") || require_path.starts_with("../") {
922 if let Some(current_file) = current_file_path {
923 if let Some(current_dir) = std::path::Path::new(current_file).parent() {
925 let resolved = current_dir.join(require_path);
926
927 let candidates = vec![
929 format!("{}.rb", resolved.display()),
930 resolved.display().to_string(),
931 ];
932
933 for candidate in candidates {
934 if let Ok(normalized) = std::path::Path::new(&candidate).canonicalize() {
936 return Some(normalized.display().to_string());
937 }
938 }
939 }
940 }
941 return None;
942 }
943
944 let first_component = require_path.split('/').next().unwrap_or(require_path);
947
948 for project in projects {
949 let gem_variants = gem_name_to_require_paths(&project.gem_name);
951
952 for variant in &gem_variants {
953 if first_component == variant {
954 let require_file_path = require_path.replace("::", "/");
956
957 let candidates = vec![
959 format!("{}/lib/{}.rb", project.project_root, require_file_path),
960 format!("{}/{}.rb", project.project_root, require_file_path),
961 ];
962
963 for candidate in candidates {
964 return Some(candidate);
965 }
966 }
967 }
968 }
969
970 None
971}
972
973pub fn reclassify_ruby_import(import_path: &str, gem_names: &[String]) -> ImportType {
976 if import_path.starts_with("./") || import_path.starts_with("../") {
978 return ImportType::Internal;
979 }
980
981 let first_component = import_path.split('/').next().unwrap_or(import_path);
983
984 for gem_name in gem_names {
986 for variant in gem_name_to_require_paths(gem_name) {
987 if first_component == variant {
988 return ImportType::Internal;
989 }
990 }
991 }
992
993 if is_ruby_stdlib(import_path) {
995 return ImportType::Stdlib;
996 }
997
998 ImportType::External
1000}
1001
1002fn is_ruby_stdlib(path: &str) -> bool {
1004 let stdlib_prefixes = [
1005 "json",
1006 "csv",
1007 "yaml",
1008 "uri",
1009 "net/",
1010 "open-uri",
1011 "openssl",
1012 "digest",
1013 "base64",
1014 "securerandom",
1015 "time",
1016 "date",
1017 "set",
1018 "fileutils",
1019 "pathname",
1020 "tempfile",
1021 "logger",
1022 "benchmark",
1023 "ostruct",
1024 "forwardable",
1025 "singleton",
1026 "observer",
1027 "delegate",
1028 "abbrev",
1029 "cgi",
1030 "erb",
1031 "optparse",
1032 "shellwords",
1033 "stringio",
1034 "strscan",
1035 "socket",
1036 "thread",
1037 "mutex_m",
1038 "monitor",
1039 "sync",
1040 "timeout",
1041 "weakref",
1042 "English",
1043 "fiddle",
1044 "rbconfig",
1045 ];
1046
1047 for prefix in &stdlib_prefixes {
1048 if path == *prefix || path.starts_with(&format!("{}/", prefix)) {
1049 return true;
1050 }
1051 }
1052
1053 false
1054}
1055
1056fn classify_ruby_import(path: &str, method: &str) -> ImportType {
1058 if method == "require_relative" {
1060 return ImportType::Internal;
1061 }
1062
1063 if is_ruby_stdlib(path) {
1065 return ImportType::Stdlib;
1066 }
1067
1068 if path.starts_with("./") || path.starts_with("../") {
1070 return ImportType::Internal;
1071 }
1072
1073 ImportType::External
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080
1081 #[test]
1082 fn test_parse_class() {
1083 let source = r#"
1084class User
1085 attr_accessor :name, :email
1086end
1087 "#;
1088
1089 let symbols = parse("test.rb", source).unwrap();
1090
1091 let class_symbols: Vec<_> = symbols
1092 .iter()
1093 .filter(|s| matches!(s.kind, SymbolKind::Class))
1094 .collect();
1095
1096 assert_eq!(class_symbols.len(), 1);
1097 assert_eq!(class_symbols[0].symbol.as_deref(), Some("User"));
1098 }
1099
1100 #[test]
1101 fn test_parse_module() {
1102 let source = r#"
1103module Authentication
1104 def login
1105 # implementation
1106 end
1107end
1108 "#;
1109
1110 let symbols = parse("test.rb", source).unwrap();
1111
1112 let module_symbols: Vec<_> = symbols
1113 .iter()
1114 .filter(|s| matches!(s.kind, SymbolKind::Module))
1115 .collect();
1116
1117 assert_eq!(module_symbols.len(), 1);
1118 assert_eq!(module_symbols[0].symbol.as_deref(), Some("Authentication"));
1119 }
1120
1121 #[test]
1122 fn test_parse_methods() {
1123 let source = r#"
1124class Calculator
1125 def add(a, b)
1126 a + b
1127 end
1128
1129 def subtract(a, b)
1130 a - b
1131 end
1132end
1133 "#;
1134
1135 let symbols = parse("test.rb", source).unwrap();
1136
1137 let method_symbols: Vec<_> = symbols
1138 .iter()
1139 .filter(|s| matches!(s.kind, SymbolKind::Method))
1140 .collect();
1141
1142 assert_eq!(method_symbols.len(), 2);
1143 assert!(
1144 method_symbols
1145 .iter()
1146 .any(|s| s.symbol.as_deref() == Some("add"))
1147 );
1148 assert!(
1149 method_symbols
1150 .iter()
1151 .any(|s| s.symbol.as_deref() == Some("subtract"))
1152 );
1153
1154 for method in method_symbols {
1156 }
1158 }
1159
1160 #[test]
1161 fn test_parse_singleton_method() {
1162 let source = r#"
1163class User
1164 def self.create(attributes)
1165 new(attributes).save
1166 end
1167end
1168 "#;
1169
1170 let symbols = parse("test.rb", source).unwrap();
1171
1172 let method_symbols: Vec<_> = symbols
1173 .iter()
1174 .filter(|s| matches!(s.kind, SymbolKind::Method))
1175 .collect();
1176
1177 assert!(method_symbols.len() >= 1);
1178 assert!(
1179 method_symbols
1180 .iter()
1181 .any(|s| s.symbol.as_deref().unwrap_or("").contains("create"))
1182 );
1183 }
1184
1185 #[test]
1186 fn test_parse_constants() {
1187 let source = r#"
1188MAX_SIZE = 100
1189DEFAULT_TIMEOUT = 30
1190API_KEY = "secret123"
1191 "#;
1192
1193 let symbols = parse("test.rb", source).unwrap();
1194
1195 let const_symbols: Vec<_> = symbols
1196 .iter()
1197 .filter(|s| matches!(s.kind, SymbolKind::Constant))
1198 .collect();
1199
1200 assert_eq!(const_symbols.len(), 3);
1201 assert!(
1202 const_symbols
1203 .iter()
1204 .any(|s| s.symbol.as_deref() == Some("MAX_SIZE"))
1205 );
1206 assert!(
1207 const_symbols
1208 .iter()
1209 .any(|s| s.symbol.as_deref() == Some("DEFAULT_TIMEOUT"))
1210 );
1211 assert!(
1212 const_symbols
1213 .iter()
1214 .any(|s| s.symbol.as_deref() == Some("API_KEY"))
1215 );
1216 }
1217
1218 #[test]
1219 fn test_parse_nested_class() {
1220 let source = r#"
1221module MyApp
1222 class User
1223 def initialize(name)
1224 @name = name
1225 end
1226 end
1227end
1228 "#;
1229
1230 let symbols = parse("test.rb", source).unwrap();
1231
1232 let module_symbols: Vec<_> = symbols
1233 .iter()
1234 .filter(|s| matches!(s.kind, SymbolKind::Module))
1235 .collect();
1236
1237 let class_symbols: Vec<_> = symbols
1238 .iter()
1239 .filter(|s| matches!(s.kind, SymbolKind::Class))
1240 .collect();
1241
1242 assert_eq!(module_symbols.len(), 1);
1243 assert_eq!(class_symbols.len(), 1);
1244 assert_eq!(module_symbols[0].symbol.as_deref(), Some("MyApp"));
1245 assert_eq!(class_symbols[0].symbol.as_deref(), Some("User"));
1246 }
1247
1248 #[test]
1249 fn test_parse_rails_controller() {
1250 let source = r#"
1251class UsersController < ApplicationController
1252 before_action :authenticate_user!
1253
1254 def index
1255 @users = User.all
1256 end
1257
1258 def show
1259 @user = User.find(params[:id])
1260 end
1261
1262 def create
1263 @user = User.new(user_params)
1264 @user.save
1265 end
1266end
1267 "#;
1268
1269 let symbols = parse("test.rb", source).unwrap();
1270
1271 let class_symbols: Vec<_> = symbols
1272 .iter()
1273 .filter(|s| matches!(s.kind, SymbolKind::Class))
1274 .collect();
1275
1276 let method_symbols: Vec<_> = symbols
1277 .iter()
1278 .filter(|s| matches!(s.kind, SymbolKind::Method))
1279 .collect();
1280
1281 assert_eq!(class_symbols.len(), 1);
1282 assert_eq!(method_symbols.len(), 3);
1283 assert!(
1284 method_symbols
1285 .iter()
1286 .any(|s| s.symbol.as_deref() == Some("index"))
1287 );
1288 assert!(
1289 method_symbols
1290 .iter()
1291 .any(|s| s.symbol.as_deref() == Some("show"))
1292 );
1293 assert!(
1294 method_symbols
1295 .iter()
1296 .any(|s| s.symbol.as_deref() == Some("create"))
1297 );
1298 }
1299
1300 #[test]
1301 fn test_parse_mixed_symbols() {
1302 let source = r#"
1303MAX_RETRIES = 3
1304
1305module Authentication
1306 class Session
1307 def login(username, password)
1308 # implementation
1309 end
1310
1311 def self.destroy_all
1312 # implementation
1313 end
1314 end
1315end
1316 "#;
1317
1318 let symbols = parse("test.rb", source).unwrap();
1319
1320 assert!(symbols.len() >= 4);
1322
1323 let kinds: Vec<&SymbolKind> = symbols.iter().map(|s| &s.kind).collect();
1324 assert!(kinds.contains(&&SymbolKind::Constant));
1325 assert!(kinds.contains(&&SymbolKind::Module));
1326 assert!(kinds.contains(&&SymbolKind::Class));
1327 assert!(kinds.contains(&&SymbolKind::Method));
1328 }
1329
1330 #[test]
1331 fn test_local_variables_included() {
1332 let source = r#"
1333GLOBAL_CONSTANT = 100
1334
1335class Calculator
1336 def calculate(input)
1337 local_var = input * 2
1338 result = local_var + 10
1339 temp = result / 2
1340 temp
1341 end
1342
1343 def self.process(value)
1344 squared = value * value
1345 doubled = squared * 2
1346 doubled
1347 end
1348end
1349 "#;
1350
1351 let symbols = parse("test.rb", source).unwrap();
1352
1353 let variables: Vec<_> = symbols
1355 .iter()
1356 .filter(|s| matches!(s.kind, SymbolKind::Variable))
1357 .collect();
1358
1359 assert!(
1361 variables
1362 .iter()
1363 .any(|v| v.symbol.as_deref() == Some("local_var"))
1364 );
1365 assert!(
1366 variables
1367 .iter()
1368 .any(|v| v.symbol.as_deref() == Some("result"))
1369 );
1370 assert!(
1371 variables
1372 .iter()
1373 .any(|v| v.symbol.as_deref() == Some("temp"))
1374 );
1375 assert!(
1376 variables
1377 .iter()
1378 .any(|v| v.symbol.as_deref() == Some("squared"))
1379 );
1380 assert!(
1381 variables
1382 .iter()
1383 .any(|v| v.symbol.as_deref() == Some("doubled"))
1384 );
1385
1386 for var in variables {
1388 }
1390
1391 let var_names: Vec<_> = symbols
1393 .iter()
1394 .filter(|s| matches!(s.kind, SymbolKind::Variable))
1395 .filter_map(|s| s.symbol.as_deref())
1396 .collect();
1397 assert!(!var_names.contains(&"GLOBAL_CONSTANT"));
1398 }
1399
1400 #[test]
1401 fn test_instance_and_class_variables() {
1402 let source = r#"
1403class Counter
1404 @@total_count = 0
1405
1406 def initialize(name)
1407 @name = name
1408 @count = 0
1409 @@total_count += 1
1410 end
1411
1412 def increment
1413 @count += 1
1414 end
1415
1416 def self.get_total
1417 @@total_count
1418 end
1419end
1420 "#;
1421
1422 let symbols = parse("test.rb", source).unwrap();
1423
1424 let variables: Vec<_> = symbols
1426 .iter()
1427 .filter(|s| matches!(s.kind, SymbolKind::Variable))
1428 .collect();
1429
1430 assert!(
1432 variables
1433 .iter()
1434 .any(|v| v.symbol.as_deref() == Some("@name"))
1435 );
1436 assert!(
1437 variables
1438 .iter()
1439 .any(|v| v.symbol.as_deref() == Some("@count"))
1440 );
1441
1442 assert!(
1444 variables
1445 .iter()
1446 .any(|v| v.symbol.as_deref() == Some("@@total_count"))
1447 );
1448 }
1449
1450 #[test]
1451 fn test_attr_accessors() {
1452 let source = r#"
1453class Person
1454 attr_reader :name, :age
1455 attr_writer :email
1456 attr_accessor :phone, :address
1457
1458 def initialize(name, age)
1459 @name = name
1460 @age = age
1461 end
1462end
1463 "#;
1464
1465 let symbols = parse("test.rb", source).unwrap();
1466
1467 let properties: Vec<_> = symbols
1469 .iter()
1470 .filter(|s| matches!(s.kind, SymbolKind::Property))
1471 .collect();
1472
1473 assert!(
1475 properties
1476 .iter()
1477 .any(|p| p.symbol.as_deref() == Some("name"))
1478 );
1479 assert!(
1480 properties
1481 .iter()
1482 .any(|p| p.symbol.as_deref() == Some("age"))
1483 );
1484 assert!(
1485 properties
1486 .iter()
1487 .any(|p| p.symbol.as_deref() == Some("email"))
1488 );
1489 assert!(
1490 properties
1491 .iter()
1492 .any(|p| p.symbol.as_deref() == Some("phone"))
1493 );
1494 assert!(
1495 properties
1496 .iter()
1497 .any(|p| p.symbol.as_deref() == Some("address"))
1498 );
1499
1500 assert_eq!(properties.len(), 5);
1501 }
1502
1503 #[test]
1504 fn test_extract_ruby_requires() {
1505 let source = r#"
1506 require 'json'
1507 require 'rails'
1508 require 'activerecord'
1509 require_relative '../models/user'
1510 require_relative './helpers/auth'
1511
1512 class UsersController
1513 def index
1514 # implementation
1515 end
1516 end
1517 "#;
1518
1519 let deps = RubyDependencyExtractor::extract_dependencies(source).unwrap();
1520
1521 assert_eq!(deps.len(), 5, "Should extract 5 require statements");
1522 assert!(deps.iter().any(|d| d.imported_path == "json"));
1523 assert!(deps.iter().any(|d| d.imported_path == "rails"));
1524 assert!(deps.iter().any(|d| d.imported_path == "activerecord"));
1525 assert!(deps.iter().any(|d| d.imported_path == "../models/user"));
1526 assert!(deps.iter().any(|d| d.imported_path == "./helpers/auth"));
1527
1528 let json_dep = deps.iter().find(|d| d.imported_path == "json").unwrap();
1530 assert!(
1531 matches!(json_dep.import_type, ImportType::Stdlib),
1532 "json should be classified as Stdlib"
1533 );
1534
1535 let rails_dep = deps.iter().find(|d| d.imported_path == "rails").unwrap();
1537 assert!(
1538 matches!(rails_dep.import_type, ImportType::External),
1539 "rails should be classified as External"
1540 );
1541
1542 let user_dep = deps
1544 .iter()
1545 .find(|d| d.imported_path == "../models/user")
1546 .unwrap();
1547 assert!(
1548 matches!(user_dep.import_type, ImportType::Internal),
1549 "require_relative should be classified as Internal"
1550 );
1551 }
1552
1553 #[test]
1554 fn test_dynamic_requires_filtered() {
1555 let source = r##"
1556 require 'json'
1557 require 'rails'
1558 require_relative '../models/user'
1559
1560 # Dynamic requires - should be filtered out
1561 require variable
1562 require CONSTANT
1563 require File.join('path', 'to', 'file')
1564 require_relative File.dirname(__FILE__) + '/dynamic'
1565 load "#{Rails.root}/lib/dynamic.rb"
1566 "##;
1567
1568 let deps = RubyDependencyExtractor::extract_dependencies(source).unwrap();
1569
1570 assert_eq!(deps.len(), 3, "Should extract 3 static requires only");
1573
1574 assert!(deps.iter().any(|d| d.imported_path == "json"));
1575 assert!(deps.iter().any(|d| d.imported_path == "rails"));
1576 assert!(deps.iter().any(|d| d.imported_path == "../models/user"));
1577
1578 assert!(!deps.iter().any(|d| d.imported_path.contains("variable")));
1580 assert!(!deps.iter().any(|d| d.imported_path.contains("CONSTANT")));
1581 assert!(!deps.iter().any(|d| d.imported_path.contains("File")));
1582 assert!(!deps.iter().any(|d| d.imported_path.contains("Rails")));
1583 }
1584}
1585
1586#[cfg(test)]
1587mod monorepo_tests {
1588 use super::*;
1589
1590 #[test]
1591 fn test_resolve_ruby_require_lib_structure() {
1592 let projects = vec![RubyProject {
1593 gem_name: "activerecord".to_string(),
1594 project_root: "gems/activerecord".to_string(),
1595 abs_project_root: "/path/to/gems/activerecord".to_string(),
1596 }];
1597
1598 let result = resolve_ruby_require_to_path("activerecord/base", &projects, None);
1600
1601 assert_eq!(
1602 result,
1603 Some("gems/activerecord/lib/activerecord/base.rb".to_string())
1604 );
1605 }
1606
1607 #[test]
1608 fn test_resolve_ruby_require_root_structure() {
1609 let projects = vec![RubyProject {
1610 gem_name: "my-gem".to_string(),
1611 project_root: "gems/my-gem".to_string(),
1612 abs_project_root: "/path/to/gems/my-gem".to_string(),
1613 }];
1614
1615 let result = resolve_ruby_require_to_path("my_gem/utils", &projects, None);
1618
1619 assert_eq!(result, Some("gems/my-gem/lib/my_gem/utils.rb".to_string()));
1621 }
1622
1623 #[test]
1624 fn test_resolve_ruby_require_no_match() {
1625 let projects = vec![RubyProject {
1626 gem_name: "activerecord".to_string(),
1627 project_root: "gems/activerecord".to_string(),
1628 abs_project_root: "/path/to/gems/activerecord".to_string(),
1629 }];
1630
1631 let result = resolve_ruby_require_to_path("rails/application", &projects, None);
1633
1634 assert_eq!(result, None);
1635 }
1636
1637 #[test]
1638 fn test_resolve_ruby_require_hyphen_underscore_conversion() {
1639 let projects = vec![RubyProject {
1640 gem_name: "active-record".to_string(),
1641 project_root: "gems/active-record".to_string(),
1642 abs_project_root: "/path/to/gems/active-record".to_string(),
1643 }];
1644
1645 let result = resolve_ruby_require_to_path("active_record/base", &projects, None);
1647
1648 assert_eq!(
1649 result,
1650 Some("gems/active-record/lib/active_record/base.rb".to_string())
1651 );
1652 }
1653
1654 #[test]
1655 fn test_resolve_ruby_require_monorepo() {
1656 let projects = vec![
1657 RubyProject {
1658 gem_name: "activerecord".to_string(),
1659 project_root: "gems/activerecord".to_string(),
1660 abs_project_root: "/path/to/gems/activerecord".to_string(),
1661 },
1662 RubyProject {
1663 gem_name: "activesupport".to_string(),
1664 project_root: "gems/activesupport".to_string(),
1665 abs_project_root: "/path/to/gems/activesupport".to_string(),
1666 },
1667 RubyProject {
1668 gem_name: "actionpack".to_string(),
1669 project_root: "gems/actionpack".to_string(),
1670 abs_project_root: "/path/to/gems/actionpack".to_string(),
1671 },
1672 ];
1673
1674 let ar_result = resolve_ruby_require_to_path("activerecord/base", &projects, None);
1676 assert_eq!(
1677 ar_result,
1678 Some("gems/activerecord/lib/activerecord/base.rb".to_string())
1679 );
1680
1681 let as_result = resolve_ruby_require_to_path("activesupport/core_ext", &projects, None);
1682 assert_eq!(
1683 as_result,
1684 Some("gems/activesupport/lib/activesupport/core_ext.rb".to_string())
1685 );
1686
1687 let ap_result = resolve_ruby_require_to_path("actionpack/controller", &projects, None);
1688 assert_eq!(
1689 ap_result,
1690 Some("gems/actionpack/lib/actionpack/controller.rb".to_string())
1691 );
1692 }
1693}