Skip to main content

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 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
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(
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    // Add file path to all symbols
70    for symbol in &mut symbols {
71        symbol.path = path.to_string();
72        symbol.lang = Language::Ruby;
73    }
74
75    Ok(symbols)
76}
77
78/// Extract module declarations
79fn 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
94/// Extract class declarations
95fn 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
110/// Extract method definitions
111fn 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                    // Find the parent method node
175                    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
210/// Extract singleton (class) methods
211fn 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
285/// Extract constants
286fn 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
302/// Extract local variables (inside methods)
303fn 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            // Check if this assignment is inside a method
345            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                // Stop at program/module/class level
354                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
383/// Extract instance variables (@variable)
384fn 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            // Only capture the first occurrence of each instance variable
407            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
429/// Extract class variables (@@variable)
430fn 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            // Only capture the first occurrence of each class variable
452            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
474/// Extract attr_accessor, attr_reader, attr_writer declarations
475fn 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                    // Remove leading : from symbol
516                    name = Some(symbol_text.trim_start_matches(':').to_string());
517
518                    // Find the parent call node
519                    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
551/// Generic symbol extraction helper
552fn 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        // Find the name capture and the full node
566        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                // Assume any other capture is the full node
581                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
604/// Convert a Tree-sitter node to a Span
605fn 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, // Convert 0-indexed to 1-indexed
611        start.column,
612        end.row + 1,
613        end.column,
614    )
615}
616
617/// Extract a preview (7 lines) around the symbol
618fn extract_preview(source: &str, span: &Span) -> String {
619    let lines: Vec<&str> = source.lines().collect();
620
621    // Extract 7 lines: the start line and 6 following lines
622    let start_idx = span.start_line - 1; // Convert back to 0-indexed
623    let end_idx = (start_idx + 7).min(lines.len());
624
625    lines[start_idx..end_idx].join("\n")
626}
627
628/// Ruby dependency extractor for require and require_relative statements
629pub 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        // Query for require and require_relative calls
647        // Match the entire call, then we'll inspect arguments manually to ensure they're static
648        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(); // Deduplicate by (path, line_number)
664
665        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                // Manual filter: only process require, require_relative, load
690                // (the #match? predicate in the query doesn't seem to work correctly)
691                if !matches!(method.as_str(), "require" | "require_relative" | "load") {
692                    continue;
693                }
694
695                // Manually inspect the argument_list's direct children
696                // STATIC ONLY: Only accept simple strings or symbols, reject complex expressions
697                let mut cursor = args.walk();
698                for child in args.children(&mut cursor) {
699                    match child.kind() {
700                        "string" => {
701                            // Check for interpolation (dynamic)
702                            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; // Skip interpolated strings
712                            }
713
714                            // Extract string_content
715                            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                                // Skip empty strings
731                                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                                // Deduplicate
739                                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                            // Remove leading ':'
758                            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                            // Deduplicate
766                            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                        // Ignore all other node types (identifiers, constants, calls, binary expressions, etc.)
781                        // These are dynamic requires and should be filtered out
782                        _ => {}
783                    }
784                }
785            }
786        }
787
788        Ok(imports)
789    }
790}
791
792/// Ruby project metadata for monorepo support
793#[derive(Debug, Clone)]
794pub struct RubyProject {
795    pub gem_name: String,         // Gem name from gemspec
796    pub project_root: String,     // Relative path to project root (gemspec directory)
797    pub abs_project_root: String, // Absolute path to project root
798}
799
800/// Find all gemspec files in the project (no depth limit for monorepo support)
801pub 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
820/// Parse all Ruby projects from gemspec files
821pub 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
848/// Find all Ruby gem names from gemspec files in the project (legacy version)
849/// DEPRECATED: Use parse_all_ruby_projects() instead for monorepo support
850pub 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
858/// Parse a gemspec file to extract the gem name
859fn 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        // Match: s.name = "activerecord"
866        // Match: spec.name = "activerecord"
867        if (trimmed.starts_with("s.name") || trimmed.starts_with("spec.name"))
868            && trimmed.contains('=')
869        {
870            // Extract quoted value after =
871            if let Some(equals_pos) = trimmed.find('=') {
872                let after_equals = &trimmed[equals_pos + 1..].trim();
873
874                // Handle both "name" and 'name'
875                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
890/// Convert a gem name to all possible require path variants
891/// Handles hyphen/underscore conversions: "active-record" → ["active-record", "active_record"]
892fn gem_name_to_require_paths(gem_name: &str) -> Vec<String> {
893    let mut paths = Vec::new();
894
895    // 1. Exact match
896    paths.push(gem_name.to_string());
897
898    // 2. Convert hyphens to underscores
899    if gem_name.contains('-') {
900        paths.push(gem_name.replace('-', "_"));
901    }
902
903    // 3. Convert underscores to hyphens
904    if gem_name.contains('_') {
905        paths.push(gem_name.replace('_', "-"));
906    }
907
908    paths
909}
910
911/// Resolve a Ruby require path to a file path in the project
912/// Handles both gem-based requires and relative requires
913pub fn resolve_ruby_require_to_path(
914    require_path: &str,
915    projects: &[RubyProject],
916    current_file_path: Option<&str>,
917) -> Option<String> {
918    // Handle require_relative (relative to current file)
919    if require_path.starts_with("./") || require_path.starts_with("../") {
920        if let Some(current_file) = current_file_path {
921            // Get directory of current file
922            if let Some(current_dir) = std::path::Path::new(current_file).parent() {
923                let resolved = current_dir.join(require_path);
924
925                // Try with .rb extension
926                let candidates = vec![
927                    format!("{}.rb", resolved.display()),
928                    resolved.display().to_string(),
929                ];
930
931                for candidate in candidates {
932                    // Normalize path
933                    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    // Handle gem-based requires
943    // Extract first component: "active_record/base" → "active_record"
944    let first_component = require_path.split('/').next().unwrap_or(require_path);
945
946    for project in projects {
947        // Check if this require matches the gem name (or its variants)
948        let gem_variants = gem_name_to_require_paths(&project.gem_name);
949
950        for variant in &gem_variants {
951            if first_component == variant {
952                // Convert require path to file path: "active_record/base" → "lib/active_record/base.rb"
953                let require_file_path = require_path.replace("::", "/");
954
955                // Try common Ruby directory structures
956                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
971/// Reclassify a Ruby import using the project's gem names
972/// Similar to reclassify_go_import() and reclassify_java_import()
973pub fn reclassify_ruby_import(import_path: &str, gem_names: &[String]) -> ImportType {
974    // require_relative is always internal
975    if import_path.starts_with("./") || import_path.starts_with("../") {
976        return ImportType::Internal;
977    }
978
979    // Extract first component: "active_record/base" → "active_record"
980    let first_component = import_path.split('/').next().unwrap_or(import_path);
981
982    // Check if matches ANY gem name variant
983    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    // Check stdlib
992    if is_ruby_stdlib(import_path) {
993        return ImportType::Stdlib;
994    }
995
996    // Default to external
997    ImportType::External
998}
999
1000/// Check if a require path is Ruby stdlib
1001fn 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
1054/// Classify Ruby imports into Internal/External/Stdlib (legacy version without gem names)
1055fn classify_ruby_import(path: &str, method: &str) -> ImportType {
1056    // require_relative is always internal (relative to current file)
1057    if method == "require_relative" {
1058        return ImportType::Internal;
1059    }
1060
1061    // Check stdlib
1062    if is_ruby_stdlib(path) {
1063        return ImportType::Stdlib;
1064    }
1065
1066    // If it starts with a relative path indicator, it's internal
1067    if path.starts_with("./") || path.starts_with("../") {
1068        return ImportType::Internal;
1069    }
1070
1071    // Default to external for unknown gems
1072    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        // Check scope
1153        for _method in method_symbols {
1154            // Removed: scope field no longer exists: assert_eq!(method.scope.as_ref().unwrap(), "class Calculator");
1155        }
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        // Should find: constant, module, class, instance method, class method
1319        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        // Filter to just variables
1352        let variables: Vec<_> = symbols
1353            .iter()
1354            .filter(|s| matches!(s.kind, SymbolKind::Variable))
1355            .collect();
1356
1357        // Check that local variables are captured
1358        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        // Verify that local variables have no scope
1385        for _var in variables {
1386            // Removed: scope field no longer exists: assert_eq!(var.scope, None);
1387        }
1388
1389        // Verify that GLOBAL_CONSTANT is not included as a variable
1390        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        // Filter to just variables
1423        let variables: Vec<_> = symbols
1424            .iter()
1425            .filter(|s| matches!(s.kind, SymbolKind::Variable))
1426            .collect();
1427
1428        // Check that instance variables are captured
1429        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        // Check that class variables are captured
1441        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        // Filter to properties
1466        let properties: Vec<_> = symbols
1467            .iter()
1468            .filter(|s| matches!(s.kind, SymbolKind::Property))
1469            .collect();
1470
1471        // Check that attr_* declarations are captured
1472        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        // Check stdlib classification
1527        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        // Check external classification
1534        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        // Check internal classification (require_relative)
1541        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        // Should only find static requires (json, rails, ../models/user)
1569        // Variable, constant, and expression-based requires are filtered (not (string) or (simple_symbol) nodes)
1570        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        // Verify dynamic requires are NOT captured
1577        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        // Test gem-based require with lib/ structure
1597        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        // Test gem-based require with root structure (no lib/)
1614        // Should return lib/ path first, but both candidates are generated
1615        let result = resolve_ruby_require_to_path("my_gem/utils", &projects, None);
1616
1617        // The resolver returns the first candidate (lib/ version)
1618        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        // Test require that doesn't match any gem
1630        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        // Test that hyphenated gem name matches underscored require
1644        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        // Test resolving to different gems
1673        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}