reflex/parsers/
ruby.rs

1//! Ruby language parser using Tree-sitter
2//!
3//! Extracts symbols from Ruby source code:
4//! - Classes
5//! - Modules
6//! - Methods (instance and class methods)
7//! - Singleton methods
8//! - Constants
9//! - Local variables (inside methods)
10//! - Instance variables (@var)
11//! - Class variables (@@var)
12//! - Attr readers/writers/accessors (attr_reader, attr_writer, attr_accessor)
13//! - Blocks (lambda, proc)
14
15use 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
21/// Parse Ruby source code and extract symbols
22pub 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    // Extract different types of symbols using Tree-sitter queries
39    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    // Add file path to all symbols
50    for symbol in &mut symbols {
51        symbol.path = path.to_string();
52        symbol.lang = Language::Ruby;
53    }
54
55    Ok(symbols)
56}
57
58/// Extract module declarations
59fn 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
75/// Extract class declarations
76fn 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
92/// Extract method definitions
93fn 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                    // Find the parent method node
140                    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
174/// Extract singleton (class) methods
175fn 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
235/// Extract constants
236fn 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
253/// Extract local variables (inside methods)
254fn 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            // Check if this assignment is inside a method
291            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                // Stop at program/module/class level
300                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
326/// Extract instance variables (@variable)
327fn 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            // Only capture the first occurrence of each instance variable
350            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
372/// Extract class variables (@@variable)
373fn 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            // Only capture the first occurrence of each class variable
396            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
418/// Extract attr_accessor, attr_reader, attr_writer declarations
419fn 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                    // Remove leading : from symbol
455                    name = Some(symbol_text.trim_start_matches(':').to_string());
456
457                    // Find the parent call node
458                    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
490/// Generic symbol extraction helper
491fn 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        // Find the name capture and the full node
505        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                // Assume any other capture is the full node
514                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
537/// Convert a Tree-sitter node to a Span
538fn 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,  // Convert 0-indexed to 1-indexed
544        start.column,
545        end.row + 1,
546        end.column,
547    )
548}
549
550/// Extract a preview (7 lines) around the symbol
551fn extract_preview(source: &str, span: &Span) -> String {
552    let lines: Vec<&str> = source.lines().collect();
553
554    // Extract 7 lines: the start line and 6 following lines
555    let start_idx = (span.start_line - 1) as usize; // Convert back to 0-indexed
556    let end_idx = (start_idx + 7).min(lines.len());
557
558    lines[start_idx..end_idx].join("\n")
559}
560
561/// Ruby dependency extractor for require and require_relative statements
562pub 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        // Query for require and require_relative calls
580        // Match the entire call, then we'll inspect arguments manually to ensure they're static
581        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(); // Deduplicate by (path, line_number)
597
598        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                // Manual filter: only process require, require_relative, load
617                // (the #match? predicate in the query doesn't seem to work correctly)
618                if !matches!(method.as_str(), "require" | "require_relative" | "load") {
619                    continue;
620                }
621
622                // Manually inspect the argument_list's direct children
623                // STATIC ONLY: Only accept simple strings or symbols, reject complex expressions
624                let mut cursor = args.walk();
625                for child in args.children(&mut cursor) {
626                    match child.kind() {
627                        "string" => {
628                            // Check for interpolation (dynamic)
629                            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; // Skip interpolated strings
639                            }
640
641                            // Extract string_content
642                            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                                // Skip empty strings
653                                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                                // Deduplicate
661                                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                            // Remove leading ':'
679                            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                            // Deduplicate
687                            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                        // Ignore all other node types (identifiers, constants, calls, binary expressions, etc.)
702                        // These are dynamic requires and should be filtered out
703                        _ => {}
704                    }
705                }
706            }
707        }
708
709        Ok(imports)
710    }
711}
712
713/// Ruby project metadata for monorepo support
714#[derive(Debug, Clone)]
715pub struct RubyProject {
716    pub gem_name: String,           // Gem name from gemspec
717    pub project_root: String,       // Relative path to project root (gemspec directory)
718    pub abs_project_root: String,   // Absolute path to project root
719}
720
721/// Find all gemspec files in the project (no depth limit for monorepo support)
722pub 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
743/// Parse all Ruby projects from gemspec files
744pub 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
770/// Find all Ruby gem names from gemspec files in the project (legacy version)
771/// DEPRECATED: Use parse_all_ruby_projects() instead for monorepo support
772pub 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
780/// Parse a gemspec file to extract the gem name
781fn 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        // Match: s.name = "activerecord"
788        // Match: spec.name = "activerecord"
789        if (trimmed.starts_with("s.name") || trimmed.starts_with("spec.name"))
790            && trimmed.contains('=')
791        {
792            // Extract quoted value after =
793            if let Some(equals_pos) = trimmed.find('=') {
794                let after_equals = &trimmed[equals_pos + 1..].trim();
795
796                // Handle both "name" and 'name'
797                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
812/// Convert a gem name to all possible require path variants
813/// Handles hyphen/underscore conversions: "active-record" → ["active-record", "active_record"]
814fn gem_name_to_require_paths(gem_name: &str) -> Vec<String> {
815    let mut paths = Vec::new();
816
817    // 1. Exact match
818    paths.push(gem_name.to_string());
819
820    // 2. Convert hyphens to underscores
821    if gem_name.contains('-') {
822        paths.push(gem_name.replace('-', "_"));
823    }
824
825    // 3. Convert underscores to hyphens
826    if gem_name.contains('_') {
827        paths.push(gem_name.replace('_', "-"));
828    }
829
830    paths
831}
832
833/// Resolve a Ruby require path to a file path in the project
834/// Handles both gem-based requires and relative requires
835pub fn resolve_ruby_require_to_path(
836    require_path: &str,
837    projects: &[RubyProject],
838    current_file_path: Option<&str>,
839) -> Option<String> {
840    // Handle require_relative (relative to current file)
841    if require_path.starts_with("./") || require_path.starts_with("../") {
842        if let Some(current_file) = current_file_path {
843            // Get directory of current file
844            if let Some(current_dir) = std::path::Path::new(current_file).parent() {
845                let resolved = current_dir.join(require_path);
846
847                // Try with .rb extension
848                let candidates = vec![
849                    format!("{}.rb", resolved.display()),
850                    resolved.display().to_string(),
851                ];
852
853                for candidate in candidates {
854                    // Normalize path
855                    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    // Handle gem-based requires
865    // Extract first component: "active_record/base" → "active_record"
866    let first_component = require_path.split('/').next().unwrap_or(require_path);
867
868    for project in projects {
869        // Check if this require matches the gem name (or its variants)
870        let gem_variants = gem_name_to_require_paths(&project.gem_name);
871
872        for variant in &gem_variants {
873            if first_component == variant {
874                // Convert require path to file path: "active_record/base" → "lib/active_record/base.rb"
875                let require_file_path = require_path.replace("::", "/");
876
877                // Try common Ruby directory structures
878                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
893/// Reclassify a Ruby import using the project's gem names
894/// Similar to reclassify_go_import() and reclassify_java_import()
895pub fn reclassify_ruby_import(
896    import_path: &str,
897    gem_names: &[String],
898) -> ImportType {
899    // require_relative is always internal
900    if import_path.starts_with("./") || import_path.starts_with("../") {
901        return ImportType::Internal;
902    }
903
904    // Extract first component: "active_record/base" → "active_record"
905    let first_component = import_path.split('/').next().unwrap_or(import_path);
906
907    // Check if matches ANY gem name variant
908    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    // Check stdlib
917    if is_ruby_stdlib(import_path) {
918        return ImportType::Stdlib;
919    }
920
921    // Default to external
922    ImportType::External
923}
924
925/// Check if a require path is Ruby stdlib
926fn 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
946/// Classify Ruby imports into Internal/External/Stdlib (legacy version without gem names)
947fn classify_ruby_import(path: &str, method: &str) -> ImportType {
948    // require_relative is always internal (relative to current file)
949    if method == "require_relative" {
950        return ImportType::Internal;
951    }
952
953    // Check stdlib
954    if is_ruby_stdlib(path) {
955        return ImportType::Stdlib;
956    }
957
958    // If it starts with a relative path indicator, it's internal
959    if path.starts_with("./") || path.starts_with("../") {
960        return ImportType::Internal;
961    }
962
963    // Default to external for unknown gems
964    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        // Check scope
1034        for method in method_symbols {
1035            // Removed: scope field no longer exists: assert_eq!(method.scope.as_ref().unwrap(), "class Calculator");
1036        }
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        // Should find: constant, module, class, instance method, class method
1166        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        // Filter to just variables
1199        let variables: Vec<_> = symbols.iter()
1200            .filter(|s| matches!(s.kind, SymbolKind::Variable))
1201            .collect();
1202
1203        // Check that local variables are captured
1204        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        // Verify that local variables have no scope
1211        for var in variables {
1212            // Removed: scope field no longer exists: assert_eq!(var.scope, None);
1213        }
1214
1215        // Verify that GLOBAL_CONSTANT is not included as a variable
1216        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        // Filter to just variables
1248        let variables: Vec<_> = symbols.iter()
1249            .filter(|s| matches!(s.kind, SymbolKind::Variable))
1250            .collect();
1251
1252        // Check that instance variables are captured
1253        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        // Check that class variables are captured
1257        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        // Filter to properties
1278        let properties: Vec<_> = symbols.iter()
1279            .filter(|s| matches!(s.kind, SymbolKind::Property))
1280            .collect();
1281
1282        // Check that attr_* declarations are captured
1283        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        // Check stdlib classification
1318        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        // Check external classification
1323        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        // Check internal classification (require_relative)
1328        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        // Should only find static requires (json, rails, ../models/user)
1351        // Variable, constant, and expression-based requires are filtered (not (string) or (simple_symbol) nodes)
1352        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        // Verify dynamic requires are NOT captured
1359        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        // Test gem-based require with lib/ structure
1381        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        // Test gem-based require with root structure (no lib/)
1401        // Should return lib/ path first, but both candidates are generated
1402        let result = resolve_ruby_require_to_path(
1403            "my_gem/utils",
1404            &projects,
1405            None,
1406        );
1407
1408        // The resolver returns the first candidate (lib/ version)
1409        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        // Test require that doesn't match any gem
1423        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        // Test that hyphenated gem name matches underscored require
1443        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        // Test resolving to different gems
1473        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}