Skip to main content

reflex/parsers/
typescript.rs

1//! TypeScript/JavaScript language parser using Tree-sitter
2//!
3//! Extracts symbols from TypeScript and JavaScript source code:
4//! - Functions (regular, arrow, async, generator)
5//! - Classes (regular, abstract)
6//! - Interfaces
7//! - Type aliases
8//! - Enums
9//! - Variables and constants (const, let, var - all scopes)
10//! - Methods (with class scope)
11//! - Modules/Namespaces
12//!
13//! This parser handles both TypeScript (.ts, .tsx) and JavaScript (.js, .jsx)
14//! files using the tree-sitter-typescript grammar.
15
16use anyhow::{Context, Result};
17use streaming_iterator::StreamingIterator;
18use tree_sitter::{Parser, Query, QueryCursor};
19use crate::models::{Language, SearchResult, Span, SymbolKind};
20
21/// Parse TypeScript/JavaScript source code and extract symbols
22pub fn parse(path: &str, source: &str, language: Language) -> Result<Vec<SearchResult>> {
23    let mut parser = Parser::new();
24
25    // tree-sitter-typescript provides both TypeScript and TSX grammars
26    // For JavaScript, we use the TypeScript grammar (it's a superset)
27    let ts_language_fn = match language {
28        Language::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
29        Language::JavaScript => tree_sitter_typescript::LANGUAGE_TSX, // TSX handles both JS and JSX
30        _ => return Err(anyhow::anyhow!("Unsupported language: {:?}", language)),
31    };
32
33    // Convert LanguageFn to Language
34    let ts_language: tree_sitter::Language = ts_language_fn.into();
35
36    parser
37        .set_language(&ts_language)
38        .context("Failed to set TypeScript/JavaScript language")?;
39
40    let tree = parser
41        .parse(source, None)
42        .context("Failed to parse TypeScript/JavaScript source")?;
43
44    let root_node = tree.root_node();
45
46    let mut symbols = Vec::new();
47
48    // Extract different types of symbols using Tree-sitter queries
49    symbols.extend(extract_functions(source, &root_node, &ts_language)?);
50    symbols.extend(extract_arrow_functions(source, &root_node, &ts_language)?);
51    symbols.extend(extract_classes(source, &root_node, &ts_language)?);
52    symbols.extend(extract_interfaces(source, &root_node, &ts_language)?);
53    symbols.extend(extract_type_aliases(source, &root_node, &ts_language)?);
54    symbols.extend(extract_enums(source, &root_node, &ts_language)?);
55    symbols.extend(extract_variables(source, &root_node, &ts_language)?);
56    symbols.extend(extract_methods(source, &root_node, &ts_language)?);
57
58    // Add file path and language to all symbols
59    for symbol in &mut symbols {
60        symbol.path = path.to_string();
61        symbol.lang = language.clone();
62    }
63
64    Ok(symbols)
65}
66
67/// Extract regular function declarations (including async and generator)
68fn extract_functions(
69    source: &str,
70    root: &tree_sitter::Node,
71    language: &tree_sitter::Language,
72) -> Result<Vec<SearchResult>> {
73    let query_str = r#"
74        (function_declaration
75            name: (identifier) @name) @function
76
77        (generator_function_declaration
78            name: (identifier) @name) @function
79    "#;
80
81    let query = Query::new(language, query_str)
82        .context("Failed to create function query")?;
83
84    extract_symbols(source, root, &query, SymbolKind::Function, None)
85}
86
87/// Extract arrow functions assigned to variables/constants
88fn extract_arrow_functions(
89    source: &str,
90    root: &tree_sitter::Node,
91    language: &tree_sitter::Language,
92) -> Result<Vec<SearchResult>> {
93    let query_str = r#"
94        (lexical_declaration
95            (variable_declarator
96                name: (identifier) @name
97                value: (arrow_function))) @arrow_fn
98
99        (variable_declaration
100            (variable_declarator
101                name: (identifier) @name
102                value: (arrow_function))) @arrow_fn
103    "#;
104
105    let query = Query::new(language, query_str)
106        .context("Failed to create arrow function query")?;
107
108    extract_symbols(source, root, &query, SymbolKind::Function, None)
109}
110
111/// Extract class declarations (including abstract classes)
112fn extract_classes(
113    source: &str,
114    root: &tree_sitter::Node,
115    language: &tree_sitter::Language,
116) -> Result<Vec<SearchResult>> {
117    let query_str = r#"
118        (class_declaration
119            name: (type_identifier) @name) @class
120
121        (abstract_class_declaration
122            name: (type_identifier) @name) @class
123    "#;
124
125    let query = Query::new(language, query_str)
126        .context("Failed to create class query")?;
127
128    extract_symbols(source, root, &query, SymbolKind::Class, None)
129}
130
131/// Extract interface declarations
132fn extract_interfaces(
133    source: &str,
134    root: &tree_sitter::Node,
135    language: &tree_sitter::Language,
136) -> Result<Vec<SearchResult>> {
137    let query_str = r#"
138        (interface_declaration
139            name: (type_identifier) @name) @interface
140    "#;
141
142    let query = Query::new(language, query_str)
143        .context("Failed to create interface query")?;
144
145    extract_symbols(source, root, &query, SymbolKind::Interface, None)
146}
147
148/// Extract type alias declarations
149fn extract_type_aliases(
150    source: &str,
151    root: &tree_sitter::Node,
152    language: &tree_sitter::Language,
153) -> Result<Vec<SearchResult>> {
154    let query_str = r#"
155        (type_alias_declaration
156            name: (type_identifier) @name) @type
157    "#;
158
159    let query = Query::new(language, query_str)
160        .context("Failed to create type alias query")?;
161
162    extract_symbols(source, root, &query, SymbolKind::Type, None)
163}
164
165/// Extract enum declarations
166fn extract_enums(
167    source: &str,
168    root: &tree_sitter::Node,
169    language: &tree_sitter::Language,
170) -> Result<Vec<SearchResult>> {
171    let query_str = r#"
172        (enum_declaration
173            name: (identifier) @name) @enum
174    "#;
175
176    let query = Query::new(language, query_str)
177        .context("Failed to create enum query")?;
178
179    extract_symbols(source, root, &query, SymbolKind::Enum, None)
180}
181
182/// Extract variable and constant declarations (const, let, var - all scopes)
183fn extract_variables(
184    source: &str,
185    root: &tree_sitter::Node,
186    language: &tree_sitter::Language,
187) -> Result<Vec<SearchResult>> {
188    // Extract const/let (lexical_declaration) and var (variable_declaration)
189    // Skip arrow functions as they're already handled by extract_arrow_functions
190    let query_str = r#"
191        (lexical_declaration
192            (variable_declarator
193                name: (identifier) @name)) @decl
194
195        (variable_declaration
196            (variable_declarator
197                name: (identifier) @name)) @decl
198    "#;
199
200    let query = Query::new(language, query_str)
201        .context("Failed to create variable query")?;
202
203    let mut cursor = QueryCursor::new();
204    let mut matches = cursor.matches(&query, *root, source.as_bytes());
205
206    let mut symbols = Vec::new();
207
208    while let Some(match_) = matches.next() {
209        let mut name = None;
210        let mut declarator_node = None;
211
212        for capture in match_.captures {
213            let capture_name: &str = &query.capture_names()[capture.index as usize];
214            if capture_name == "name" {
215                name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
216                // Get the variable_declarator node
217                if let Some(parent) = capture.node.parent() {
218                    if parent.kind() == "variable_declarator" {
219                        declarator_node = Some(parent);
220                    }
221                }
222            }
223        }
224
225        if let (Some(name), Some(declarator)) = (name, declarator_node) {
226            // Check if the value is an arrow function (skip those)
227            let mut is_arrow_function = false;
228            for i in 0..declarator.child_count() {
229                if let Some(child) = declarator.child(i) {
230                    if child.kind() == "arrow_function" {
231                        is_arrow_function = true;
232                        break;
233                    }
234                }
235            }
236
237            // Only add if it's NOT an arrow function
238            if !is_arrow_function {
239                if let Some(decl_node) = declarator.parent() {
240                    let span = node_to_span(&decl_node);
241                    let preview = extract_preview(source, &span);
242
243                    // Determine if it's a constant (const) or variable (let/var)
244                    let decl_text = decl_node.utf8_text(source.as_bytes()).unwrap_or("");
245                    let kind = if decl_text.trim_start().starts_with("const") {
246                        SymbolKind::Constant
247                    } else {
248                        SymbolKind::Variable
249                    };
250
251                    symbols.push(SearchResult::new(
252                        String::new(),
253                        Language::TypeScript,
254                        kind,
255                        Some(name),
256                        span,
257                        None,
258                        preview,
259                    ));
260                }
261            }
262        }
263    }
264
265    Ok(symbols)
266}
267
268/// Extract method definitions from classes
269fn extract_methods(
270    source: &str,
271    root: &tree_sitter::Node,
272    language: &tree_sitter::Language,
273) -> Result<Vec<SearchResult>> {
274    let query_str = r#"
275        (class_declaration
276            name: (type_identifier) @class_name
277            body: (class_body
278                (method_definition
279                    name: (_) @method_name))) @class
280
281        (abstract_class_declaration
282            name: (type_identifier) @class_name
283            body: (class_body
284                (method_definition
285                    name: (_) @method_name))) @class
286    "#;
287
288    let query = Query::new(language, query_str)
289        .context("Failed to create method query")?;
290
291    let mut cursor = QueryCursor::new();
292    let mut matches = cursor.matches(&query, *root, source.as_bytes());
293
294    let mut symbols = Vec::new();
295
296    while let Some(match_) = matches.next() {
297        let mut class_name = None;
298        let mut method_name = None;
299        let mut method_node = None;
300
301        for capture in match_.captures {
302            let capture_name: &str = &query.capture_names()[capture.index as usize];
303            match capture_name {
304                "class_name" => {
305                    class_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
306                }
307                "method_name" => {
308                    method_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
309                    // Find the parent method_definition node
310                    let mut current = capture.node;
311                    while let Some(parent) = current.parent() {
312                        if parent.kind() == "method_definition" {
313                            method_node = Some(parent);
314                            break;
315                        }
316                        current = parent;
317                    }
318                }
319                _ => {}
320            }
321        }
322
323        if let (Some(class_name), Some(method_name), Some(node)) = (class_name, method_name, method_node) {
324            let scope = format!("class {}", class_name);
325            let span = node_to_span(&node);
326            let preview = extract_preview(source, &span);
327
328            symbols.push(SearchResult::new(
329                String::new(),
330                Language::TypeScript,
331                SymbolKind::Method,
332                Some(method_name),
333                span,
334                Some(scope),
335                preview,
336            ));
337        }
338    }
339
340    Ok(symbols)
341}
342
343/// Generic symbol extraction helper
344fn extract_symbols(
345    source: &str,
346    root: &tree_sitter::Node,
347    query: &Query,
348    kind: SymbolKind,
349    scope: Option<String>,
350) -> Result<Vec<SearchResult>> {
351    let mut cursor = QueryCursor::new();
352    let mut matches = cursor.matches(query, *root, source.as_bytes());
353
354    let mut symbols = Vec::new();
355
356    while let Some(match_) = matches.next() {
357        // Find the name capture and the full node
358        let mut name = None;
359        let mut full_node = None;
360
361        for capture in match_.captures {
362            let capture_name: &str = &query.capture_names()[capture.index as usize];
363            if capture_name == "name" {
364                name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
365            } else {
366                // Assume any other capture is the full node
367                full_node = Some(capture.node);
368            }
369        }
370
371        if let (Some(name), Some(node)) = (name, full_node) {
372            let span = node_to_span(&node);
373            let preview = extract_preview(source, &span);
374
375            symbols.push(SearchResult::new(
376                String::new(),
377                Language::TypeScript,
378                kind.clone(),
379                Some(name),
380                span,
381                scope.clone(),
382                preview,
383            ));
384        }
385    }
386
387    Ok(symbols)
388}
389
390/// Convert a Tree-sitter node to a Span
391fn node_to_span(node: &tree_sitter::Node) -> Span {
392    let start = node.start_position();
393    let end = node.end_position();
394
395    Span::new(
396        start.row + 1,  // Convert 0-indexed to 1-indexed
397        start.column,
398        end.row + 1,
399        end.column,
400    )
401}
402
403/// Extract a preview (7 lines) around the symbol
404fn extract_preview(source: &str, span: &Span) -> String {
405    let lines: Vec<&str> = source.lines().collect();
406
407    // Extract 7 lines: the start line and 6 following lines
408    let start_idx = (span.start_line - 1) as usize; // Convert back to 0-indexed
409    let end_idx = (start_idx + 7).min(lines.len());
410
411    lines[start_idx..end_idx].join("\n")
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn test_parse_function() {
420        let source = r#"
421            function greet(name: string): string {
422                return `Hello, ${name}!`;
423            }
424        "#;
425
426        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
427        assert_eq!(symbols.len(), 1);
428        assert_eq!(symbols[0].symbol.as_deref(), Some("greet"));
429        assert!(matches!(symbols[0].kind, SymbolKind::Function));
430    }
431
432    #[test]
433    fn test_parse_arrow_function() {
434        let source = r#"
435            const add = (a: number, b: number): number => {
436                return a + b;
437            };
438        "#;
439
440        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
441        assert_eq!(symbols.len(), 1);
442        assert_eq!(symbols[0].symbol.as_deref(), Some("add"));
443        assert!(matches!(symbols[0].kind, SymbolKind::Function));
444    }
445
446    #[test]
447    fn test_parse_async_function() {
448        let source = r#"
449            async function fetchData(url: string): Promise<Response> {
450                return await fetch(url);
451            }
452        "#;
453
454        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
455        assert_eq!(symbols.len(), 1);
456        assert_eq!(symbols[0].symbol.as_deref(), Some("fetchData"));
457        assert!(matches!(symbols[0].kind, SymbolKind::Function));
458    }
459
460    #[test]
461    fn test_parse_class() {
462        let source = r#"
463            class User {
464                name: string;
465                age: number;
466
467                constructor(name: string, age: number) {
468                    this.name = name;
469                    this.age = age;
470                }
471            }
472        "#;
473
474        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
475
476        // Should find class
477        let class_symbols: Vec<_> = symbols.iter()
478            .filter(|s| matches!(s.kind, SymbolKind::Class))
479            .collect();
480
481        assert_eq!(class_symbols.len(), 1);
482        assert_eq!(class_symbols[0].symbol.as_deref(), Some("User"));
483    }
484
485    #[test]
486    fn test_parse_class_with_methods() {
487        let source = r#"
488            class Calculator {
489                add(a: number, b: number): number {
490                    return a + b;
491                }
492
493                subtract(a: number, b: number): number {
494                    return a - b;
495                }
496            }
497        "#;
498
499        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
500
501        // Should find class + 2 methods
502        assert!(symbols.len() >= 3);
503
504        let method_symbols: Vec<_> = symbols.iter()
505            .filter(|s| matches!(s.kind, SymbolKind::Method))
506            .collect();
507
508        assert_eq!(method_symbols.len(), 2);
509        assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("add")));
510        assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("subtract")));
511
512        // Check scope
513        for method in method_symbols {
514            // Removed: scope field no longer exists: assert_eq!(method.scope.as_ref().unwrap(), "class Calculator");
515        }
516    }
517
518    #[test]
519    fn test_parse_interface() {
520        let source = r#"
521            interface User {
522                name: string;
523                age: number;
524                email?: string;
525            }
526        "#;
527
528        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
529        assert_eq!(symbols.len(), 1);
530        assert_eq!(symbols[0].symbol.as_deref(), Some("User"));
531        assert!(matches!(symbols[0].kind, SymbolKind::Interface));
532    }
533
534    #[test]
535    fn test_parse_type_alias() {
536        let source = r#"
537            type UserId = string | number;
538            type UserRole = 'admin' | 'user' | 'guest';
539        "#;
540
541        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
542        assert_eq!(symbols.len(), 2);
543
544        let type_symbols: Vec<_> = symbols.iter()
545            .filter(|s| matches!(s.kind, SymbolKind::Type))
546            .collect();
547
548        assert_eq!(type_symbols.len(), 2);
549        assert!(type_symbols.iter().any(|s| s.symbol.as_deref() == Some("UserId")));
550        assert!(type_symbols.iter().any(|s| s.symbol.as_deref() == Some("UserRole")));
551    }
552
553    #[test]
554    fn test_parse_enum() {
555        let source = r#"
556            enum Status {
557                Active,
558                Inactive,
559                Pending
560            }
561        "#;
562
563        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
564        assert_eq!(symbols.len(), 1);
565        assert_eq!(symbols[0].symbol.as_deref(), Some("Status"));
566        assert!(matches!(symbols[0].kind, SymbolKind::Enum));
567    }
568
569    #[test]
570    fn test_parse_const() {
571        let source = r#"
572            const MAX_SIZE = 100;
573            const DEFAULT_USER = {
574                name: "Anonymous",
575                age: 0
576            };
577        "#;
578
579        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
580        assert_eq!(symbols.len(), 2);
581
582        let const_symbols: Vec<_> = symbols.iter()
583            .filter(|s| matches!(s.kind, SymbolKind::Constant))
584            .collect();
585
586        assert_eq!(const_symbols.len(), 2);
587        assert!(const_symbols.iter().any(|s| s.symbol.as_deref() == Some("MAX_SIZE")));
588        assert!(const_symbols.iter().any(|s| s.symbol.as_deref() == Some("DEFAULT_USER")));
589    }
590
591    #[test]
592    fn test_parse_react_component() {
593        let source = r#"
594            import React, { useState } from 'react';
595
596            interface ButtonProps {
597                label: string;
598                onClick: () => void;
599            }
600
601            const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
602                return (
603                    <button onClick={onClick}>
604                        {label}
605                    </button>
606                );
607            };
608
609            function useCounter(initial: number) {
610                const [count, setCount] = React.useState(initial);
611                return { count, setCount };
612            }
613
614            export default Button;
615        "#;
616
617        let symbols = parse("Button.tsx", source, Language::TypeScript).unwrap();
618
619        // Should find interface, Button component (arrow fn), useCounter hook (function)
620        assert!(symbols.iter().any(|s| s.symbol.as_deref() == Some("ButtonProps") && matches!(s.kind, SymbolKind::Interface)));
621        assert!(symbols.iter().any(|s| s.symbol.as_deref() == Some("Button") && matches!(s.kind, SymbolKind::Function)));
622        assert!(symbols.iter().any(|s| s.symbol.as_deref() == Some("useCounter") && matches!(s.kind, SymbolKind::Function)));
623    }
624
625    #[test]
626    fn test_parse_mixed_symbols() {
627        let source = r#"
628            interface Config {
629                debug: boolean;
630            }
631
632            type ConfigKey = keyof Config;
633
634            const DEFAULT_CONFIG: Config = {
635                debug: false
636            };
637
638            class ConfigManager {
639                private config: Config;
640
641                constructor(config: Config) {
642                    this.config = config;
643                }
644
645                getConfig(): Config {
646                    return this.config;
647                }
648            }
649
650            function loadConfig(): Config {
651                return DEFAULT_CONFIG;
652            }
653        "#;
654
655        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
656
657        // Should find: interface, type, const, class, method, function
658        assert!(symbols.len() >= 6);
659
660        let kinds: Vec<&SymbolKind> = symbols.iter().map(|s| &s.kind).collect();
661        assert!(kinds.contains(&&SymbolKind::Interface));
662        assert!(kinds.contains(&&SymbolKind::Type));
663        assert!(kinds.contains(&&SymbolKind::Constant));
664        assert!(kinds.contains(&&SymbolKind::Class));
665        assert!(kinds.contains(&&SymbolKind::Method));
666        assert!(kinds.contains(&&SymbolKind::Function));
667    }
668
669    #[test]
670    fn test_parse_async_class_methods() {
671        let source = r#"
672            export class CentralUsersModule {
673                async getAllUsers(params) {
674                    return await this.call('get', `/users`, params)
675                }
676
677                async getUser(userId) {
678                    return await this.call('get', `/users/${userId}`)
679                }
680
681                deleteUser(userId) {
682                    return this.call('delete', `/user/${userId}`)
683                }
684            }
685        "#;
686
687        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
688
689        // Debug: Print all symbols
690        println!("\nAll symbols found:");
691        for symbol in &symbols {
692            println!("  {:?} - {}", symbol.kind, symbol.symbol.as_deref().unwrap_or(""));
693        }
694
695        // Should find: class + 3 methods (2 async, 1 regular)
696        let class_symbols: Vec<_> = symbols.iter()
697            .filter(|s| matches!(s.kind, SymbolKind::Class))
698            .collect();
699        assert_eq!(class_symbols.len(), 1);
700        assert_eq!(class_symbols[0].symbol.as_deref(), Some("CentralUsersModule"));
701
702        let method_symbols: Vec<_> = symbols.iter()
703            .filter(|s| matches!(s.kind, SymbolKind::Method))
704            .collect();
705
706        // All three should be detected as methods, not variables
707        assert_eq!(method_symbols.len(), 3, "Expected 3 methods, found {}", method_symbols.len());
708        assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("getAllUsers")));
709        assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("getUser")));
710        assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("deleteUser")));
711
712        // Verify no async methods are misclassified as variables
713        let variable_symbols: Vec<_> = symbols.iter()
714            .filter(|s| matches!(s.kind, SymbolKind::Constant) || matches!(s.kind, SymbolKind::Variable))
715            .collect();
716        assert_eq!(variable_symbols.len(), 0, "Async methods should not be classified as variables");
717
718        // Check scope
719        for method in method_symbols {
720            // Removed: scope field no longer exists: assert_eq!(method.scope.as_ref().unwrap(), "class CentralUsersModule");
721        }
722    }
723
724    #[test]
725    fn test_parse_user_exact_code() {
726        // User's exact code with TypeScript types
727        let source = r#"
728export class CentralUsersModule extends HttpFactory<WatchHookMap, WatchEvents> {
729  protected $events = {
730    //
731  }
732
733  async checkAuthenticated() {
734    return await this.call('get', '/check')
735  }
736
737  async getUser(userId: CentralUser['id']) {
738    return await this.call<CentralUser>('get', `/users/${userId}`)
739  }
740
741  async getAllUsers(params?: PaginatedParams & SortableParams & SearchableParams) {
742    return await this.call<CentralUser[]>('get', `/users`, params)
743  }
744
745  async deleteUser(userId: CentralUser['id']) {
746    return await this.call<void>('delete', `/user/${userId}`)
747  }
748}
749        "#;
750
751        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
752
753        // Debug: Print all symbols
754        println!("\nAll symbols found in user code:");
755        for symbol in &symbols {
756            println!("  {:?} - {}", symbol.kind, symbol.symbol.as_deref().unwrap_or(""));
757        }
758
759        // Verify getAllUsers is a Method, not a Variable
760        let get_all_users_symbols: Vec<_> = symbols.iter()
761            .filter(|s| s.symbol.as_deref() == Some("getAllUsers"))
762            .collect();
763
764        assert_eq!(get_all_users_symbols.len(), 1, "Should find exactly one getAllUsers");
765        assert!(
766            matches!(get_all_users_symbols[0].kind, SymbolKind::Method),
767            "getAllUsers should be a Method, not {:?}",
768            get_all_users_symbols[0].kind
769        );
770    }
771
772    #[test]
773    fn test_local_variables_included() {
774        let source = r#"
775            const GLOBAL_CONSTANT = 100;
776            let globalLet = 50;
777            var globalVar = 25;
778
779            function calculate(x: number): number {
780                const localConst = x * 2;
781                let localLet = 5;
782                var localVar = 10;
783                return localConst + localLet + localVar;
784            }
785        "#;
786
787        let symbols = parse("test.ts", source, Language::TypeScript).unwrap();
788
789        let var_symbols: Vec<_> = symbols.iter()
790            .filter(|s| matches!(s.kind, SymbolKind::Variable) || matches!(s.kind, SymbolKind::Constant))
791            .collect();
792
793        // Should find all: 3 global + 3 local = 6 variables
794        assert_eq!(var_symbols.len(), 6);
795
796        // Check globals
797        assert!(var_symbols.iter().any(|s| s.symbol.as_deref() == Some("GLOBAL_CONSTANT")));
798        assert!(var_symbols.iter().any(|s| s.symbol.as_deref() == Some("globalLet")));
799        assert!(var_symbols.iter().any(|s| s.symbol.as_deref() == Some("globalVar")));
800
801        // Check locals
802        assert!(var_symbols.iter().any(|s| s.symbol.as_deref() == Some("localConst")));
803        assert!(var_symbols.iter().any(|s| s.symbol.as_deref() == Some("localLet")));
804        assert!(var_symbols.iter().any(|s| s.symbol.as_deref() == Some("localVar")));
805
806        // Verify const vs variable classification
807        let global_const = var_symbols.iter().find(|s| s.symbol.as_deref() == Some("GLOBAL_CONSTANT")).unwrap();
808        assert!(matches!(global_const.kind, SymbolKind::Constant));
809
810        let global_let = var_symbols.iter().find(|s| s.symbol.as_deref() == Some("globalLet")).unwrap();
811        assert!(matches!(global_let.kind, SymbolKind::Variable));
812    }
813}
814
815// ============================================================================
816// Dependency Extraction
817// ============================================================================
818
819use crate::models::ImportType;
820use crate::parsers::{DependencyExtractor, ImportInfo};
821
822/// TypeScript/JavaScript dependency extractor
823pub struct TypeScriptDependencyExtractor;
824
825impl DependencyExtractor for TypeScriptDependencyExtractor {
826    fn extract_dependencies(source: &str) -> Result<Vec<ImportInfo>> {
827        // Delegate to the version without alias map for compatibility
828        Self::extract_dependencies_with_alias_map(source, None)
829    }
830}
831
832impl TypeScriptDependencyExtractor {
833    /// Extract dependencies with optional tsconfig alias map support
834    ///
835    /// This version properly classifies path alias imports (like @packages/*, ~/*) as Internal
836    /// when they match configured aliases from tsconfig.json.
837    pub fn extract_dependencies_with_alias_map(
838        source: &str,
839        alias_map: Option<&crate::parsers::tsconfig::PathAliasMap>,
840    ) -> Result<Vec<ImportInfo>> {
841        let mut parser = Parser::new();
842        let language = tree_sitter_typescript::LANGUAGE_TSX; // Use TSX for JS/TS compatibility
843
844        parser
845            .set_language(&language.into())
846            .context("Failed to set TypeScript/JavaScript language")?;
847
848        let tree = parser
849            .parse(source, None)
850            .context("Failed to parse TypeScript/JavaScript source")?;
851
852        let root_node = tree.root_node();
853
854        let mut imports = Vec::new();
855
856        // Extract ES6 import statements
857        imports.extend(extract_import_declarations(source, &root_node, alias_map)?);
858
859        // Extract require() statements
860        imports.extend(extract_require_statements(source, &root_node, alias_map)?);
861
862        Ok(imports)
863    }
864}
865
866/// Extract ES6 import declarations: import { foo } from 'module'
867fn extract_import_declarations(
868    source: &str,
869    root: &tree_sitter::Node,
870    alias_map: Option<&crate::parsers::tsconfig::PathAliasMap>,
871) -> Result<Vec<ImportInfo>> {
872    let language = tree_sitter_typescript::LANGUAGE_TSX;
873
874    let query_str = r#"
875        (import_statement
876            source: (string) @import_path) @import
877    "#;
878
879    let query = Query::new(&language.into(), query_str)
880        .context("Failed to create import declaration query")?;
881
882    let mut cursor = QueryCursor::new();
883    let mut matches = cursor.matches(&query, *root, source.as_bytes());
884
885    let mut imports = Vec::new();
886
887    while let Some(match_) = matches.next() {
888        let mut import_path = None;
889        let mut import_node = None;
890
891        for capture in match_.captures {
892            let capture_name: &str = &query.capture_names()[capture.index as usize];
893            match capture_name {
894                "import_path" => {
895                    // Remove quotes from string literal
896                    let raw_path = capture.node.utf8_text(source.as_bytes()).unwrap_or("");
897                    import_path = Some(raw_path.trim_matches(|c| c == '"' || c == '\'' || c == '`').to_string());
898                }
899                "import" => {
900                    import_node = Some(capture.node);
901                }
902                _ => {}
903            }
904        }
905
906        if let (Some(path), Some(node)) = (import_path, import_node) {
907            let import_type = classify_js_import(&path, alias_map);
908            let line_number = node.start_position().row + 1;
909
910            // Extract imported symbols
911            let imported_symbols = extract_imported_symbols_js(source, &node);
912
913            imports.push(ImportInfo {
914                imported_path: path,
915                import_type,
916                line_number,
917                imported_symbols,
918            });
919        }
920    }
921
922    Ok(imports)
923}
924
925/// Extract require() statements: const foo = require('module')
926fn extract_require_statements(
927    source: &str,
928    root: &tree_sitter::Node,
929    alias_map: Option<&crate::parsers::tsconfig::PathAliasMap>,
930) -> Result<Vec<ImportInfo>> {
931    let language = tree_sitter_typescript::LANGUAGE_TSX;
932
933    let query_str = r#"
934        (call_expression
935            function: (identifier) @func_name
936            arguments: (arguments (string) @require_path)) @require_call
937    "#;
938
939    let query = Query::new(&language.into(), query_str)
940        .context("Failed to create require query")?;
941
942    let mut cursor = QueryCursor::new();
943    let mut matches = cursor.matches(&query, *root, source.as_bytes());
944
945    let mut imports = Vec::new();
946
947    while let Some(match_) = matches.next() {
948        let mut func_name = None;
949        let mut require_path = None;
950        let mut require_node = None;
951
952        for capture in match_.captures {
953            let capture_name: &str = &query.capture_names()[capture.index as usize];
954            match capture_name {
955                "func_name" => {
956                    func_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or(""));
957                }
958                "require_path" => {
959                    // Remove quotes from string literal
960                    let raw_path = capture.node.utf8_text(source.as_bytes()).unwrap_or("");
961                    require_path = Some(raw_path.trim_matches(|c| c == '"' || c == '\'' || c == '`').to_string());
962                }
963                "require_call" => {
964                    require_node = Some(capture.node);
965                }
966                _ => {}
967            }
968        }
969
970        // Only process if it's actually a require() call
971        if func_name == Some("require") {
972            if let (Some(path), Some(node)) = (require_path, require_node) {
973                let import_type = classify_js_import(&path, alias_map);
974                let line_number = node.start_position().row + 1;
975
976                imports.push(ImportInfo {
977                    imported_path: path,
978                    import_type,
979                    line_number,
980                    imported_symbols: None, // require doesn't have selective imports
981                });
982            }
983        }
984    }
985
986    Ok(imports)
987}
988
989/// Extract the list of imported symbols from an import statement
990fn extract_imported_symbols_js(source: &str, import_node: &tree_sitter::Node) -> Option<Vec<String>> {
991    let mut symbols = Vec::new();
992
993    // Walk children to find import_clause nodes
994    let mut cursor = import_node.walk();
995    for child in import_node.children(&mut cursor) {
996        if child.kind() == "import_clause" {
997            // Look for named_imports or namespace_import
998            let mut clause_cursor = child.walk();
999            for grandchild in child.children(&mut clause_cursor) {
1000                match grandchild.kind() {
1001                    "named_imports" => {
1002                        // Extract individual import specifiers
1003                        let mut specifier_cursor = grandchild.walk();
1004                        for specifier in grandchild.children(&mut specifier_cursor) {
1005                            if specifier.kind() == "import_specifier" {
1006                                // Get the name (could be aliased)
1007                                if let Ok(text) = specifier.utf8_text(source.as_bytes()) {
1008                                    // Parse "foo as bar" or just "foo"
1009                                    let name = text.split_whitespace().next().unwrap_or(text);
1010                                    symbols.push(name.to_string());
1011                                }
1012                            }
1013                        }
1014                    }
1015                    "identifier" => {
1016                        // Default import: import Foo from 'module'
1017                        if let Ok(text) = grandchild.utf8_text(source.as_bytes()) {
1018                            symbols.push(text.to_string());
1019                        }
1020                    }
1021                    _ => {}
1022                }
1023            }
1024        }
1025    }
1026
1027    if symbols.is_empty() {
1028        None
1029    } else {
1030        Some(symbols)
1031    }
1032}
1033
1034/// Classify a JavaScript/TypeScript import as internal, external, or stdlib
1035///
1036/// # Arguments
1037///
1038/// * `import_path` - The import path string
1039/// * `alias_map` - Optional tsconfig path alias mappings
1040///
1041/// Path alias imports (like `@packages/*` from tsconfig.json) are classified as Internal
1042/// when they match a configured alias pattern.
1043fn classify_js_import(import_path: &str, alias_map: Option<&crate::parsers::tsconfig::PathAliasMap>) -> ImportType {
1044    // Relative imports (./ or ../)
1045    if import_path.starts_with("./") || import_path.starts_with("../") {
1046        log::trace!("classify_js_import: '{}' => Internal (relative)", import_path);
1047        return ImportType::Internal;
1048    }
1049
1050    // Absolute imports starting with /
1051    if import_path.starts_with("/") {
1052        log::trace!("classify_js_import: '{}' => Internal (absolute)", import_path);
1053        return ImportType::Internal;
1054    }
1055
1056    // Check if import matches a configured path alias (e.g., @packages/*, ~/*)
1057    if let Some(map) = alias_map {
1058        log::trace!("classify_js_import: checking '{}' against {} aliases", import_path, map.aliases.len());
1059        for alias_pattern in map.aliases.keys() {
1060            // Check for wildcard patterns like "@packages/*"
1061            if alias_pattern.ends_with("/*") {
1062                let alias_prefix = alias_pattern.trim_end_matches("/*");
1063                if import_path.starts_with(alias_prefix) {
1064                    log::info!("classify_js_import: '{}' => Internal (matches alias pattern '{}')", import_path, alias_pattern);
1065                    return ImportType::Internal;
1066                }
1067            } else {
1068                // Exact match
1069                if import_path == alias_pattern {
1070                    log::info!("classify_js_import: '{}' => Internal (exact match alias '{}')", import_path, alias_pattern);
1071                    return ImportType::Internal;
1072                }
1073            }
1074        }
1075        log::trace!("classify_js_import: '{}' did not match any of {} alias patterns", import_path, map.aliases.len());
1076    } else {
1077        log::trace!("classify_js_import: no alias map provided for '{}'", import_path);
1078    }
1079
1080    // Node.js built-in modules (stdlib)
1081    const STDLIB_MODULES: &[&str] = &[
1082        "fs", "path", "os", "crypto", "util", "events", "stream", "buffer",
1083        "http", "https", "net", "tls", "url", "querystring", "dns",
1084        "child_process", "cluster", "worker_threads", "readline",
1085        "zlib", "assert", "console", "module", "process", "timers",
1086        "vm", "string_decoder", "dgram", "v8", "perf_hooks",
1087        // Node.js prefixed imports (node:fs, etc.)
1088        "node:fs", "node:path", "node:os", "node:crypto", "node:util", "node:events",
1089        "node:stream", "node:buffer", "node:http", "node:https", "node:net",
1090    ];
1091
1092    // Check if it's a stdlib module
1093    if STDLIB_MODULES.contains(&import_path) {
1094        log::trace!("classify_js_import: '{}' => Stdlib", import_path);
1095        return ImportType::Stdlib;
1096    }
1097
1098    // Everything else is external (third-party packages from npm)
1099    log::info!("classify_js_import: '{}' => External (not alias, relative, absolute, or stdlib)", import_path);
1100    ImportType::External
1101}
1102
1103// ============================================================================
1104// Export Extraction (for barrel export tracking)
1105// ============================================================================
1106
1107use crate::parsers::ExportInfo;
1108
1109impl TypeScriptDependencyExtractor {
1110    /// Extract export/re-export statements for barrel export tracking
1111    ///
1112    /// Extracts:
1113    /// - `export * from './module'` (wildcard re-exports)
1114    /// - `export { Named } from './module'` (named re-exports)
1115    /// - `export { default as Name } from './module'` (default re-exports)
1116    ///
1117    /// Returns ExportInfo records with the exported symbol name (None for wildcard)
1118    /// and the source path. The indexer will resolve these to file IDs.
1119    pub fn extract_export_declarations(
1120        source: &str,
1121        _alias_map: Option<&crate::parsers::tsconfig::PathAliasMap>,
1122    ) -> Result<Vec<ExportInfo>> {
1123        let mut parser = Parser::new();
1124        let language = tree_sitter_typescript::LANGUAGE_TSX;
1125
1126        parser
1127            .set_language(&language.into())
1128            .context("Failed to set TypeScript/JavaScript language")?;
1129
1130        let tree = parser
1131            .parse(source, None)
1132            .context("Failed to parse TypeScript/JavaScript source for export extraction")?;
1133
1134        let root_node = tree.root_node();
1135
1136        let mut exports = Vec::new();
1137
1138        // Extract ES6 export statements with source paths
1139        exports.extend(extract_export_from_statements(source, &root_node)?);
1140
1141        Ok(exports)
1142    }
1143}
1144
1145/// Extract export statements that re-export from other modules
1146///
1147/// Handles:
1148/// - export * from './module'
1149/// - export { Named } from './module'
1150/// - export { Named as Alias } from './module'
1151/// - export { default as Name } from './module.vue'
1152fn extract_export_from_statements(
1153    source: &str,
1154    root: &tree_sitter::Node,
1155) -> Result<Vec<ExportInfo>> {
1156    let language = tree_sitter_typescript::LANGUAGE_TSX;
1157
1158    // Query for export statements with a source clause
1159    let query_str = r#"
1160        (export_statement
1161            source: (string) @source_path) @export
1162    "#;
1163
1164    let query = Query::new(&language.into(), query_str)
1165        .context("Failed to create export statement query")?;
1166
1167    let mut cursor = QueryCursor::new();
1168    let mut matches = cursor.matches(&query, *root, source.as_bytes());
1169
1170    let mut exports = Vec::new();
1171
1172    while let Some(match_) = matches.next() {
1173        let mut source_path = None;
1174        let mut export_node = None;
1175
1176        for capture in match_.captures {
1177            let capture_name: &str = &query.capture_names()[capture.index as usize];
1178            match capture_name {
1179                "source_path" => {
1180                    // Remove quotes from string literal
1181                    let raw_path = capture.node.utf8_text(source.as_bytes()).unwrap_or("");
1182                    source_path = Some(raw_path.trim_matches(|c| c == '"' || c == '\'' || c == '`').to_string());
1183                }
1184                "export" => {
1185                    export_node = Some(capture.node);
1186                }
1187                _ => {}
1188            }
1189        }
1190
1191        if let (Some(path), Some(node)) = (source_path, export_node) {
1192            let line_number = node.start_position().row + 1;
1193
1194            // Extract exported symbols from this export statement
1195            let exported_symbols = extract_exported_symbols(source, &node)?;
1196
1197            // If no specific symbols extracted (export *), create one entry with None
1198            if exported_symbols.is_empty() {
1199                exports.push(ExportInfo {
1200                    exported_symbol: None, // Wildcard export
1201                    source_path: path,
1202                    line_number,
1203                });
1204            } else {
1205                // Create one entry per exported symbol
1206                for symbol in exported_symbols {
1207                    exports.push(ExportInfo {
1208                        exported_symbol: Some(symbol),
1209                        source_path: path.clone(),
1210                        line_number,
1211                    });
1212                }
1213            }
1214        }
1215    }
1216
1217    Ok(exports)
1218}
1219
1220/// Extract the list of symbols being exported from an export statement
1221///
1222/// For `export { A, B as C } from './module'`, returns ["A", "B"]
1223/// For `export * from './module'`, returns empty vec (handled by caller)
1224fn extract_exported_symbols(source: &str, export_node: &tree_sitter::Node) -> Result<Vec<String>> {
1225    let mut symbols = Vec::new();
1226
1227    // Walk children to find export_clause
1228    let mut cursor = export_node.walk();
1229    for child in export_node.children(&mut cursor) {
1230        if child.kind() == "export_clause" {
1231            // Extract individual export specifiers
1232            let mut specifier_cursor = child.walk();
1233            for specifier in child.children(&mut specifier_cursor) {
1234                if specifier.kind() == "export_specifier" {
1235                    // Get the exported name (before "as" if aliased)
1236                    // For `export { foo as bar }`, we want "foo" (the original name)
1237                    if let Ok(text) = specifier.utf8_text(source.as_bytes()) {
1238                        // Parse "foo as bar" or just "foo"
1239                        let name = text.split_whitespace().next().unwrap_or(text);
1240                        symbols.push(name.to_string());
1241                    }
1242                }
1243            }
1244        }
1245    }
1246
1247    Ok(symbols)
1248}
1249
1250// ============================================================================
1251// Path Resolution
1252// ============================================================================
1253
1254/// Resolve a TypeScript/JavaScript import to a file path
1255///
1256/// Handles:
1257/// - Path aliases: `@packages/ui/store` → `../../packages/ui/store.ts` (requires tsconfig.json)
1258/// - Relative imports: `./components/Button` → `components/Button.tsx` or `components/Button/index.tsx`
1259/// - Parent directory imports: `../../utils/helper` → `../../utils/helper.ts`
1260/// - Index files: `./components` → `components/index.ts`
1261///
1262/// Does NOT handle:
1263/// - Node modules (external dependencies)
1264pub fn resolve_ts_import_to_path(
1265    import_path: &str,
1266    current_file_path: Option<&str>,
1267    alias_map: Option<&crate::parsers::tsconfig::PathAliasMap>,
1268) -> Option<String> {
1269    log::debug!("resolve_ts_import_to_path: import_path={}, current_file={:?}, has_alias_map={}",
1270               import_path, current_file_path, alias_map.is_some());
1271
1272    // Try path alias resolution first (if alias map is provided)
1273    if let Some(map) = alias_map {
1274        log::debug!("  Trying alias resolution with {} aliases (config_dir: {:?}, base_url: {:?})",
1275                   map.aliases.len(), map.config_dir, map.base_url);
1276        if let Some(resolved_alias) = map.resolve_alias(import_path) {
1277            log::debug!("  Alias matched! {} => {}", import_path, resolved_alias);
1278            // Alias matched! Now resolve relative to the tsconfig directory
1279            let resolved_path = map.resolve_relative_to_config(&resolved_alias);
1280            let path_str = resolved_path.to_string_lossy().to_string();
1281            log::debug!("  After resolve_relative_to_config: {}", path_str);
1282
1283            // Check if resolved path has an extension
1284            let has_extension = path_str.ends_with(".vue")
1285                || path_str.ends_with(".svelte")
1286                || path_str.ends_with(".ts")
1287                || path_str.ends_with(".tsx")
1288                || path_str.ends_with(".js")
1289                || path_str.ends_with(".jsx")
1290                || path_str.ends_with(".mjs")
1291                || path_str.ends_with(".cjs");
1292
1293            if has_extension {
1294                log::trace!("Resolved alias {} => {}", import_path, path_str);
1295                return Some(path_str);
1296            }
1297
1298            // No extension - generate candidates
1299            let extensions = vec![
1300                ".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs",
1301                "/index.tsx", "/index.ts", "/index.jsx", "/index.js",
1302            ];
1303
1304            let candidates: Vec<String> = extensions
1305                .iter()
1306                .map(|ext| format!("{}{}", path_str, ext))
1307                .collect();
1308
1309            log::trace!("Resolved alias {} => {} (candidates: {})",
1310                       import_path, path_str, candidates.join(" | "));
1311            return Some(candidates.join("|"));
1312        }
1313    }
1314
1315    // Fall back to relative import resolution
1316    // Only handle relative imports
1317    if !import_path.starts_with("./") && !import_path.starts_with("../") {
1318        return None;
1319    }
1320
1321    let current_file = current_file_path?;
1322
1323    // Get the directory of the current file
1324    let current_dir = std::path::Path::new(current_file).parent()?;
1325
1326    // Resolve the import path relative to current directory
1327    let resolved = current_dir.join(import_path);
1328
1329    // Normalize the path (resolve .. and . components)
1330    // Use components() to properly handle . and .. without requiring filesystem access
1331    let normalized_path = std::path::Path::new(&resolved)
1332        .components()
1333        .fold(std::path::PathBuf::new(), |mut acc, component| {
1334            match component {
1335                std::path::Component::CurDir => acc, // Skip .
1336                std::path::Component::ParentDir => {
1337                    acc.pop(); // Go up one level for ..
1338                    acc
1339                }
1340                _ => {
1341                    acc.push(component);
1342                    acc
1343                }
1344            }
1345        });
1346
1347    let normalized = normalized_path.to_string_lossy().to_string();
1348
1349    // Check if the import already has a known extension
1350    // Vue/Svelte files are imported with their extension: import Foo from './Foo.vue'
1351    let has_extension = normalized.ends_with(".vue")
1352        || normalized.ends_with(".svelte")
1353        || normalized.ends_with(".ts")
1354        || normalized.ends_with(".tsx")
1355        || normalized.ends_with(".js")
1356        || normalized.ends_with(".jsx")
1357        || normalized.ends_with(".mjs")
1358        || normalized.ends_with(".cjs");
1359
1360    if has_extension {
1361        // Import already has an extension - just return it normalized
1362        log::trace!("TS/JS import with extension: {}", normalized);
1363        return Some(normalized);
1364    }
1365
1366    // No extension - try multiple file extensions in order of preference
1367    // TypeScript: .ts, .tsx, .d.ts
1368    // JavaScript: .js, .jsx, .mjs, .cjs
1369    // Also try index files if the import is a directory
1370    //
1371    // NOTE: We return a list of candidates separated by "|" delimiter
1372    // The indexer will try each one in order until it finds a match in the database
1373    let extensions = vec![
1374        ".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs",
1375        "/index.tsx", "/index.ts", "/index.jsx", "/index.js",
1376    ];
1377
1378    let candidates: Vec<String> = extensions
1379        .iter()
1380        .map(|ext| format!("{}{}", normalized, ext))
1381        .collect();
1382
1383    log::trace!("TS/JS import candidates (no extension): {}", candidates.join(" | "));
1384
1385    // Return all candidates as a pipe-delimited string
1386    // Format: "path.tsx|path.ts|path.jsx|path.js|..."
1387    Some(candidates.join("|"))
1388}
1389
1390#[cfg(test)]
1391mod path_resolution_tests {
1392    use super::*;
1393
1394    #[test]
1395    fn test_resolve_relative_import_same_directory() {
1396        // import { Button } from './Button'
1397        let result = resolve_ts_import_to_path(
1398            "./Button",
1399            Some("src/components/App.tsx"),
1400            None,
1401        );
1402
1403        assert!(result.is_some());
1404        let candidates = result.unwrap();
1405        // Should contain pipe-delimited candidates with .tsx first, then .ts, etc.
1406        assert!(candidates.contains("Button.tsx"));
1407        assert!(candidates.contains("Button.ts"));
1408        // First candidate should be .tsx
1409        assert!(candidates.starts_with("src/components/Button.tsx") || candidates.contains("/Button.tsx|"));
1410    }
1411
1412    #[test]
1413    fn test_resolve_relative_import_parent_directory() {
1414        // import { helper} from '../utils/helper'
1415        let result = resolve_ts_import_to_path(
1416            "../utils/helper",
1417            Some("src/components/Button.tsx"),
1418            None,
1419        );
1420
1421        assert!(result.is_some());
1422        let path = result.unwrap();
1423        assert!(path.contains("utils/helper"));
1424    }
1425
1426    #[test]
1427    fn test_resolve_relative_import_multiple_parents() {
1428        // import { config } from '../../config/app'
1429        let result = resolve_ts_import_to_path(
1430            "../../config/app",
1431            Some("src/components/ui/Button.tsx"),
1432            None,
1433        );
1434
1435        assert!(result.is_some());
1436        let path = result.unwrap();
1437        assert!(path.contains("config/app"));
1438    }
1439
1440    #[test]
1441    fn test_resolve_index_file() {
1442        // import { components } from './components' (should try ./components/index.tsx)
1443        let result = resolve_ts_import_to_path(
1444            "./components",
1445            Some("src/App.tsx"),
1446            None,
1447        );
1448
1449        assert!(result.is_some());
1450        // The function returns the first candidate, which is .tsx
1451        // In reality, the indexer would try each candidate
1452        assert!(result.unwrap().contains("components"));
1453    }
1454
1455    #[test]
1456    fn test_absolute_import_not_supported_without_alias_map() {
1457        // import { Button } from '@components/Button' (requires tsconfig.json)
1458        let result = resolve_ts_import_to_path(
1459            "@components/Button",
1460            Some("src/App.tsx"),
1461            None,
1462        );
1463
1464        // Should return None for absolute imports when no alias map provided
1465        assert!(result.is_none());
1466    }
1467
1468    #[test]
1469    fn test_node_modules_import_not_supported() {
1470        // import { React } from 'react'
1471        let result = resolve_ts_import_to_path(
1472            "react",
1473            Some("src/App.tsx"),
1474            None,
1475        );
1476
1477        // Should return None for node_modules imports
1478        assert!(result.is_none());
1479    }
1480
1481    #[test]
1482    fn test_resolve_without_current_file() {
1483        let result = resolve_ts_import_to_path(
1484            "./Button",
1485            None,
1486            None,
1487        );
1488
1489        // Should return None if no current file provided
1490        assert!(result.is_none());
1491    }
1492
1493    #[test]
1494    fn test_resolve_nested_directory_structure() {
1495        // import { api } from './api/client'
1496        let result = resolve_ts_import_to_path(
1497            "./api/client",
1498            Some("src/services/http.ts"),
1499            None,
1500        );
1501
1502        assert!(result.is_some());
1503        let path = result.unwrap();
1504        // Should resolve to src/services/api/client with an extension
1505        assert!(path.contains("api/client"));
1506    }
1507}
1508
1509#[cfg(test)]
1510mod dependency_extraction_tests {
1511    use super::*;
1512
1513    #[test]
1514    fn test_extract_basic_imports() {
1515        let source = r#"
1516            import { Button } from './components/Button';
1517            import React from 'react';
1518            import fs from 'fs';
1519            import '../styles.css';
1520        "#;
1521
1522        let deps = TypeScriptDependencyExtractor::extract_dependencies(source).unwrap();
1523
1524        assert_eq!(deps.len(), 4, "Should extract 4 import statements");
1525        assert!(deps.iter().any(|d| d.imported_path == "./components/Button"));
1526        assert!(deps.iter().any(|d| d.imported_path == "react"));
1527        assert!(deps.iter().any(|d| d.imported_path == "fs"));
1528        assert!(deps.iter().any(|d| d.imported_path == "../styles.css"));
1529    }
1530
1531    #[test]
1532    fn test_dynamic_imports_filtered() {
1533        let source = r#"
1534            import { Button } from './components/Button';
1535            import React from 'react';
1536            const fs = require('fs');
1537
1538            // Dynamic imports - should be filtered out
1539            const moduleName = './dynamic-module';
1540            import(moduleName);
1541            import(`./templates/${template}`);
1542            require(variable);
1543            require(CONFIG_PATH + '/settings.js');
1544        "#;
1545
1546        let deps = TypeScriptDependencyExtractor::extract_dependencies(source).unwrap();
1547
1548        // Should only find static imports (Button, React, fs)
1549        // Variable and template literal imports are filtered (not (string) nodes)
1550        assert_eq!(deps.len(), 3, "Should extract 3 static imports only");
1551
1552        assert!(deps.iter().any(|d| d.imported_path == "./components/Button"));
1553        assert!(deps.iter().any(|d| d.imported_path == "react"));
1554        assert!(deps.iter().any(|d| d.imported_path == "fs"));
1555
1556        // Verify dynamic imports are NOT captured
1557        assert!(!deps.iter().any(|d| d.imported_path.contains("moduleName")));
1558        assert!(!deps.iter().any(|d| d.imported_path.contains("template")));
1559        assert!(!deps.iter().any(|d| d.imported_path.contains("variable")));
1560        assert!(!deps.iter().any(|d| d.imported_path.contains("CONFIG_PATH")));
1561    }
1562
1563    #[test]
1564    fn test_require_with_template_literals_filtered() {
1565        let source = r#"
1566            const path = require('path');
1567            const utils = require('./utils');
1568
1569            // Dynamic requires with template literals - should be filtered out
1570            const config = require(`./config/${env}.json`);
1571            const plugin = require(`${PLUGIN_DIR}/loader`);
1572        "#;
1573
1574        let deps = TypeScriptDependencyExtractor::extract_dependencies(source).unwrap();
1575
1576        // Should only find static requires (path, ./utils)
1577        // Template literal requires are filtered (template_string nodes, not string nodes)
1578        assert_eq!(deps.len(), 2, "Should extract 2 static requires only");
1579
1580        assert!(deps.iter().any(|d| d.imported_path == "path"));
1581        assert!(deps.iter().any(|d| d.imported_path == "./utils"));
1582
1583        // Verify dynamic requires are NOT captured
1584        assert!(!deps.iter().any(|d| d.imported_path.contains("env")));
1585        assert!(!deps.iter().any(|d| d.imported_path.contains("PLUGIN_DIR")));
1586    }
1587}