Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 143 additions & 7 deletions src/generators/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ pub mod ts;
pub mod zod;

use crate::analysis::CommandAnalyzer;
use crate::models::{CommandInfo, EventInfo, StructInfo};
use crate::models::{CommandInfo, EnumVariantKind, EventInfo, StructInfo};
use crate::GenerateConfig;
use base::template_context::{CommandContext, EventContext, FieldContext, StructContext};
use base::type_visitor::TypeVisitor;
Expand Down Expand Up @@ -166,6 +166,103 @@ impl TypeCollector {
}
}

/// Return generated type names in deterministic dependency-first order.
pub fn sort_struct_names_for_generation(
used_structs: &HashMap<String, StructInfo>,
) -> Vec<String> {
let mut type_names: Vec<_> = used_structs.keys().cloned().collect();
type_names.sort();

let mut sorted = Vec::new();
let mut visited = std::collections::HashSet::new();
let mut visiting = std::collections::HashSet::new();

for type_name in type_names {
Self::visit_struct_for_generation(
&type_name,
used_structs,
&mut sorted,
&mut visited,
&mut visiting,
);
}

sorted
}

fn visit_struct_for_generation(
type_name: &str,
used_structs: &HashMap<String, StructInfo>,
sorted: &mut Vec<String>,
visited: &mut std::collections::HashSet<String>,
visiting: &mut std::collections::HashSet<String>,
) {
if visited.contains(type_name) || !used_structs.contains_key(type_name) {
return;
}

if !visiting.insert(type_name.to_string()) {
return;
}

if let Some(struct_info) = used_structs.get(type_name) {
let mut dependencies = std::collections::HashSet::new();
Self::collect_struct_dependencies(struct_info, &mut dependencies);

let mut dependencies: Vec<_> = dependencies
.into_iter()
.filter(|dependency| {
dependency != type_name && used_structs.contains_key(dependency)
})
.collect();
dependencies.sort();

for dependency in dependencies {
Self::visit_struct_for_generation(
&dependency,
used_structs,
sorted,
visited,
visiting,
);
}
}

visiting.remove(type_name);
visited.insert(type_name.to_string());
sorted.push(type_name.to_string());
}

fn collect_struct_dependencies(
struct_info: &StructInfo,
dependencies: &mut std::collections::HashSet<String>,
) {
for field in &struct_info.fields {
Self::collect_referenced_types_from_structure(&field.type_structure, dependencies);
}

if let Some(variants) = &struct_info.enum_variants {
for variant in variants {
match &variant.kind {
EnumVariantKind::Tuple(types) => {
for ty in types {
Self::collect_referenced_types_from_structure(ty, dependencies);
}
}
EnumVariantKind::Struct(fields) => {
for field in fields {
Self::collect_referenced_types_from_structure(
&field.type_structure,
dependencies,
);
}
}
EnumVariantKind::Unit => {}
}
}
}
}

/// Create CommandContext instances from CommandInfo using the provided visitor
pub fn create_command_contexts<V: TypeVisitor>(
&self,
Expand All @@ -176,14 +273,23 @@ impl TypeCollector {
) -> Vec<CommandContext> {
let type_resolver = analyzer.get_type_resolver();

commands
let mut contexts: Vec<_> = commands
.iter()
.map(|cmd| {
CommandContext::new(config).from_command_info(cmd, visitor, &|rust_type: &str| {
type_resolver.borrow_mut().parse_type_structure(rust_type)
})
})
.collect()
.collect();

contexts.sort_by(|a, b| {
a.ts_function_name
.cmp(&b.ts_function_name)
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.file_path.cmp(&b.file_path))
.then_with(|| a.line_number.cmp(&b.line_number))
});
contexts
}

/// Create EventContext instances from EventInfo using the provided visitor
Expand All @@ -196,14 +302,23 @@ impl TypeCollector {
) -> Vec<EventContext> {
let type_resolver = analyzer.get_type_resolver();

events
let mut contexts: Vec<_> = events
.iter()
.map(|event| {
EventContext::new(config).from_event_info(event, visitor, &|rust_type: &str| {
type_resolver.borrow_mut().parse_type_structure(rust_type)
})
})
.collect()
.collect();

contexts.sort_by(|a, b| {
a.ts_function_name
.cmp(&b.ts_function_name)
.then_with(|| a.event_name.cmp(&b.event_name))
.then_with(|| a.file_path.cmp(&b.file_path))
.then_with(|| a.line_number.cmp(&b.line_number))
});
contexts
}

/// Create StructContext instances from StructInfo using the provided visitor
Expand All @@ -213,14 +328,35 @@ impl TypeCollector {
visitor: &V,
config: &GenerateConfig,
) -> Vec<StructContext> {
used_structs
.iter()
let mut structs: Vec<_> = used_structs.iter().collect();
structs.sort_by(|(a, _), (b, _)| a.cmp(b));

structs
.into_iter()
.map(|(name, struct_info)| {
StructContext::new(config).from_struct_info(name, struct_info, visitor)
})
.collect()
}

