@@ -312,6 +312,111 @@ pub fn transpile_to_js_with_entry_validation(
312312 js_generator. transpile ( & ast)
313313}
314314
315+ /// Transpile a project consisting of multiple files with shared extern context
316+ ///
317+ /// This function implements a two-pass compilation strategy:
318+ /// 1. First pass: Collect all extern declarations from all files
319+ /// 2. Second pass: Analyze and transpile each file with full extern visibility
320+ ///
321+ /// # Arguments
322+ /// * `files` - All source files in the project
323+ /// * `target` - Target JavaScript environment (e.g., "node-esm", "browser", etc.)
324+ /// * `main_entry` - Optional path to the main entry file (for entry point validation)
325+ /// * `project_root` - Optional project root directory for module resolution
326+ ///
327+ /// # Returns
328+ /// A `ProjectCompilationResult` containing the transpiled JavaScript for each file
329+ pub fn transpile_project (
330+ files : Vec < ProjectFile > ,
331+ target : & str ,
332+ main_entry : Option < & std:: path:: Path > ,
333+ project_root : Option < & std:: path:: Path > ,
334+ ) -> Result < ProjectCompilationResult > {
335+ // Pass 1: Collect all extern declarations from all files
336+ let mut project_externs = ProjectExterns :: new ( ) ;
337+
338+ for file in & files {
339+ // Parse the file
340+ let mut lexer = Lexer :: new ( file. content . clone ( ) ) ;
341+ let tokens = lexer. lex_all ( ) ;
342+ let mut parser = Parser :: new ( tokens) ;
343+ let ast = parser. parse ( ) ?;
344+
345+ // Collect extern declarations
346+ let file_externs = SemanticVisitor :: collect_extern_declarations ( & ast) ;
347+
348+ // Merge into project externs
349+ project_externs. merge ( file_externs) ?;
350+ }
351+
352+ // Pass 2: Analyze and transpile with full context
353+ let mut results = Vec :: new ( ) ;
354+
355+ for file in files {
356+ // Parse the file again
357+ let mut lexer = Lexer :: new ( file. content . clone ( ) ) ;
358+ let tokens = lexer. lex_all ( ) ;
359+ let mut parser = Parser :: new ( tokens) ;
360+ let ast = parser. parse ( ) ?;
361+
362+ // Create analyzer with shared extern context
363+ let mut analyzer = SemanticVisitor :: with_project_context (
364+ project_externs. clone ( ) ,
365+ file. path . clone ( ) ,
366+ project_root. map ( |p| p. to_path_buf ( ) ) ,
367+ ) ;
368+
369+ // Analyze with full extern visibility
370+ analyzer. analyze ( & ast) ?;
371+
372+ // Check if this is the main entry point
373+ let is_main = main_entry. map_or ( false , |entry| entry == file. path ) ;
374+
375+ // Validate main entry point if needed
376+ if is_main {
377+ let has_main = ast. iter ( ) . any ( |stmt| {
378+ matches ! (
379+ stmt,
380+ Stmt :: Function ( _, _, name, _, _, _, _, _)
381+ | Stmt :: AsyncFunction ( _, _, name, _, _, _, _, _)
382+ if name == "main"
383+ )
384+ } ) ;
385+
386+ if !has_main {
387+ return Err ( Error :: new_semantic (
388+ "Main entry point must contain a 'main' function" . to_string ( ) ,
389+ crate :: span:: Span :: default ( ) ,
390+ ) ) ;
391+ }
392+ }
393+
394+ // Generate JavaScript
395+ let mut js_generator = match JsTranspiler :: with_target ( target) {
396+ Ok ( transpiler) => transpiler,
397+ Err ( _) => JsTranspiler :: new ( ) ,
398+ } ;
399+
400+ // Pass type-only imports and extern functions to transpiler
401+ js_generator. set_type_only_imports ( analyzer. get_type_only_imports ( ) . clone ( ) ) ;
402+ js_generator. set_extern_functions ( analyzer. get_extern_functions ( ) . clone ( ) ) ;
403+
404+ let javascript = js_generator. transpile ( & ast) ?;
405+
406+ // Compute output path (same directory structure, .js extension)
407+ let output_path = file. path . with_extension ( "js" ) ;
408+
409+ results. push ( CompiledFile {
410+ source_path : file. path ,
411+ output_path,
412+ javascript,
413+ source_map : None , // Future enhancement
414+ } ) ;
415+ }
416+
417+ Ok ( ProjectCompilationResult { files : results } )
418+ }
419+
315420#[ cfg( test) ]
316421mod lib_tests {
317422 use super :: * ;
@@ -410,4 +515,123 @@ mod lib_tests {
410515 assert ! ( result. is_ok( ) , "Failed for target: {}" , target) ;
411516 }
412517 }
518+
519+ #[ test]
520+ fn test_transpile_project_with_extern_modules ( ) {
521+ // Create test files
522+ let types_file = ProjectFile {
523+ path : PathBuf :: from ( "types.husk" ) ,
524+ content : r#"
525+ extern mod fs {
526+ fn exists_sync(path: string) -> bool;
527+ fn read_file_sync(path: string) -> string;
528+ }
529+ "#
530+ . to_string ( ) ,
531+ } ;
532+
533+ let main_file = ProjectFile {
534+ path : PathBuf :: from ( "main.husk" ) ,
535+ content : r#"
536+ use fs::{exists_sync, read_file_sync};
537+
538+ fn main() {
539+ if exists_sync("test.txt") {
540+ let content = read_file_sync("test.txt");
541+ println!("File content: {}", content);
542+ }
543+ }
544+ "#
545+ . to_string ( ) ,
546+ } ;
547+
548+ let files = vec ! [ types_file, main_file] ;
549+
550+ // Transpile the project
551+ let result = transpile_project ( files, "node-esm" , Some ( & PathBuf :: from ( "main.husk" ) ) , None ) ;
552+
553+ assert ! ( result. is_ok( ) ) ;
554+ let compilation_result = result. unwrap ( ) ;
555+
556+ // Should have 2 compiled files
557+ assert_eq ! ( compilation_result. files. len( ) , 2 ) ;
558+
559+ // Find the main.js file
560+ let main_js = compilation_result
561+ . files
562+ . iter ( )
563+ . find ( |f| f. source_path == PathBuf :: from ( "main.husk" ) )
564+ . unwrap ( ) ;
565+
566+ // Debug: Print the JavaScript to see what's generated
567+ println ! ( "Generated JavaScript:\n {}" , main_js. javascript) ;
568+
569+ // Verify the JavaScript contains the extern function calls
570+ // The functions should be imported and used (exists_sync, not existsSync yet)
571+ assert ! (
572+ main_js. javascript. contains( "exists_sync" ) || main_js. javascript. contains( "existsSync" )
573+ ) ;
574+ assert ! (
575+ main_js. javascript. contains( "read_file_sync" )
576+ || main_js. javascript. contains( "readFileSync" )
577+ ) ;
578+ assert ! ( main_js. javascript. contains( "main()" ) ) ;
579+ }
580+
581+ #[ test]
582+ fn test_transpile_project_duplicate_extern_error ( ) {
583+ let file1 = ProjectFile {
584+ path : PathBuf :: from ( "file1.husk" ) ,
585+ content : r#"
586+ extern mod fs {
587+ fn exists_sync(path: string) -> bool;
588+ }
589+ "#
590+ . to_string ( ) ,
591+ } ;
592+
593+ let file2 = ProjectFile {
594+ path : PathBuf :: from ( "file2.husk" ) ,
595+ content : r#"
596+ extern mod fs {
597+ fn exists_sync(path: string) -> bool;
598+ }
599+ "#
600+ . to_string ( ) ,
601+ } ;
602+
603+ let files = vec ! [ file1, file2] ;
604+
605+ // Should fail due to duplicate extern declaration
606+ let result = transpile_project ( files, "node-esm" , None , None ) ;
607+ assert ! ( result. is_err( ) ) ;
608+ let err = result. unwrap_err ( ) ;
609+ assert ! ( err
610+ . to_string( )
611+ . contains( "Duplicate extern function 'fs::exists_sync'" ) ) ;
612+ }
613+
614+ #[ test]
615+ fn test_transpile_project_main_entry_validation ( ) {
616+ let file_without_main = ProjectFile {
617+ path : PathBuf :: from ( "lib.husk" ) ,
618+ content : r#"
619+ fn helper() {
620+ println!("Helper function");
621+ }
622+ "#
623+ . to_string ( ) ,
624+ } ;
625+
626+ let files = vec ! [ file_without_main] ;
627+
628+ // Should fail when specified as main entry but missing main function
629+ let result = transpile_project ( files, "node-esm" , Some ( & PathBuf :: from ( "lib.husk" ) ) , None ) ;
630+
631+ assert ! ( result. is_err( ) ) ;
632+ let err = result. unwrap_err ( ) ;
633+ assert ! ( err
634+ . to_string( )
635+ . contains( "Main entry point must contain a 'main' function" ) ) ;
636+ }
413637}
0 commit comments