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