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