Skip to main content

oparry_parser/
javascript_complete.rs

1//! Complete JavaScript/TypeScript parser using Oxc
2
3use oparry_core::Result;
4use oxc_allocator::Allocator;
5use oxc_ast::ast::*;
6use oxc_parser::Parser;
7use oxc_span::SourceType;
8
9/// Information about an import specifier
10#[derive(Debug, Clone)]
11pub enum ImportSpecifierInfo {
12    /// Default import: `import React from 'react'`
13    Default { local_name: String },
14    /// Named import: `import { useState } from 'react'`
15    Named { imported_name: String, local_name: String },
16    /// Namespace import: `import * as React from 'react'`
17    Namespace { local_name: String },
18}
19
20/// Information about an import declaration
21#[derive(Debug, Clone)]
22pub struct ImportInfo {
23    /// Source module path
24    pub source: String,
25    /// All specifiers in this import
26    pub specifiers: Vec<ImportSpecifierInfo>,
27    /// Whether this is a type-only import (TypeScript)
28    pub is_type_only: bool,
29}
30
31/// Information about an export
32#[derive(Debug, Clone)]
33pub enum ExportInfo {
34    /// Named export: `export { foo, bar }`
35    Named { names: Vec<String> },
36    /// Default export: `export default Component`
37    Default,
38    /// Export all: `export * from './module'`
39    AllFrom { source: String },
40    /// Export declaration: `export function foo() {}`
41    Declaration { kind: String, name: String },
42}
43
44/// Information about a JSX component
45#[derive(Debug, Clone)]
46pub struct ComponentInfo {
47    /// Component name
48    pub name: String,
49    /// Line number where component opens
50    pub line: usize,
51    /// Whether component has children
52    pub has_children: bool,
53}
54
55/// Information about a React hook usage
56#[derive(Debug, Clone)]
57pub struct HookInfo {
58    /// Hook name (useState, useEffect, etc.)
59    pub name: String,
60    /// Line number where hook is called
61    pub line: usize,
62}
63
64/// Parse results containing all extracted information
65#[derive(Debug, Clone, Default)]
66pub struct ParseResults {
67    /// All imports found
68    pub imports: Vec<ImportInfo>,
69    /// All exports found
70    pub exports: Vec<ExportInfo>,
71    /// All JSX components found
72    pub components: Vec<ComponentInfo>,
73    /// All React hooks found
74    pub hooks: Vec<HookInfo>,
75}
76
77/// JavaScript AST with parsed results
78pub struct JavaScriptAst {
79    source: String,
80    pub results: ParseResults,
81}
82
83impl JavaScriptAst {
84    /// Create new JavaScript AST
85    pub fn new(source: String, results: ParseResults) -> Self {
86        Self { source, results }
87    }
88
89    /// Get source code
90    pub fn source(&self) -> &str {
91        &self.source
92    }
93
94    /// Get parse results
95    pub fn results(&self) -> &ParseResults {
96        &self.results
97    }
98
99    /// Extract all imports
100    pub fn extract_imports(&self) -> Vec<ImportInfo> {
101        self.results.imports.clone()
102    }
103
104    /// Extract all exports
105    pub fn extract_exports(&self) -> Vec<ExportInfo> {
106        self.results.exports.clone()
107    }
108
109    /// Extract all JSX components
110    pub fn extract_jsx_components(&self) -> Vec<ComponentInfo> {
111        self.results.components.clone()
112    }
113
114    /// Extract all React hooks
115    pub fn extract_hooks(&self) -> Vec<HookInfo> {
116        self.results.hooks.clone()
117    }
118}
119
120/// Check if name is a component name (PascalCase)
121pub fn is_component_name(name: &str) -> bool {
122    name.starts_with(char::is_uppercase)
123}
124
125/// JavaScript/TypeScript parser
126pub struct JavaScriptParser;
127
128impl JavaScriptParser {
129    pub fn new() -> Self {
130        Self
131    }
132
133    /// Parse source code - validates syntax and extracts information
134    pub fn parse(&self, source: &str, source_type: SourceType) -> Result<JavaScriptAst> {
135        let allocator = Allocator::default();
136
137        // Validate syntax using Oxc
138        let parser = Parser::new(&allocator, source, source_type);
139        let result = parser.parse();
140
141        if !result.errors.is_empty() {
142            let e = &result.errors[0];
143            return Err(oparry_core::Error::Parse {
144                file: "<unknown>".to_string(),
145                line: 0,
146                column: 0,
147                message: e.message.to_string(),
148            });
149        }
150
151        // Extract information using regex-based approach for MVP
152        let results = Self::extract_info(source);
153        let source_str = source.to_string();
154
155        Ok(JavaScriptAst::new(source_str, results))
156    }
157
158    /// Extract information from source code using regex
159    fn extract_info(source: &str) -> ParseResults {
160        let mut results = ParseResults::default();
161
162        // Extract imports
163        let import_re = regex::Regex::new(
164            r#"import\s+(?:(?P<default>\w+)|\{(?P<named>[^}]+)\}|\*\s+as\s+(?P<namespace>\w+))\s+from\s+['"](?P<source>[^'"]+)['"]"#
165        ).unwrap();
166
167        for caps in import_re.captures_iter(source) {
168            let source = caps.name("source")
169                .map(|m| m.as_str().to_string())
170                .unwrap_or_default();
171            let is_type_only = source.contains("type");
172
173            let specifiers = if caps.name("default").is_some() {
174                vec![ImportSpecifierInfo::Default {
175                    local_name: caps.name("default").unwrap().as_str().to_string(),
176                }]
177            } else if caps.name("namespace").is_some() {
178                vec![ImportSpecifierInfo::Namespace {
179                    local_name: caps.name("namespace").unwrap().as_str().to_string(),
180                }]
181            } else if let Some(named) = caps.name("named") {
182                named.as_str().split(',')
183                    .map(|s| ImportSpecifierInfo::Named {
184                        imported_name: s.trim().to_string(),
185                        local_name: s.trim().to_string(),
186                    })
187                    .collect()
188            } else {
189                vec![]
190            };
191
192            results.imports.push(ImportInfo {
193                source,
194                specifiers,
195                is_type_only,
196            });
197        }
198
199        // Extract components (PascalCase names in JSX-like context)
200        let jsx_re = regex::Regex::new(r#"<([A-Z][a-zA-Z0-9]*)"#).unwrap();
201        let mut component_names = std::collections::HashSet::new();
202        for caps in jsx_re.captures_iter(source) {
203            if let Some(name) = caps.get(1) {
204                component_names.insert(name.as_str().to_string());
205            }
206        }
207
208        results.components = component_names.into_iter()
209            .enumerate()
210            .map(|(_i, name)| ComponentInfo {
211                name,
212                line: 0,
213                has_children: false,
214            })
215            .collect();
216
217        // Extract React hooks - match complete hook names
218        let hook_re = regex::Regex::new(
219            r#"\b(useState|useEffect|useCallback|useMemo|useRef|useContext|useReducer|useTransition|useId|useSyncExternalStore|useLayoutEffect|useImperativeHandle|useDeferredValue|useDebugValue|useInsertionEffect)\b"#
220        ).unwrap();
221        let mut hook_names = std::collections::HashSet::new();
222        for caps in hook_re.captures_iter(source) {
223            if let Some(name) = caps.get(1) {
224                hook_names.insert(name.as_str().to_string());
225            }
226        }
227
228        results.hooks = hook_names.into_iter()
229            .map(|name| HookInfo {
230                name,
231                line: 0,
232            })
233            .collect();
234
235        // Extract exports - simpler regex approach
236        if source.contains("export default") {
237            results.exports.push(ExportInfo::Default);
238        }
239
240        // Export * from './module'
241        let export_all_re = regex::Regex::new(r#"export\s+\*\s+from\s+['"]([^'"]+)['"]"#).unwrap();
242        for caps in export_all_re.captures_iter(source) {
243            if let Some(from) = caps.get(1) {
244                results.exports.push(ExportInfo::AllFrom {
245                    source: from.as_str().to_string(),
246                });
247            }
248        }
249
250        // Export const/function/class
251        let export_decl_re = regex::Regex::new(r#"export\s+(?:const|function|class)\s+(\w+)"#).unwrap();
252        for caps in export_decl_re.captures_iter(source) {
253            if let Some(name) = caps.get(1) {
254                results.exports.push(ExportInfo::Declaration {
255                    kind: "declaration".to_string(),
256                    name: name.as_str().to_string(),
257                });
258            }
259        }
260
261        results
262    }
263}
264
265impl Default for JavaScriptParser {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_parse_simple_js() {
277        let parser = JavaScriptParser::new();
278        let source = r#"
279            function hello() {
280                return "world";
281            }
282        "#;
283
284        let ast = parser.parse(source, SourceType::default()).unwrap();
285        assert_eq!(ast.source(), source);
286    }
287
288    #[test]
289    fn test_parse_invalid_js() {
290        let parser = JavaScriptParser::new();
291        let source = "function { invalid";
292
293        let result = parser.parse(source, SourceType::default());
294        assert!(result.is_err());
295    }
296
297    #[test]
298    fn test_extract_imports() {
299        let parser = JavaScriptParser::new();
300        let source = r#"
301            import React from 'react';
302            import { useState, useEffect } from 'react';
303            import * as utils from './utils';
304        "#;
305
306        let ast = parser.parse(source, SourceType::default()).unwrap();
307        let imports = ast.extract_imports();
308        assert_eq!(imports.len(), 3);
309    }
310
311    #[test]
312    fn test_extract_components() {
313        let parser = JavaScriptParser::new();
314        let source = r#"
315            function App() {
316                return <div><Header /><Main /></div>;
317            }
318        "#;
319
320        let ast = parser.parse(source, SourceType::tsx()).unwrap();
321        let components = ast.extract_jsx_components();
322        assert!(!components.is_empty());
323        assert!(components.iter().any(|c| c.name == "Header"));
324    }
325
326    #[test]
327    fn test_extract_hooks() {
328        let parser = JavaScriptParser::new();
329        let source = r#"
330            function Component() {
331                const [state, setState] = useState(0);
332                useEffect(() => {}, []);
333                const memoized = useMemo(() => compute(), []);
334                return <div />;
335            }
336        "#;
337
338        let ast = parser.parse(source, SourceType::tsx()).unwrap();
339        let hooks = ast.extract_hooks();
340        assert!(!hooks.is_empty());
341        assert!(hooks.iter().any(|h| h.name == "useState"));
342        assert!(hooks.iter().any(|h| h.name == "useEffect"));
343    }
344
345    #[test]
346    fn test_is_component_name() {
347        assert!(is_component_name("Button"));
348        assert!(is_component_name("MyComponent"));
349        assert!(!is_component_name("button"));
350        assert!(!is_component_name("div"));
351    }
352
353    #[test]
354    fn test_parse_typescript() {
355        let parser = JavaScriptParser::new();
356        let source = r#"
357            interface User {
358                name: string;
359                age: number;
360            }
361
362            function greet(user: User): string {
363                return `Hello ${user.name}`;
364            }
365        "#;
366
367        let ast = parser.parse(source, SourceType::ts()).unwrap();
368        assert_eq!(ast.source(), source);
369    }
370
371    #[test]
372    fn test_parse_tsx() {
373        let parser = JavaScriptParser::new();
374        let source = r#"
375            interface Props {
376                children: string;
377            }
378
379            export function Button({ children }: Props) {
380                return <button>{children}</button>;
381            }
382        "#;
383
384        let ast = parser.parse(source, SourceType::tsx()).unwrap();
385        assert_eq!(ast.source(), source);
386    }
387
388    #[test]
389    fn test_ast_new() {
390        let source = "test source".to_string();
391        let results = ParseResults::default();
392        let ast = JavaScriptAst::new(source, results);
393
394        assert_eq!(ast.source(), "test source");
395    }
396}