/// Create StructContext instances following a caller-provided type order.
pub fn create_struct_contexts_in_order<V: TypeVisitor>(
&self,
type_names: &[String],
used_structs: &HashMap<String, StructInfo>,
visitor: &V,
config: &GenerateConfig,
) -> Vec<StructContext> {
type_names
.iter()
.filter_map(|name| {
used_structs.get(name).map(|struct_info| {
StructContext::new(config).from_struct_info(name, struct_info, visitor)
})
})
.collect()
}

/// Create FieldContext instances from StructInfo using the provided visitor
pub fn create_field_contexts<V: TypeVisitor>(
&self,
Expand Down
12 changes: 8 additions & 4 deletions src/generators/ts/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,14 @@ impl TypeScriptBindingsGenerator {
let has_channels = commands.iter().any(|cmd| !cmd.channels.is_empty());
let visitor = TypeScriptVisitor::with_config(config);

// Convert structs to context wrappers
let struct_context = self
.collector
.create_struct_contexts(used_structs, &visitor, config);
// Convert structs to context wrappers in dependency-safe, deterministic output order.
let sorted_types = TypeCollector::sort_struct_names_for_generation(used_structs);
let struct_context = self.collector.create_struct_contexts_in_order(
&sorted_types,
used_structs,
&visitor,
config,
);

// Convert commands to context wrappers
let command_context = self
Expand Down
7 changes: 3 additions & 4 deletions src/generators/zod/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::generators::zod::type_visitor::ZodVisitor;
use crate::generators::TypeCollector;
use crate::models::{CommandInfo, EventInfo, StructInfo};
use crate::GenerateConfig;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use tera::{Context, Tera};

/// Generator for Zod schema-based TypeScript bindings with validation
Expand Down Expand Up @@ -121,9 +121,8 @@ impl ZodBindingsGenerator {
analyzer: &CommandAnalyzer,
config: &GenerateConfig,
) -> String {
// Sort structs topologically
let type_names: HashSet<String> = used_structs.keys().cloned().collect();
let sorted_types = analyzer.topological_sort_types(&type_names);
// Sort structs in dependency-safe, deterministic output order.
let sorted_types = TypeCollector::sort_struct_names_for_generation(used_structs);

// Generate struct schemas
let mut struct_schemas = String::new();
Expand Down
127 changes: 127 additions & 0 deletions tests/integration_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,133 @@ fn test_deeply_nested_types_full_pipeline() {
);
}

/// Test generated output ordering is stable across files and dependency graphs.
#[test]
fn test_generated_output_has_stable_order() {
fn assert_before(content: &str, first: &str, second: &str) {
let first_pos = content
.find(first)
.unwrap_or_else(|| panic!("Missing `{}` in:\n{}", first, content));
let second_pos = content
.find(second)
.unwrap_or_else(|| panic!("Missing `{}` in:\n{}", second, content));
assert!(
first_pos < second_pos,
"Expected `{}` before `{}` in:\n{}",
first,
second,
content
);
}

let project = TestProject::new();

project.write_file(
"b_commands.rs",
r#"
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Parent {
pub child: Child,
pub note: String,
}

#[tauri::command]
pub fn beta_command(parent: Parent) -> Result<Parent, String> {
Ok(parent)
}

pub fn emit_beta(app: tauri::AppHandle, payload: Parent) {
app.emit("beta-event", payload).ok();
}
"#,
);

project.write_file(
"a_commands.rs",
r#"
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Child {
pub id: String,
}

#[tauri::command]
pub fn alpha_command(id: String) -> Child {
Child { id }
}

pub fn emit_alpha(app: tauri::AppHandle, payload: Child) {
app.emit("alpha-event", payload).ok();
}
"#,
);

let (analyzer, commands) = project.analyze();
assert_eq!(commands.len(), 2);
assert_eq!(analyzer.get_discovered_events().len(), 2);

let vanilla_generator = TestGenerator::new();
vanilla_generator.generate(
&commands,
analyzer.get_discovered_structs(),
&analyzer,
Some("none"),
None,
);

let vanilla_types = vanilla_generator.read_file("types.ts");
assert_before(
&vanilla_types,
"export interface Child",
"export interface Parent",
);
assert_before(
&vanilla_types,
"export interface AlphaCommandParams",
"export interface BetaCommandParams",
);

let vanilla_commands = vanilla_generator.read_file("commands.ts");
assert_before(
&vanilla_commands,
"export async function alphaCommand",
"export async function betaCommand",
);

let vanilla_events = vanilla_generator.read_file("events.ts");
assert_before(&vanilla_events, "onAlphaEvent", "onBetaEvent");

let zod_generator = TestGenerator::new();
zod_generator.generate(
&commands,
analyzer.get_discovered_structs(),
&analyzer,
Some("zod"),
None,
);

let zod_types = zod_generator.read_file("types.ts");
assert_before(&zod_types, "ChildSchema", "ParentSchema");
assert_before(
&zod_types,
"AlphaCommandParamsSchema",
"BetaCommandParamsSchema",
);

let zod_commands = zod_generator.read_file("commands.ts");
assert_before(
&zod_commands,
"export async function alphaCommand",
"export async function betaCommand",
);

let zod_events = zod_generator.read_file("events.ts");
assert_before(&zod_events, "onAlphaEvent", "onBetaEvent");
}

/// Test event payload type discovery when emitting from helper functions
/// Verifies that variable types are correctly inferred from function parameters
#[test]
Expand Down