relay_hook_transpiler/
lib.rs

1mod jsx_parser;
2mod swc_transformer;
3pub mod debug;
4#[cfg(all(feature = "native-swc", not(target_arch = "wasm32")))]
5mod swc_native;
6
7#[cfg(feature = "wasm")]
8use serde::{Deserialize, Serialize};
9
10pub use debug::{DebugLevel, DebugContext, DebugEntry};
11
12/// Target platform for transpilation
13#[cfg_attr(feature = "wasm", derive(Serialize, Deserialize))]
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum TranspileTarget {
16    /// Web browser - supports modern ES2020+ features
17    Web,
18    /// Android JavaScriptCore - older JS engine, needs more transpilation
19    Android,
20}
21
22impl Default for TranspileTarget {
23    fn default() -> Self {
24        Self::Web
25    }
26}
27
28pub struct TranspileOptions {
29    pub is_typescript: bool,
30    /// Target platform - determines which features need transpilation
31    pub target: TranspileTarget,
32    /// Optional filename for diagnostics and SWC parser hints
33    pub filename: Option<String>,
34    /// Whether to emit CommonJS-compatible output (native targets only)
35    pub to_commonjs: bool,
36    /// Emit source maps (native SWC path only)
37    pub source_maps: bool,
38    /// Inline source maps as data URLs (native SWC path only)
39    pub inline_source_map: bool,
40    /// Apply compat downlevel transforms for older engines (native SWC path only)
41    pub compat_for_jsc: bool,
42    /// Debug level for transpilation logging
43    pub debug_level: DebugLevel,
44}
45
46impl Default for TranspileOptions {
47    fn default() -> Self {
48        Self {
49            is_typescript: false,
50            target: TranspileTarget::Web,
51            filename: None,
52            to_commonjs: false,
53            source_maps: false,
54            inline_source_map: false,
55            compat_for_jsc: true,
56            debug_level: DebugLevel::default(),
57        }
58    }
59}
60
61/// Describes a single import statement
62#[cfg_attr(feature = "wasm", derive(Serialize, Deserialize))]
63#[derive(Debug, Clone, PartialEq)]
64pub struct ImportMetadata {
65    pub source: String,
66    pub kind: ImportKind,
67    pub bindings: Vec<ImportBinding>,
68}
69
70#[cfg_attr(feature = "wasm", derive(Serialize, Deserialize))]
71#[derive(Debug, Clone, PartialEq)]
72#[cfg_attr(feature = "wasm", serde(tag = "type", content = "value"))]
73pub enum ImportKind {
74    Builtin,
75    SpecialPackage,
76    Module,
77}
78
79#[cfg_attr(feature = "wasm", derive(Serialize, Deserialize))]
80#[derive(Debug, Clone, PartialEq)]
81pub struct ImportBinding {
82    pub binding_type: ImportBindingType,
83    pub name: String,
84    pub alias: Option<String>,
85}
86
87#[cfg_attr(feature = "wasm", derive(Serialize, Deserialize))]
88#[derive(Debug, Clone, PartialEq)]
89#[cfg_attr(feature = "wasm", serde(tag = "type"))]
90pub enum ImportBindingType {
91    Default,
92    Named,
93    Namespace,
94}
95
96/// Metadata about a transpiled module
97#[cfg_attr(feature = "wasm", derive(Serialize, Deserialize))]
98#[derive(Debug, Clone, PartialEq)]
99pub struct TranspileMetadata {
100    pub imports: Vec<ImportMetadata>,
101    pub has_jsx: bool,
102    pub has_dynamic_import: bool,
103    pub version: String,
104}
105
106/// Returns the crate version
107pub fn version() -> &'static str {
108    env!("CARGO_PKG_VERSION")
109}
110
111/// Simple JSX to JS transpiler using custom parser
112/// Outputs direct calls to __hook_jsx_runtime.jsx(...)
113pub fn transpile_jsx_simple(source: &str) -> Result<String, String> {
114    transpile_jsx_with_options(source, &TranspileOptions::default())
115}
116
117/// Transpile JSX with options (e.g. TypeScript support)
118pub fn transpile_jsx_with_options(source: &str, opts: &TranspileOptions) -> Result<String, String> {
119    let debug_ctx = DebugContext::new(opts.debug_level);
120    
121    debug_ctx.info(format!("Starting transpilation for target: {:?}", opts.target));
122    if let Some(filename) = &opts.filename {
123        debug_ctx.trace(format!("File: {}", filename));
124    }
125    debug_ctx.trace(format!("Options: typescript={}, commonjs={}, maps={}", 
126        opts.is_typescript, opts.to_commonjs, opts.source_maps));
127    
128    // Prefer SWC for native Android when the feature is enabled; keep wasm/web slim.
129    #[cfg(all(feature = "native-swc", not(target_arch = "wasm32")))]
130    if opts.target == TranspileTarget::Android {
131        debug_ctx.trace("Using SWC transpiler for Android target");
132        let mut code = swc_native::transpile_with_swc(source, opts)
133            .map_err(|e| {
134                debug_ctx.error(format!("SWC transpile error: {}", e));
135                format!("SWC transpile failed: {e}")
136            })?;
137        
138        // Apply dynamic import transformation (import() -> __hook_import())
139        // Always run this since SWC preserves import() calls
140        debug_ctx.trace("Applying dynamic import transformation");
141        code = jsx_parser::transform_dynamic_imports(&code);
142        
143        debug_ctx.info("Transpilation completed successfully");
144        return Ok(code);
145    }
146
147    debug_ctx.trace("Using JSX parser for transpilation");
148    let jsx_output = jsx_parser::transpile_jsx(source, opts).map_err(|e| {
149        debug_ctx.error(format!("JSX parse error: {}", e));
150        e.to_string()
151    })?;
152    debug_ctx.trace("JSX transformation complete");
153    
154    // For Android/iOS targets, apply ES5 downleveling for JavaScriptCore
155    // Web target doesn't need transformation (modern browsers support ES2020+)
156    if opts.target == TranspileTarget::Android {
157        debug_ctx.trace("Applying ES5 downleveling for Android JavaScriptCore");
158        let downleveled = swc_transformer::downlevel_for_jsc(&jsx_output)
159            .map_err(|e| {
160                debug_ctx.error(format!("ES5 downlevel error: {}", e));
161                format!("ES5 transformation failed: {}", e)
162            })?;
163        
164        // CRITICAL: Transform dynamic imports after downleveling
165        // This ensures import() calls become __hook_import() calls
166        debug_ctx.trace("Applying dynamic import transformation");
167        let transformed = jsx_parser::transform_dynamic_imports(&downleveled);
168        
169        debug_ctx.info("Transpilation completed successfully");
170        return Ok(transformed);
171    }
172    
173    debug_ctx.info("Transpilation completed successfully");
174    Ok(jsx_output)
175}
176
177/// Transform ES6 modules to CommonJS
178/// Converts: import X from 'mod' → const X = require('mod')
179/// Converts: export default X → module.exports.default = X
180pub fn transform_es6_modules(source: &str) -> String {
181    jsx_parser::transform_es6_modules(source)
182}
183
184/// Metadata about an import statement for static analysis
185#[cfg_attr(feature = "wasm", derive(Serialize, Deserialize))]
186#[derive(Debug, Clone, PartialEq)]
187pub struct StaticImportMetadata {
188    pub module: String,
189    pub imported: Vec<String>,
190    pub is_default: bool,
191    pub is_namespace: bool,
192    pub is_lazy: bool,
193}
194
195/// Extract import metadata from source without executing it
196/// Useful for pre-fetching imports or analyzing module dependencies
197pub fn extract_imports(source: &str) -> Vec<StaticImportMetadata> {
198    jsx_parser::extract_imports(source)
199        .into_iter()
200        .map(|m| StaticImportMetadata {
201            module: m.module,
202            imported: m.imported,
203            is_default: m.is_default,
204            is_namespace: m.is_namespace,
205            is_lazy: m.is_lazy,
206        })
207        .collect()
208}
209
210/// Transpile JSX and extract metadata in one call
211/// Returns both the transpiled code and import/JSX metadata
212#[cfg_attr(feature = "wasm", derive(Serialize, Deserialize))]
213#[derive(Debug, Clone)]
214pub struct TranspileResult {
215    pub code: String,
216    pub metadata: TranspileMetadata,
217}
218
219/// Transpile JSX with metadata extraction
220/// This is the primary entry point for web clients needing full analysis
221pub fn transpile_jsx_with_metadata(source: &str, _filename: Option<&str>, is_typescript: bool) -> Result<TranspileResult, String> {
222    let debug_ctx = DebugContext::new(DebugLevel::default());
223    
224    debug_ctx.trace("Extracting metadata from source");
225    
226    // Detect if we have JSX
227    let has_jsx = source.contains('<') && source.contains('>') && 
228                  (source.contains("return") || source.contains("(") || source.contains("<"));
229    debug_ctx.trace(format!("Has JSX: {}", has_jsx));
230    
231    // Detect dynamic imports
232    let has_dynamic_import = source.contains("import(");
233    debug_ctx.trace(format!("Has dynamic imports: {}", has_dynamic_import));
234    
235    // Transpile the JSX with Web target (no unnecessary transpilation)
236    let opts = TranspileOptions {
237        is_typescript,
238        target: TranspileTarget::Web,
239        filename: _filename.map(|f| f.to_string()),
240        debug_level: DebugLevel::default(),
241        ..Default::default()
242    };
243    let code = transpile_jsx_with_options(source, &opts)?;
244    
245    debug_ctx.trace("Extracting import bindings");
246    // Extract imports for metadata with proper binding detection
247    let imports = extract_imports_with_bindings(source);
248    debug_ctx.info(format!("Extracted {} imports", imports.len()));
249    
250    Ok(TranspileResult {
251        code,
252        metadata: TranspileMetadata {
253            imports,
254            has_jsx,
255            has_dynamic_import,
256            version: version().to_string(),
257        },
258    })
259}
260
261/// Extract imports and detect binding types from source
262fn extract_imports_with_bindings(source: &str) -> Vec<ImportMetadata> {
263    jsx_parser::extract_imports(source)
264        .into_iter()
265        .map(|m| {
266            let kind = classify_import(&m.module);
267            
268            // Determine binding type based on extraction metadata
269            let bindings = if m.is_namespace {
270                m.imported.into_iter().map(|name| {
271                    ImportBinding {
272                        binding_type: ImportBindingType::Namespace,
273                        name,
274                        alias: None,
275                    }
276                }).collect()
277            } else if m.is_default {
278                m.imported.into_iter().map(|name| {
279                    ImportBinding {
280                        binding_type: ImportBindingType::Default,
281                        name,
282                        alias: None,
283                    }
284                }).collect()
285            } else {
286                // Named imports
287                m.imported.into_iter().map(|name| {
288                    // Check if there's an alias (format: "original as alias")
289                    if name.contains(" as ") {
290                        let parts: Vec<&str> = name.split(" as ").collect();
291                        ImportBinding {
292                            binding_type: ImportBindingType::Named,
293                            name: parts[0].trim().to_string(),
294                            alias: Some(parts[1].trim().to_string()),
295                        }
296                    } else {
297                        ImportBinding {
298                            binding_type: ImportBindingType::Named,
299                            name,
300                            alias: None,
301                        }
302                    }
303                }).collect()
304            };
305            
306            ImportMetadata {
307                source: m.module,
308                kind,
309                bindings,
310            }
311        })
312        .collect()
313}
314
315/// Classify an import source (builtin, special package, or regular module)
316fn classify_import(source: &str) -> ImportKind {
317    if source == "react" || source == "react-dom" || source == "react-native" {
318        ImportKind::SpecialPackage
319    } else if source.starts_with("@clevertree/") {
320        ImportKind::SpecialPackage
321    } else if source.starts_with("@") {
322        // Other scoped packages
323        ImportKind::Module
324    } else if source.starts_with(".") {
325        ImportKind::Module
326    } else if source.contains("/") {
327        ImportKind::Module
328    } else {
329        // Unscoped packages
330        ImportKind::Module
331    }
332}
333
334// WASM bindings for client-web (feature = "wasm")
335#[cfg(feature = "wasm")]
336mod wasm_api;
337
338#[cfg(feature = "android")]
339mod android_jni;
340
341mod ffi;
342pub use ffi::*;
343
344#[cfg(target_vendor = "apple")]
345mod ios_ffi;
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    #[cfg(all(feature = "native-swc", not(target_arch = "wasm32")))]
353    fn swc_native_emits_cjs_and_sourcemap_footer() {
354        let opts = TranspileOptions {
355            is_typescript: false,
356            target: TranspileTarget::Android,
357            filename: Some("map-test.jsx".to_string()),
358            to_commonjs: true,
359            source_maps: true,
360            inline_source_map: true,
361            compat_for_jsc: true,
362            debug_level: Default::default(),
363        };
364
365        let code = r#"
366            export default function Component() {
367                return <div>hello</div>;
368            }
369        "#;
370
371        let output = transpile_jsx_with_options(code, &opts).expect("SWC transpile should succeed");
372        println!("SWC output:\n{}", output);
373        assert!(
374            output.contains("module.exports")
375                || output.contains("exports.default")
376                || output.contains("Object.defineProperty(exports, \"default\"")
377        , "should emit CommonJS exports");
378        assert!(output.contains("__hook_jsx_runtime") || output.contains("jsx"), "should include JSX runtime calls");
379        assert!(output.contains("sourceMappingURL=data:application/json;base64,"), "should include inline source map footer");
380        
381        // Verify source map content
382        let footer_prefix = "sourceMappingURL=data:application/json;base64,";
383        let footer_start = output.find(footer_prefix).unwrap() + footer_prefix.len();
384        let encoded_map = &output[footer_start..].trim();
385        let decoded_map = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded_map).unwrap();
386        let map_json: serde_json::Value = serde_json::from_slice(&decoded_map).unwrap();
387        
388        assert!(map_json["sources"].as_array().unwrap().iter().any(|s| s.as_str() == Some("map-test.jsx")), "should contain filename in sources");
389        assert!(map_json["sourcesContent"].as_array().is_some(), "should contain sourcesContent");
390        assert!(map_json["sourcesContent"].as_array().unwrap()[0].as_str().unwrap().contains("Component"), "sourcesContent should contain original source");
391    }
392
393    #[test]
394    fn test_simple_jsx() {
395        let input = "<div>Hello</div>";
396        let output = transpile_jsx_simple(input).unwrap();
397        assert!(output.contains("__hook_jsx_runtime.jsx"));
398        assert!(output.contains("div"));
399        assert!(output.contains("Hello"));
400    }
401
402    #[test]
403    fn test_jsx_with_props() {
404        let input = r#"<div className="test">Content</div>"#;
405        let output = transpile_jsx_simple(input).unwrap();
406        assert!(output.contains("className"));
407        assert!(output.contains("test"));
408    }
409
410    #[test]
411    fn test_self_closing() {
412        let input = "<img src='test.jpg' />";
413        let output = transpile_jsx_simple(input).unwrap();
414        assert!(output.contains("img"));
415        assert!(output.contains("src"));
416    }
417
418    #[test]
419    fn test_strings_with_reserved_keywords() {
420        // Test that strings containing reserved keywords are properly transpiled
421        // and not treated as code
422        let input = r#"<p className="text-xs text-gray-500 mb-2">Select a theme for the application</p>"#;
423        let output = transpile_jsx_simple(input).unwrap();
424        
425        // Should contain the className prop
426        assert!(output.contains("className"));
427        assert!(output.contains("text-xs text-gray-500 mb-2"));
428        
429        // Should contain the text content as a string
430        assert!(output.contains("Select a theme for the application"));
431        
432        // Should not have "className" inside the text content treated as code
433        assert!(output.contains("__hook_jsx_runtime.jsx"));
434    }
435
436    #[test]
437    fn test_string_with_jsx_like_content() {
438        // Test strings containing JSX-like syntax are preserved as strings
439        let input = r#"<div>{"<div>test</div>"}</div>"#;
440        let output = transpile_jsx_simple(input).unwrap();
441        
442        // The outer div should be transpiled
443        assert!(output.contains("__hook_jsx_runtime.jsx"));
444        assert!(output.contains("div"));
445        
446        // The inner JSX-like string should be preserved as a string
447        assert!(output.contains("<div>test</div>"));
448    }
449
450    #[test]
451    fn test_multiple_class_names() {
452        // Test multiple space-separated class names with reserved keywords
453        let input = r#"<button className="px-3 py-1 bg-blue-600 text-white rounded">Click me</button>"#;
454        let output = transpile_jsx_simple(input).unwrap();
455        
456        assert!(output.contains("className"));
457        assert!(output.contains("px-3 py-1 bg-blue-600 text-white rounded"));
458        assert!(output.contains("Click me"));
459    }
460
461    #[test]
462    fn test_full_test_hook_transpiles() {
463        // Regression: ensure the web fixture hook transpiles without EOF errors
464        let input = include_str!("../tests/web/public/hooks/test-hook.jsx");
465        let output = transpile_jsx_simple(input);
466
467        assert!(output.is_ok(), "test-hook.jsx should transpile: {output:?}");
468    }
469
470    #[test]
471    fn test_reserved_keywords_in_jsx_text() {
472        // Test that reserved keywords in JSX text content don't cause errors
473        let input = r#"<p>This paragraph has reserved keywords like interface await default import export but should still render correctly.</p>"#;
474        let output = transpile_jsx_simple(input).unwrap();
475        
476        // Should successfully transpile
477        assert!(output.contains("__hook_jsx_runtime.jsx"));
478        assert!(output.contains("p"));
479        
480        // Text content should be preserved
481        assert!(output.contains("interface"));
482        assert!(output.contains("await"));
483        assert!(output.contains("default"));
484        assert!(output.contains("import"));
485        assert!(output.contains("export"));
486    }
487
488    #[test]
489    fn test_transform_es6_modules() {
490        let input = r#"import { useState } from 'react';
491export default function MyComponent() {
492  return <div>Test</div>;
493}"#;
494        let output = transform_es6_modules(input);
495        
496        // Should convert import to require
497        assert!(output.contains("const { useState } = require('react')"));
498        
499        // Should convert export default
500        assert!(output.contains("module.exports.default = function MyComponent()"));
501    }
502
503    #[test]
504    fn test_transform_es6_named_exports() {
505        let input = "export { foo, bar as baz };";
506        let output = transform_es6_modules(input);
507        
508        assert!(output.contains("module.exports.foo = foo"));
509        assert!(output.contains("module.exports.baz = bar"));
510    }
511
512    #[test]
513    fn test_transform_es6_side_effect_imports() {
514        let input = r#"import 'styles.css';"#;
515        let output = transform_es6_modules(input);
516        
517        assert!(output.contains("require('styles.css')"));
518    }
519
520    #[test]
521    fn test_extract_imports_for_prefetch() {
522        let input = r#"
523import React from 'react';
524import { useState, useEffect } from 'react';
525import * as Utils from './utils';
526const lazyComp = import('./LazyComponent');
527import 'styles.css';
528"#;
529        let imports = extract_imports(input);
530        
531        // Should have 5 imports (React default, React named, Utils namespace, LazyComponent lazy, styles side-effect)
532        assert_eq!(imports.len(), 5);
533        
534        // Verify each import can be used for pre-fetching
535        assert_eq!(imports[0].module, "react");
536        assert!(imports[0].is_default);
537        assert!(!imports[0].is_lazy);
538        
539        assert_eq!(imports[1].module, "react");
540        assert!(!imports[1].is_default);
541        assert_eq!(imports[1].imported.len(), 2);
542        
543        assert_eq!(imports[2].module, "./utils");
544        assert!(imports[2].is_namespace);
545        
546        assert_eq!(imports[3].module, "./LazyComponent");
547        assert!(imports[3].is_lazy);
548        
549        // Side-effect import (no imported names)
550        assert_eq!(imports[4].module, "styles.css");
551        assert!(imports[4].imported.is_empty());
552    }
553
554    #[test]
555    fn test_extract_imports_with_aliases() {
556        let input = "import { useState as State, useEffect as Effect } from 'react';";
557        let imports = extract_imports(input);
558        
559        assert_eq!(imports.len(), 1);
560        assert_eq!(imports[0].imported.len(), 2);
561        assert!(imports[0].imported.contains(&"State".to_string()));
562        assert!(imports[0].imported.contains(&"Effect".to_string()));
563    }
564
565    #[test]
566    fn test_extract_imports_scoped_packages() {
567        let input = "import { Logger } from '@myorg/logging';";
568        let imports = extract_imports(input);
569        
570        assert_eq!(imports.len(), 1);
571        assert_eq!(imports[0].module, "@myorg/logging");
572        assert_eq!(imports[0].imported, vec!["Logger"]);
573    }
574
575    #[test]
576    fn test_combined_module_transformation_and_extraction() {
577        let source = r#"
578import React, { useState } from 'react';
579import './styles.css';
580
581export const useMyHook = () => {
582  const [state, setState] = useState(null);
583  return state;
584};
585
586export default function Component() {
587  return <div>Test</div>;
588}
589"#;
590        
591        // Extract imports for static analysis
592        let imports = extract_imports(source);
593        
594        // Should have 2 imports: 
595        // 1. React and useState from react
596        // 2. styles.css side-effect
597        assert!(imports.len() >= 2);
598        
599        // Find the react import (may be split or combined depending on parser)
600        let react_imports: Vec<_> = imports.iter()
601            .filter(|i| i.module == "react")
602            .collect();
603        assert!(!react_imports.is_empty());
604        
605        // Find the styles import
606        let styles_import = imports.iter()
607            .find(|i| i.module == "./styles.css");
608        assert!(styles_import.is_some());
609        
610        // Transform to CommonJS
611        let transformed = transform_es6_modules(source);
612        assert!(transformed.contains("require('react')") || transformed.contains("require(\"react\")"));
613        assert!(transformed.contains("require('./styles.css')") || transformed.contains("require(\"./styles.css\")"));
614        assert!(transformed.contains("module.exports.useMyHook"));
615        assert!(transformed.contains("module.exports.default"));
616    }
617
618    #[test]
619    fn test_lazy_import_extraction() {
620        let source = r#"
621const lazyForm = import('./forms/FormComponent');
622const lazyModal = import('./modals/Modal');
623"#;
624        let imports = extract_imports(source);
625        
626        assert_eq!(imports.len(), 2);
627        assert!(imports[0].is_lazy);
628        assert!(imports[1].is_lazy);
629        assert_eq!(imports[0].module, "./forms/FormComponent");
630        assert_eq!(imports[1].module, "./modals/Modal");
631    }
632}