@@ -37,21 +37,26 @@ impl TypeScriptBindingsGenerator {
3737 let visitor = TypeScriptVisitor :: with_config ( config) ;
3838
3939 // Convert structs to context wrappers
40- let struct_context = self
40+ let struct_contexts = self
4141 . collector
4242 . create_struct_contexts ( used_structs, & visitor, config) ;
4343
4444 // Convert commands to context wrappers
45- let command_context = self
45+ let command_contexts = self
4646 . collector
4747 . create_command_contexts ( commands, & visitor, analyzer, config) ;
48+ let param_commands = command_contexts
49+ . iter ( )
50+ . filter ( |command| !command. parameters . is_empty ( ) || !command. channels . is_empty ( ) )
51+ . cloned ( )
52+ . collect :: < Vec < _ > > ( ) ;
4853
4954 // Render main types.ts template
5055 let mut context = Context :: new ( ) ;
5156 context. insert ( "header" , & self . generate_file_header ( ) ) ;
5257 context. insert ( "has_channels" , & has_channels) ;
53- context. insert ( "structs" , & struct_context ) ;
54- context. insert ( "commands " , & command_context ) ;
58+ context. insert ( "structs" , & struct_contexts ) ;
59+ context. insert ( "param_commands " , & param_commands ) ;
5560
5661 self . render ( "typescript/types.ts.tera" , & context)
5762 . unwrap_or_else ( |e| {
@@ -89,9 +94,14 @@ impl TypeScriptBindingsGenerator {
8994
9095 /// Generate index.ts file
9196 fn generate_index_file ( & self , generated_files : & [ String ] ) -> String {
97+ let modules = generated_files
98+ . iter ( )
99+ . filter ( |file| file. as_str ( ) != "index.ts" )
100+ . cloned ( )
101+ . collect :: < Vec < _ > > ( ) ;
92102 let mut context = Context :: new ( ) ;
93103 context. insert ( "header" , & self . generate_file_header ( ) ) ;
94- context. insert ( "files " , generated_files ) ;
104+ context. insert ( "modules " , & modules ) ;
95105
96106 self . render ( "typescript/index.ts.tera" , & context)
97107 . unwrap_or_else ( |e| {
@@ -209,6 +219,10 @@ impl Default for TypeScriptBindingsGenerator {
209219#[ cfg( test) ]
210220mod tests {
211221 use super :: * ;
222+ use crate :: analysis:: CommandAnalyzer ;
223+ use crate :: models:: { EventInfo , FieldInfo , StructInfo , TypeStructure } ;
224+ use crate :: GenerateConfig ;
225+ use std:: collections:: HashMap ;
212226
213227 mod initialization {
214228 use super :: * ;
@@ -290,6 +304,7 @@ mod tests {
290304
291305 mod helper_methods {
292306 use super :: * ;
307+ use crate :: models:: { ParameterInfo , TypeStructure } ;
293308
294309 #[ test]
295310 fn test_generate_index_file_with_empty_files ( ) {
@@ -306,5 +321,331 @@ mod tests {
306321 let result = gen. generate_index_file ( & files) ;
307322 assert ! ( !result. is_empty( ) ) ;
308323 }
324+
325+ #[ test]
326+ fn test_generate_index_file_skips_index_without_blank_lines ( ) {
327+ let gen = TypeScriptBindingsGenerator :: new ( ) ;
328+ let files = vec ! [
329+ "types.ts" . to_string( ) ,
330+ "index.ts" . to_string( ) ,
331+ "commands.ts" . to_string( ) ,
332+ ] ;
333+ let result = result_without_timestamp ( & gen. generate_index_file ( & files) ) ;
334+
335+ assert ! ( result. contains( " */\n \n export * from './types';" ) ) ;
336+ assert ! ( result. contains( "export * from './types';\n export * from './commands';" ) ) ;
337+ assert ! ( !result. contains( "export * from './types';\n \n export * from './commands';" ) ) ;
338+ }
339+
340+ #[ test]
341+ fn test_generate_command_bindings_avoid_blank_lines_between_functions ( ) {
342+ let gen = TypeScriptBindingsGenerator :: new ( ) ;
343+ let analyzer = CommandAnalyzer :: new ( ) ;
344+ let config = GenerateConfig :: default ( ) ;
345+ let commands = vec ! [
346+ CommandInfo :: new_for_test(
347+ "alpha_command" ,
348+ "a.rs" ,
349+ 1 ,
350+ vec![ ParameterInfo {
351+ name: "value" . to_string( ) ,
352+ rust_type: "String" . to_string( ) ,
353+ is_optional: false ,
354+ type_structure: TypeStructure :: Primitive ( "string" . to_string( ) ) ,
355+ serde_rename: None ,
356+ } ] ,
357+ "Alpha" ,
358+ false ,
359+ vec![ ] ,
360+ ) ,
361+ CommandInfo :: new_for_test( "beta_command" , "b.rs" , 1 , vec![ ] , "Beta" , false , vec![ ] ) ,
362+ ] ;
363+ let rendered = result_without_timestamp (
364+ & gen. generate_command_bindings ( & commands, & analyzer, & config) ,
365+ ) ;
366+
367+ assert ! (
368+ rendered. contains(
369+ "import * as types from './types';\n \n export async function alphaCommand"
370+ ) ,
371+ "unexpected render:\n {rendered}"
372+ ) ;
373+ assert ! (
374+ rendered. contains(
375+ "return invoke('alpha_command', params);\n }\n \n export async function betaCommand"
376+ ) ,
377+ "unexpected render:\n {rendered}"
378+ ) ;
379+ assert ! (
380+ !rendered. contains(
381+ "return invoke('alpha_command', params);\n }\n \n \n export async function betaCommand"
382+ ) ,
383+ "unexpected render:\n {rendered}"
384+ ) ;
385+ }
386+
387+ #[ test]
388+ fn test_generate_events_file_has_single_blank_line_between_listeners ( ) {
389+ let gen = TypeScriptBindingsGenerator :: new ( ) ;
390+ let analyzer = CommandAnalyzer :: new ( ) ;
391+ let config = GenerateConfig :: default ( ) ;
392+ let events = vec ! [
393+ EventInfo {
394+ event_name: "alpha-ready" . to_string( ) ,
395+ payload_type: "String" . to_string( ) ,
396+ payload_type_structure: TypeStructure :: Primitive ( "string" . to_string( ) ) ,
397+ file_path: "a.rs" . to_string( ) ,
398+ line_number: 1 ,
399+ } ,
400+ EventInfo {
401+ event_name: "beta-ready" . to_string( ) ,
402+ payload_type: "String" . to_string( ) ,
403+ payload_type_structure: TypeStructure :: Primitive ( "string" . to_string( ) ) ,
404+ file_path: "b.rs" . to_string( ) ,
405+ line_number: 2 ,
406+ } ,
407+ ] ;
408+ let rendered =
409+ result_without_timestamp ( & gen. generate_events_file ( & events, & analyzer, & config) ) ;
410+
411+ assert ! (
412+ rendered. contains( " });\n }\n \n /**\n * Listen for 'beta-ready' events" ) ,
413+ "unexpected render:\n {rendered}"
414+ ) ;
415+ assert ! (
416+ !rendered. contains( " });\n }\n \n \n /**\n * Listen for 'beta-ready' events" ) ,
417+ "unexpected render:\n {rendered}"
418+ ) ;
419+ }
420+
421+ #[ test]
422+ fn test_generate_types_file_keeps_blank_line_after_header ( ) {
423+ let gen = TypeScriptBindingsGenerator :: new ( ) ;
424+ let analyzer = CommandAnalyzer :: new ( ) ;
425+ let config = GenerateConfig :: default ( ) ;
426+ let rendered = result_without_timestamp ( & gen. generate_types_file_content (
427+ & [ ] ,
428+ & HashMap :: new ( ) ,
429+ & analyzer,
430+ & config,
431+ ) ) ;
432+
433+ assert ! (
434+ rendered. contains( " */\n \n " ) ,
435+ "unexpected render:\n {rendered}"
436+ ) ;
437+ }
438+
439+ #[ test]
440+ fn test_generate_types_file_compacts_param_interfaces ( ) {
441+ let gen = TypeScriptBindingsGenerator :: new ( ) ;
442+ let analyzer = CommandAnalyzer :: new ( ) ;
443+ let config = GenerateConfig :: default ( ) ;
444+ let commands = vec ! [ CommandInfo :: new_for_test(
445+ "abort_loopback_fetch" ,
446+ "test.rs" ,
447+ 1 ,
448+ vec![ ParameterInfo {
449+ name: "request_id" . to_string( ) ,
450+ rust_type: "String" . to_string( ) ,
451+ is_optional: false ,
452+ type_structure: TypeStructure :: Primitive ( "string" . to_string( ) ) ,
453+ serde_rename: None ,
454+ } ] ,
455+ "void" ,
456+ false ,
457+ vec![ ] ,
458+ ) ] ;
459+ let rendered = result_without_timestamp ( & gen. generate_types_file_content (
460+ & commands,
461+ & HashMap :: new ( ) ,
462+ & analyzer,
463+ & config,
464+ ) ) ;
465+
466+ assert ! (
467+ rendered. contains(
468+ "export interface AbortLoopbackFetchParams {\n requestId: string;\n [key: string]: unknown;\n }"
469+ ) ,
470+ "unexpected render:\n {rendered}"
471+ ) ;
472+ }
473+
474+ fn result_without_timestamp ( content : & str ) -> String {
475+ content
476+ . lines ( )
477+ . map ( |line| {
478+ if line. starts_with ( " * Generated at:" ) {
479+ " * Generated at: <normalized>" . to_string ( )
480+ } else {
481+ line. to_string ( )
482+ }
483+ } )
484+ . collect :: < Vec < _ > > ( )
485+ . join ( "\n " )
486+ }
487+ }
488+
489+ mod determinism {
490+ use super :: * ;
491+
492+ fn create_test_config ( ) -> GenerateConfig {
493+ GenerateConfig {
494+ project_path : "." . to_string ( ) ,
495+ output_path : "./generated" . to_string ( ) ,
496+ validation_library : "none" . to_string ( ) ,
497+ verbose : Some ( false ) ,
498+ visualize_deps : Some ( false ) ,
499+ include_private : Some ( false ) ,
500+ type_mappings : None ,
501+ exclude_patterns : None ,
502+ include_patterns : None ,
503+ default_parameter_case : "camelCase" . to_string ( ) ,
504+ default_field_case : "snake_case" . to_string ( ) ,
505+ force : Some ( false ) ,
506+ }
507+ }
508+
509+ fn create_test_struct ( name : & str , rust_type : & str , ts_type : & str ) -> StructInfo {
510+ StructInfo {
511+ name : name. to_string ( ) ,
512+ fields : vec ! [ FieldInfo {
513+ name: "value" . to_string( ) ,
514+ rust_type: rust_type. to_string( ) ,
515+ is_optional: false ,
516+ is_public: true ,
517+ type_structure: TypeStructure :: Primitive ( ts_type. to_string( ) ) ,
518+ serde_rename: None ,
519+ validator_attributes: None ,
520+ } ] ,
521+ file_path : format ! ( "{name}.rs" ) ,
522+ is_enum : false ,
523+ serde_rename_all : None ,
524+ serde_tag : None ,
525+ enum_variants : None ,
526+ }
527+ }
528+
529+ fn create_test_event ( event_name : & str , file_path : & str , line_number : usize ) -> EventInfo {
530+ EventInfo {
531+ event_name : event_name. to_string ( ) ,
532+ payload_type : "String" . to_string ( ) ,
533+ payload_type_structure : TypeStructure :: Primitive ( "string" . to_string ( ) ) ,
534+ file_path : file_path. to_string ( ) ,
535+ line_number,
536+ }
537+ }
538+
539+ fn normalize_generated_output ( content : & str ) -> String {
540+ content
541+ . lines ( )
542+ . map ( |line| {
543+ if line. starts_with ( " * Generated at:" ) {
544+ " * Generated at: <normalized>" . to_string ( )
545+ } else {
546+ line. to_string ( )
547+ }
548+ } )
549+ . collect :: < Vec < _ > > ( )
550+ . join ( "\n " )
551+ }
552+
553+ #[ test]
554+ fn deterministic_output_for_reversed_inputs ( ) {
555+ let generator = TypeScriptBindingsGenerator :: new ( ) ;
556+ let analyzer = CommandAnalyzer :: new ( ) ;
557+ let config = create_test_config ( ) ;
558+
559+ let commands1 = vec ! [
560+ CommandInfo :: new_for_test(
561+ "alpha_command" ,
562+ "b.rs" ,
563+ 1 ,
564+ vec![ ] ,
565+ "Alpha" ,
566+ false ,
567+ vec![ ] ,
568+ ) ,
569+ CommandInfo :: new_for_test( "beta_command" , "a.rs" , 1 , vec![ ] , "Beta" , false , vec![ ] ) ,
570+ ] ;
571+ let commands2 = vec ! [
572+ CommandInfo :: new_for_test( "beta_command" , "a.rs" , 1 , vec![ ] , "Beta" , false , vec![ ] ) ,
573+ CommandInfo :: new_for_test(
574+ "alpha_command" ,
575+ "b.rs" ,
576+ 1 ,
577+ vec![ ] ,
578+ "Alpha" ,
579+ false ,
580+ vec![ ] ,
581+ ) ,
582+ ] ;
583+
584+ let mut structs1 = HashMap :: new ( ) ;
585+ structs1. insert (
586+ "Alpha" . to_string ( ) ,
587+ create_test_struct ( "Alpha" , "String" , "string" ) ,
588+ ) ;
589+ structs1. insert (
590+ "Beta" . to_string ( ) ,
591+ create_test_struct ( "Beta" , "i32" , "number" ) ,
592+ ) ;
593+
594+ let mut structs2 = HashMap :: new ( ) ;
595+ structs2. insert (
596+ "Beta" . to_string ( ) ,
597+ create_test_struct ( "Beta" , "i32" , "number" ) ,
598+ ) ;
599+ structs2. insert (
600+ "Alpha" . to_string ( ) ,
601+ create_test_struct ( "Alpha" , "String" , "string" ) ,
602+ ) ;
603+
604+ let events1 = vec ! [
605+ create_test_event( "beta-ready" , "b.rs" , 20 ) ,
606+ create_test_event( "alpha-ready" , "a.rs" , 10 ) ,
607+ ] ;
608+ let events2 = vec ! [
609+ create_test_event( "alpha-ready" , "a.rs" , 10 ) ,
610+ create_test_event( "beta-ready" , "b.rs" , 20 ) ,
611+ ] ;
612+
613+ let types1 =
614+ generator. generate_types_file_content ( & commands1, & structs1, & analyzer, & config) ;
615+ let types2 =
616+ generator. generate_types_file_content ( & commands2, & structs2, & analyzer, & config) ;
617+ let commands_file1 =
618+ generator. generate_command_bindings ( & commands1, & analyzer, & config) ;
619+ let commands_file2 =
620+ generator. generate_command_bindings ( & commands2, & analyzer, & config) ;
621+ let events_file1 = generator. generate_events_file ( & events1, & analyzer, & config) ;
622+ let events_file2 = generator. generate_events_file ( & events2, & analyzer, & config) ;
623+
624+ assert_eq ! (
625+ normalize_generated_output( & types1) ,
626+ normalize_generated_output( & types2)
627+ ) ;
628+ assert_eq ! (
629+ normalize_generated_output( & commands_file1) ,
630+ normalize_generated_output( & commands_file2)
631+ ) ;
632+ assert_eq ! (
633+ normalize_generated_output( & events_file1) ,
634+ normalize_generated_output( & events_file2)
635+ ) ;
636+
637+ for ( file_name, content) in [
638+ ( "types.ts" , & types1) ,
639+ ( "commands.ts" , & commands_file1) ,
640+ ( "events.ts" , & events_file1) ,
641+ ] {
642+ let normalized = normalize_generated_output ( & content) ;
643+ assert ! (
644+ !normalized. contains( "\n \n \n " ) ,
645+ "unexpected blank lines in {file_name}:\n {normalized}"
646+ ) ;
647+ assert ! ( content. ends_with( '\n' ) ) ;
648+ }
649+ }
309650 }
310651}
0 commit comments