Skip to content

Commit a566357

Browse files
committed
fix: normalize generated output formatting
1 parent 29bffec commit a566357

19 files changed

Lines changed: 832 additions & 152 deletions

src/generators/ts/generator.rs

Lines changed: 346 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
210220
mod 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\nexport * from './types';"));
336+
assert!(result.contains("export * from './types';\nexport * from './commands';"));
337+
assert!(!result.contains("export * from './types';\n\nexport * 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\nexport async function alphaCommand"
370+
),
371+
"unexpected render:\n{rendered}"
372+
);
373+
assert!(
374+
rendered.contains(
375+
"return invoke('alpha_command', params);\n}\n\nexport async function betaCommand"
376+
),
377+
"unexpected render:\n{rendered}"
378+
);
379+
assert!(
380+
!rendered.contains(
381+
"return invoke('alpha_command', params);\n}\n\n\nexport 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

Comments
 (0)