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