From 888baa848d509ce861887b3770ab8f416a2ee0a7 Mon Sep 17 00:00:00 2001 From: hosted-fornet Date: Thu, 25 Sep 2025 12:07:28 -0700 Subject: [PATCH 1/3] build: allow multiple hyperapp uis; fix wit generation & ts issues --- Cargo.lock | 1 + src/build/caller_utils_ts_generator.rs | 675 +++++++++++++++++--- src/build/mod.rs | 3 +- src/build/wit_generator.rs | 818 +++++++++++++++++++++++-- 4 files changed, 1342 insertions(+), 155 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a2c95c7..21d83443 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2309,6 +2309,7 @@ dependencies = [ "sha2", "sha3", "syn 2.0.90", + "tempfile", "thiserror 1.0.63", "tokio", "toml", diff --git a/src/build/caller_utils_ts_generator.rs b/src/build/caller_utils_ts_generator.rs index bce2418f..ff459b1c 100644 --- a/src/build/caller_utils_ts_generator.rs +++ b/src/build/caller_utils_ts_generator.rs @@ -1,5 +1,6 @@ +use std::collections::HashMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use color_eyre::{eyre::WrapErr, Result}; use tracing::{debug, info, instrument, warn}; @@ -40,6 +41,42 @@ pub fn to_pascal_case(s: &str) -> String { result } +// Extract hyperapp name from WIT filename +fn extract_hyperapp_name(wit_file_path: &Path) -> Option { + wit_file_path + .file_stem() + .and_then(|s| s.to_str()) + .map(|filename| { + // Remove -sys-v0 suffix if present + let name = if filename.ends_with("-sys-v0") { + &filename[..filename.len() - 7] + } else { + filename + }; + + // Skip types- prefix files + if name.starts_with("types-") { + return extract_hyperapp_name_from_types(name); + } + + // Convert to PascalCase for namespace name + to_pascal_case(name) + }) +} + +// Extract hyperapp name from types- prefixed files +fn extract_hyperapp_name_from_types(filename: &str) -> String { + // types-spider-sys-v0 -> Spider + // types-ttstt-sys-v0 -> Ttstt + let name = filename.strip_prefix("types-").unwrap_or(filename); + let name = if name.ends_with("-sys-v0") { + &name[..name.len() - 7] + } else { + name + }; + to_pascal_case(name) +} + // Convert WIT type to TypeScript type fn wit_type_to_typescript(wit_type: &str) -> String { match wit_type { @@ -195,9 +232,23 @@ struct WitRecord { fields: Vec, } +// Structure to represent a WIT variant case with optional data +#[derive(Debug)] +struct WitVariantCase { + name: String, + data_type: Option, +} + // Structure to represent a WIT variant #[derive(Debug)] struct WitVariant { + name: String, + cases: Vec, +} + +// Structure to represent a WIT enum (variant without data) +#[derive(Debug)] +struct WitEnum { name: String, cases: Vec, } @@ -207,6 +258,16 @@ struct WitTypes { signatures: Vec, records: Vec, variants: Vec, + enums: Vec, +} + +// Structure to hold types grouped by hyperapp +struct HyperappTypes { + _name: String, + signatures: Vec, + records: Vec, + variants: Vec, + enums: Vec, } // Parse WIT file to extract function signatures, records, and variants @@ -220,6 +281,7 @@ fn parse_wit_file(file_path: &Path) -> Result { let mut signatures = Vec::new(); let mut records = Vec::new(); let mut variants = Vec::new(); + let mut enums = Vec::new(); // Simple parser for WIT files to extract record definitions let lines: Vec<_> = content.lines().collect(); @@ -351,16 +413,24 @@ fn parse_wit_file(file_path: &Path) -> Result { continue; } - // Parse case - just the name, ignoring any associated data for now + // Parse case with optional associated data let case_raw = case_line.trim_end_matches(','); - // Extract case name (might have associated type in parentheses) - let case_name = if let Some(paren_pos) = case_raw.find('(') { - strip_wit_escape(&case_raw[..paren_pos]).to_string() + + let (case_name, data_type) = if let Some(paren_pos) = case_raw.find('(') { + let name = strip_wit_escape(&case_raw[..paren_pos]).to_string(); + // Extract the type between parentheses + let type_end = case_raw.rfind(')').unwrap_or(case_raw.len()); + let type_str = &case_raw[paren_pos + 1..type_end]; + (name, Some(type_str.to_string())) } else { - strip_wit_escape(case_raw).to_string() + (strip_wit_escape(case_raw).to_string(), None) }; - debug!(case = %case_name, "Found variant case"); - cases.push(case_name); + + debug!(case = %case_name, data_type = ?data_type, "Found variant case"); + cases.push(WitVariantCase { + name: case_name, + data_type, + }); i += 1; } @@ -370,6 +440,43 @@ fn parse_wit_file(file_path: &Path) -> Result { cases, }); } + // Look for enum definitions + else if line.starts_with("enum ") { + let enum_name = line + .trim_start_matches("enum ") + .trim_end_matches(" {") + .trim(); + + // Strip % prefix if present + let enum_name = strip_wit_escape(enum_name); + debug!(name = %enum_name, "Found enum"); + + // Parse enum cases + let mut cases = Vec::new(); + i += 1; + + while i < lines.len() && !lines[i].trim().starts_with("}") { + let case_line = lines[i].trim(); + + // Skip comments and empty lines + if case_line.starts_with("//") || case_line.is_empty() { + i += 1; + continue; + } + + // Parse enum case (simple name without data) + let case_name = strip_wit_escape(case_line.trim_end_matches(',')).to_string(); + debug!(case = %case_name, "Found enum case"); + cases.push(case_name); + + i += 1; + } + + enums.push(WitEnum { + name: enum_name.to_string(), + cases, + }); + } i += 1; } @@ -379,12 +486,14 @@ fn parse_wit_file(file_path: &Path) -> Result { signatures = signatures.len(), records = records.len(), variants = variants.len(), + enums = enums.len(), "Finished parsing WIT file" ); Ok(WitTypes { signatures, records, variants, + enums, }) } @@ -406,20 +515,107 @@ fn generate_typescript_interface(record: &WitRecord) -> String { ) } +// Generate TypeScript enum from a WIT enum +fn generate_typescript_enum(enum_def: &WitEnum) -> String { + let type_name = to_pascal_case(&enum_def.name); + + // Generate as TypeScript enum with string values + let mut enum_str = format!("export enum {} {{\n", type_name); + + for case in &enum_def.cases { + let case_pascal = to_pascal_case(case); + // Use the original kebab-case value as the string value + enum_str.push_str(&format!(" {} = \"{}\",\n", case_pascal, case)); + } + + enum_str.push_str("}"); + enum_str +} + // Generate TypeScript type from a WIT variant fn generate_typescript_variant(variant: &WitVariant) -> String { let type_name = to_pascal_case(&variant.name); - let cases: Vec = variant - .cases - .iter() - .map(|case| format!("\"{}\"", to_pascal_case(case))) + + // Check if this is a simple enum (no associated data) or a tagged union + let has_data = variant.cases.iter().any(|case| case.data_type.is_some()); + + if !has_data { + // Simple enum - generate as string union + let cases: Vec = variant + .cases + .iter() + .map(|case| format!("\"{}\"", to_pascal_case(&case.name))) + .collect(); + format!("export type {} = {};", type_name, cases.join(" | ")) + } else { + // Tagged union - generate as discriminated union + let cases: Vec = variant + .cases + .iter() + .map(|case| { + let case_name = to_pascal_case(&case.name); + if let Some(ref data_type) = case.data_type { + // Handle record types specially + if data_type.trim().starts_with("record {") { + // Parse record fields from the data type + let record_content = data_type.trim_start_matches("record").trim(); + let fields = parse_inline_record_fields(record_content); + format!("{{ {}: {} }}", case_name, fields) + } else { + // Simple type + let ts_type = wit_type_to_typescript(data_type); + format!("{{ {}: {} }}", case_name, ts_type) + } + } else { + // Case without data - still use object format for consistency + format!("{{ {}: null }}", case_name) + } + }) + .collect(); + + format!("export type {} = {};", type_name, cases.join(" | ")) + } +} + +// Helper to parse inline record fields +fn parse_inline_record_fields(record_str: &str) -> String { + // Remove the curly braces + let content = record_str + .trim_start_matches('{') + .trim_end_matches('}') + .trim(); + + // Parse each field + let fields: Vec = content + .split(',') + .filter_map(|field| { + let field = field.trim(); + if field.is_empty() { + return None; + } + + // Split field name and type + if let Some(colon_pos) = field.find(':') { + let field_name = field[..colon_pos].trim(); + let field_type = field[colon_pos + 1..].trim(); + let field_name = strip_wit_escape(field_name); + let ts_name = to_snake_case(field_name); + let ts_type = wit_type_to_typescript(field_type); + Some(format!("{}: {}", ts_name, ts_type)) + } else { + None + } + }) .collect(); - format!("export type {} = {};", type_name, cases.join(" | ")) + format!("{{ {} }}", fields.join(", ")) } // Generate TypeScript interface and function from a signature struct -fn generate_typescript_function(signature: &SignatureStruct) -> (String, String, String) { +fn generate_typescript_function( + signature: &SignatureStruct, + _use_namespace: bool, +) -> (String, String, String) { // Convert function name from kebab-case to camelCase let camel_function_name = to_snake_case(&signature.function_name); let pascal_function_name = to_pascal_case(&signature.function_name); @@ -432,6 +628,7 @@ fn generate_typescript_function(signature: &SignatureStruct) -> (String, String, let mut param_types = Vec::new(); let mut full_return_type = "void".to_string(); let mut unwrapped_return_type = "void".to_string(); + let actual_param_type: String; for field in &signature.fields { let field_name_camel = to_snake_case(&field.name); @@ -457,28 +654,21 @@ fn generate_typescript_function(signature: &SignatureStruct) -> (String, String, } } - // Generate request interface - let request_interface = if param_names.is_empty() { - // No parameters case - format!( - "export interface {}Request {{\n {}: null\n}}", - pascal_function_name, pascal_function_name - ) + // Determine the actual parameter type for the function + if param_names.is_empty() { + actual_param_type = "null".to_string(); } else if param_names.len() == 1 { - // Single parameter case - format!( - "export interface {}Request {{\n {}: {}\n}}", - pascal_function_name, pascal_function_name, param_types[0] - ) + actual_param_type = param_types[0].clone(); } else { - // Multiple parameters case - use tuple format - format!( - "export interface {}Request {{\n {}: [{}]\n}}", - pascal_function_name, - pascal_function_name, - param_types.join(", ") - ) - }; + actual_param_type = format!("[{}]", param_types.join(", ")); + } + + // Generate request interface with a different name to avoid conflicts + let request_interface_name = format!("{}RequestWrapper", pascal_function_name); + let request_interface = format!( + "export interface {} {{\n {}: {}\n}}", + request_interface_name, pascal_function_name, actual_param_type + ); // Generate response type alias (using the full Result type) let response_type = format!( @@ -491,18 +681,18 @@ fn generate_typescript_function(signature: &SignatureStruct) -> (String, String, let data_construction = if param_names.is_empty() { format!( - " const data: {}Request = {{\n {}: null,\n }};", - pascal_function_name, pascal_function_name + " const data: {} = {{\n {}: null,\n }};", + request_interface_name, pascal_function_name ) } else if param_names.len() == 1 { format!( - " const data: {}Request = {{\n {}: {},\n }};", - pascal_function_name, pascal_function_name, param_names[0] + " const data: {} = {{\n {}: {},\n }};", + request_interface_name, pascal_function_name, param_names[0] ) } else { format!( - " const data: {}Request = {{\n {}: [{}],\n }};", - pascal_function_name, + " const data: {} = {{\n {}: [{}],\n }};", + request_interface_name, pascal_function_name, param_names.join(", ") ) @@ -510,14 +700,14 @@ fn generate_typescript_function(signature: &SignatureStruct) -> (String, String, // Function returns the unwrapped type since parseResultResponse extracts it let function_impl = format!( - "/**\n * {}\n{} * @returns Promise with result\n * @throws ApiError if the request fails\n */\nexport async function {}({}): Promise<{}> {{\n{}\n\n return await apiRequest<{}Request, {}>('{}', 'POST', data);\n}}", + "/**\n * {}\n{} * @returns Promise with result\n * @throws ApiError if the request fails\n */\nexport async function {}({}): Promise<{}> {{\n{}\n\n return await apiRequest<{}, {}>('{}', 'POST', data);\n}}", camel_function_name, params.iter().map(|p| format!(" * @param {}", p)).collect::>().join("\n"), camel_function_name, function_params, unwrapped_return_type, // Use unwrapped type as the function return data_construction, - pascal_function_name, + request_interface_name, unwrapped_return_type, // Pass unwrapped type to apiRequest, not Response type camel_function_name ); @@ -544,8 +734,9 @@ pub fn create_typescript_caller_utils(base_dir: &Path, api_dir: &Path) -> Result "Creating TypeScript caller-utils" ); - // Find all WIT files in the api directory - let mut wit_files = Vec::new(); + // Find all WIT files in the api directory and group by hyperapp + let mut hyperapp_files: HashMap> = HashMap::new(); + for entry in WalkDir::new(api_dir) .max_depth(1) .into_iter() @@ -556,8 +747,14 @@ pub fn create_typescript_caller_utils(base_dir: &Path, api_dir: &Path) -> Result // Exclude world definition files if let Ok(content) = fs::read_to_string(path) { if !content.contains("world ") { - debug!(file = %path.display(), "Adding WIT file for parsing"); - wit_files.push(path.to_path_buf()); + // Extract hyperapp name from filename + if let Some(hyperapp_name) = extract_hyperapp_name(path) { + debug!(file = %path.display(), hyperapp = %hyperapp_name, "Adding WIT file for parsing"); + hyperapp_files + .entry(hyperapp_name) + .or_insert_with(Vec::new) + .push(path.to_path_buf()); + } } else { debug!(file = %path.display(), "Skipping world definition WIT file"); } @@ -566,8 +763,8 @@ pub fn create_typescript_caller_utils(base_dir: &Path, api_dir: &Path) -> Result } debug!( - count = wit_files.len(), - "Found WIT interface files for TypeScript generation" + hyperapps = hyperapp_files.len(), + "Found hyperapps for TypeScript generation" ); // Generate TypeScript content @@ -626,49 +823,124 @@ pub fn create_typescript_caller_utils(base_dir: &Path, api_dir: &Path) -> Result ts_content.push_str(" return parseResultResponse(jsonResponse);\n"); ts_content.push_str("}\n\n"); - // Collect all interfaces, types, and functions - let mut all_interfaces = Vec::new(); - let mut all_types = Vec::new(); - let mut all_functions = Vec::new(); - let mut function_names = Vec::new(); - let mut custom_types = Vec::new(); // For records and variants - - // Generate content for each WIT file - for wit_file in &wit_files { - match parse_wit_file(wit_file) { - Ok(wit_types) => { - // Process custom types (records and variants) - for record in &wit_types.records { - let interface_def = generate_typescript_interface(record); - custom_types.push(interface_def); - } + // Collect types grouped by hyperapp + let mut hyperapp_types_map: HashMap = HashMap::new(); + let mut has_any_functions = false; + + // Process WIT files grouped by hyperapp + for (hyperapp_name, wit_files) in &hyperapp_files { + let mut hyperapp_data = HyperappTypes { + _name: hyperapp_name.clone(), + signatures: Vec::new(), + records: Vec::new(), + variants: Vec::new(), + enums: Vec::new(), + }; + + // Parse each WIT file for this hyperapp + for wit_file in wit_files { + match parse_wit_file(wit_file) { + Ok(wit_types) => { + // Check for conflicting type names + for record in &wit_types.records { + let type_name = to_pascal_case(&record.name); + if type_name.ends_with("Request") || type_name.ends_with("Response") { + return Err(color_eyre::eyre::eyre!( + "Type '{}' in {} has a reserved suffix (Request/Response). \ + These suffixes are reserved for generated wrapper types. \ + Please rename the type in the WIT file.", + record.name, + wit_file.display() + )); + } + if type_name.ends_with("RequestWrapper") + || type_name.ends_with("ResponseWrapper") + { + return Err(color_eyre::eyre::eyre!( + "Type '{}' in {} has a reserved suffix (RequestWrapper/ResponseWrapper). \ + These suffixes are reserved for generated types. \ + Please rename the type in the WIT file.", + record.name, wit_file.display() + )); + } + } - for variant in &wit_types.variants { - let type_def = generate_typescript_variant(variant); - custom_types.push(type_def); - } + for variant in &wit_types.variants { + let type_name = to_pascal_case(&variant.name); + if type_name.ends_with("Request") || type_name.ends_with("Response") { + return Err(color_eyre::eyre::eyre!( + "Type '{}' in {} has a reserved suffix (Request/Response). \ + These suffixes are reserved for generated wrapper types. \ + Please rename the type in the WIT file.", + variant.name, + wit_file.display() + )); + } + if type_name.ends_with("RequestWrapper") + || type_name.ends_with("ResponseWrapper") + { + return Err(color_eyre::eyre::eyre!( + "Type '{}' in {} has a reserved suffix (RequestWrapper/ResponseWrapper). \ + These suffixes are reserved for generated types. \ + Please rename the type in the WIT file.", + variant.name, wit_file.display() + )); + } + } - // Process function signatures - for signature in &wit_types.signatures { - let (interface_def, type_def, function_def) = - generate_typescript_function(&signature); + for enum_def in &wit_types.enums { + let type_name = to_pascal_case(&enum_def.name); + if type_name.ends_with("Request") || type_name.ends_with("Response") { + return Err(color_eyre::eyre::eyre!( + "Type '{}' in {} has a reserved suffix (Request/Response). \ + These suffixes are reserved for generated wrapper types. \ + Please rename the type in the WIT file.", + enum_def.name, + wit_file.display() + )); + } + if type_name.ends_with("RequestWrapper") + || type_name.ends_with("ResponseWrapper") + { + return Err(color_eyre::eyre::eyre!( + "Type '{}' in {} has a reserved suffix (RequestWrapper/ResponseWrapper). \ + These suffixes are reserved for generated types. \ + Please rename the type in the WIT file.", + enum_def.name, wit_file.display() + )); + } + } - if !interface_def.is_empty() { - all_interfaces.push(interface_def); - all_types.push(type_def); - all_functions.push(function_def); - function_names.push(to_snake_case(&signature.function_name)); + // Collect all types for this hyperapp + hyperapp_data.records.extend(wit_types.records); + hyperapp_data.variants.extend(wit_types.variants); + hyperapp_data.enums.extend(wit_types.enums); + + // Only collect HTTP signatures + for sig in wit_types.signatures { + if sig.attr_type == "http" { + hyperapp_data.signatures.push(sig); + has_any_functions = true; + } } } - } - Err(e) => { - warn!(file = %wit_file.display(), error = %e, "Error parsing WIT file, skipping"); + Err(e) => { + warn!(file = %wit_file.display(), error = %e, "Error parsing WIT file, skipping"); + } } } + + if !hyperapp_data.signatures.is_empty() + || !hyperapp_data.records.is_empty() + || !hyperapp_data.variants.is_empty() + || !hyperapp_data.enums.is_empty() + { + hyperapp_types_map.insert(hyperapp_name.clone(), hyperapp_data); + } } // If no HTTP functions were found, don't generate the file - if all_functions.is_empty() { + if !has_any_functions { debug!("No HTTP functions found in WIT files, skipping TypeScript generation"); return Ok(()); } @@ -677,29 +949,147 @@ pub fn create_typescript_caller_utils(base_dir: &Path, api_dir: &Path) -> Result fs::create_dir_all(&ui_target_dir)?; debug!("Created UI target directory structure"); - // Add custom types (records and variants) first - if !custom_types.is_empty() { - ts_content.push_str("\n// Custom Types from WIT definitions\n\n"); - ts_content.push_str(&custom_types.join("\n\n")); - ts_content.push_str("\n\n"); - } + // Generate TypeScript namespaces for each hyperapp + for (hyperapp_name, hyperapp_data) in &hyperapp_types_map { + ts_content.push_str(&format!( + "\n// ============= {} Hyperapp =============\n", + hyperapp_name + )); + ts_content.push_str(&format!("export namespace {} {{\n", hyperapp_name)); + + // Add custom types (records, variants, and enums) for this hyperapp + if !hyperapp_data.records.is_empty() + || !hyperapp_data.variants.is_empty() + || !hyperapp_data.enums.is_empty() + { + ts_content.push_str("\n // Custom Types\n"); + + // Generate enums first + for enum_def in &hyperapp_data.enums { + let enum_ts = generate_typescript_enum(enum_def); + // Indent the enum definition for namespace + let indented = enum_ts + .lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n"); + ts_content.push_str(&indented); + ts_content.push_str("\n\n"); + } - // Add all collected definitions - if !all_interfaces.is_empty() { - ts_content.push_str("\n// API Interface Definitions\n\n"); - ts_content.push_str(&all_interfaces.join("\n\n")); - ts_content.push_str("\n\n"); - ts_content.push_str(&all_types.join("\n\n")); - ts_content.push_str("\n\n"); - } + for record in &hyperapp_data.records { + let interface_def = generate_typescript_interface(record); + // Indent the interface definition for namespace + let indented = interface_def + .lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n"); + ts_content.push_str(&indented); + ts_content.push_str("\n\n"); + } - if !all_functions.is_empty() { - ts_content.push_str("// API Function Implementations\n\n"); - ts_content.push_str(&all_functions.join("\n\n")); - ts_content.push_str("\n\n"); + for variant in &hyperapp_data.variants { + let type_def = generate_typescript_variant(variant); + // Indent the type definition for namespace + let indented = type_def + .lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n"); + ts_content.push_str(&indented); + ts_content.push_str("\n\n"); + } + } + + // Add request/response interfaces and functions for this hyperapp + if !hyperapp_data.signatures.is_empty() { + ts_content.push_str("\n // API Request/Response Types\n"); + + for signature in &hyperapp_data.signatures { + let (interface_def, type_def, _function_def) = + generate_typescript_function(signature, true); + + if !interface_def.is_empty() { + // Indent interface definition + let indented = interface_def + .lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n"); + ts_content.push_str(&indented); + ts_content.push_str("\n\n"); + + // Indent type definition + let indented = type_def + .lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n"); + ts_content.push_str(&indented); + ts_content.push_str("\n\n"); + } + } + + ts_content.push_str("\n // API Functions\n"); + + for signature in &hyperapp_data.signatures { + let (_, _, function_def) = generate_typescript_function(signature, true); + + if !function_def.is_empty() { + // Indent function definition + let indented = function_def + .lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n"); + ts_content.push_str(&indented); + ts_content.push_str("\n\n"); + } + } + } + + // Close namespace + ts_content.push_str("}\n"); } - // No need for explicit exports since functions are already exported inline + ts_content.push_str("\n"); // Write the TypeScript file debug!( @@ -719,3 +1109,88 @@ pub fn create_typescript_caller_utils(base_dir: &Path, api_dir: &Path) -> Result ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_enum_generation() { + // Create a temporary directory + let temp_dir = tempdir().unwrap(); + let api_dir = temp_dir.path().join("api"); + fs::create_dir(&api_dir).unwrap(); + + // Create a test WIT file with an enum + let wit_content = r#" +interface test { + enum test-enum { + option-one, + option-two, + option-three + } + + record test-data { + value: test-enum + } + + // Function signature for: test-func (http) + // HTTP: POST /api/test-func + record test-func-signature-http { + target: string, + request: test-data, + returning: result + } +} +"#; + + let wit_file = api_dir.join("test.wit"); + fs::write(&wit_file, wit_content).unwrap(); + + // Generate TypeScript + let result = create_typescript_caller_utils(temp_dir.path(), &api_dir); + assert!( + result.is_ok(), + "Failed to generate TypeScript: {:?}", + result + ); + + // Read generated TypeScript file + let ts_file = temp_dir + .path() + .join("target") + .join("ui") + .join("caller-utils.ts"); + let ts_content = fs::read_to_string(&ts_file).unwrap(); + + // Check that the enum was generated + assert!( + ts_content.contains("export enum TestEnum"), + "Enum not found in generated TypeScript" + ); + assert!( + ts_content.contains("OptionOne = \"option-one\""), + "Enum case OptionOne not found" + ); + assert!( + ts_content.contains("OptionTwo = \"option-two\""), + "Enum case OptionTwo not found" + ); + assert!( + ts_content.contains("OptionThree = \"option-three\""), + "Enum case OptionThree not found" + ); + + // Check that the interface using the enum was generated + assert!( + ts_content.contains("export interface TestData"), + "Interface not found" + ); + assert!( + ts_content.contains("value: TestEnum"), + "Enum reference in interface not found" + ); + } +} diff --git a/src/build/mod.rs b/src/build/mod.rs index d7f5502b..d56ce32c 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -1545,7 +1545,7 @@ fn is_cluded(path: &Path, include: &HashSet, exclude: &HashSet } /// package dir looks like: -/// ``` +/// /// metadata.json /// api/ <- optional /// my_package:publisher.os-v0.wit @@ -1565,7 +1565,6 @@ fn is_cluded(path: &Path, include: &HashSet, exclude: &HashSet /// target/ <- built /// api/ /// wit/ -/// ``` #[instrument(level = "trace", skip_all)] async fn compile_package( package_dir: &Path, diff --git a/src/build/wit_generator.rs b/src/build/wit_generator.rs index 2a879750..f2bbada6 100644 --- a/src/build/wit_generator.rs +++ b/src/build/wit_generator.rs @@ -103,8 +103,16 @@ fn validate_name(name: &str, kind: &str) -> Result<()> { // Check for numbers if name.chars().any(|c| c.is_digit(10)) { bail!( - "Error: {} name '{}' contains numbers, which is not allowed", + "{} name '{}' contains numbers, which are not allowed in WIT identifiers.\n\ + \n\ + WIT (WebAssembly Interface Types) has strict naming rules:\n\ + - Names must contain only letters (a-z, A-Z), underscores (_), and hyphens (-)\n\ + - Numbers are not permitted in identifiers\n\ + \n\ + Suggestion: Rename '{}' to use descriptive words instead of numbers.\n\ + Examples: 'field1' → 'first_field', 'level2' → 'level_two', 'data3' → 'third_data'", kind, + name, name ); } @@ -112,7 +120,12 @@ fn validate_name(name: &str, kind: &str) -> Result<()> { // Check for "stream" if name.to_lowercase().contains("stream") { bail!( - "Error: {} name '{}' contains 'stream', which is not allowed", + "{} name '{}' contains 'stream', which is a reserved keyword in WIT.\n\ + \n\ + 'stream' is reserved for future WIT streaming functionality and cannot be used in identifiers.\n\ + \n\ + Suggestion: Use an alternative term like 'flow', 'channel', 'pipeline', or 'sequence'.\n\ + Examples: 'data_stream' → 'data_flow', 'stream_handler' → 'channel_handler'", kind, name ); @@ -485,7 +498,7 @@ fn generate_signature_struct( // Get original param name let param_orig_name = pat_ident.ident.to_string(); - let method_name_for_error = method.sig.ident.to_string(); // Get method name for error messages + let _method_name_for_error = method.sig.ident.to_string(); // Get method name for error messages // Validate parameter name match validate_name(¶m_orig_name, "Parameter") { @@ -503,11 +516,8 @@ fn generate_signature_struct( .push(format!(" {}: {}", param_wit_ident, param_type)); } Err(e) => { - // Wrap parameter type conversion error with context - return Err(e.wrap_err(format!( - "Failed to convert type for parameter '{}' in function '{}'", - param_orig_name, method_name_for_error - ))); + // Return error, preserving the helpful validation message if present + return Err(e); } } } @@ -634,8 +644,215 @@ impl AsTypePath for syn::Type { } // Helper function to collect all type definitions from a file +// Collect a single type definition from a file #[instrument(level = "trace", skip_all)] -fn collect_type_definitions_from_file( +fn collect_single_type_definition( + file_path: &Path, + target_type_kebab: &str, // The kebab-case type name we're looking for +) -> Result)>> { + // Returns (WIT definition, dependencies) + debug!(file_path = %file_path.display(), target_type = %target_type_kebab, "Looking for type in file"); + + let content = fs::read_to_string(file_path) + .with_context(|| format!("Failed to read file: {}", file_path.display()))?; + + let ast = syn::parse_file(&content) + .with_context(|| format!("Failed to parse file: {}", file_path.display()))?; + + let mut dependencies = HashSet::new(); + + for item in &ast.items { + match item { + Item::Struct(s) => { + let name = s.ident.to_string(); + // Skip internal types + if name.contains("__") { + continue; + } + + let kebab_name = to_kebab_case(&name); + debug!(struct_name = %name, kebab_name = %kebab_name, target = %target_type_kebab, "Checking struct"); + if kebab_name != target_type_kebab { + continue; // Not the type we're looking for + } + + // Found the type! Generate its WIT definition + return generate_struct_wit_definition(s, &name, &kebab_name, &mut dependencies) + .map(|wit_def| Some((wit_def, dependencies))); + } + Item::Enum(e) => { + let name = e.ident.to_string(); + // Skip internal types + if name.contains("__") { + continue; + } + + let kebab_name = to_kebab_case(&name); + if kebab_name != target_type_kebab { + continue; // Not the type we're looking for + } + + // Found the type! Generate its WIT definition + return generate_enum_wit_definition(e, &name, &kebab_name, &mut dependencies) + .map(|wit_def| Some((wit_def, dependencies))); + } + _ => {} + } + } + + Ok(None) // Type not found in this file +} + +// Helper function to generate WIT definition for a struct +fn generate_struct_wit_definition( + s: &syn::ItemStruct, + name: &str, + kebab_name: &str, + dependencies: &mut HashSet, +) -> Result { + // Validate name + if let Err(e) = validate_name(&name, "Struct") { + return Err(e); + } + + // Generate WIT definition for this struct + let fields_result: Result> = match &s.fields { + syn::Fields::Named(fields) => { + let mut field_strings = Vec::new(); + for f in &fields.named { + if let Some(field_ident) = &f.ident { + let field_orig_name = field_ident.to_string(); + let stripped_field_orig_name = + check_and_strip_leading_underscore(field_orig_name.clone()); + + if let Err(e) = validate_name(&stripped_field_orig_name, "Field") { + // Return the validation error directly to preserve the helpful message + return Err(e); + } + + let field_kebab_name = to_kebab_case(&stripped_field_orig_name); + let wit_type = rust_type_to_wit(&f.ty, dependencies)?; + field_strings.push(format!( + "{}: {}", + to_wit_ident(&field_kebab_name), + wit_type + )); + } + } + Ok(field_strings) + } + syn::Fields::Unnamed(_) => { + bail!( + "Struct '{}' has unnamed (tuple-style) fields, which are not supported in WIT. \ + WIT only supports named fields in records. \ + Consider converting to a struct with named fields.", + name + ); + } + syn::Fields::Unit => { + // Unit struct becomes an empty record + Ok(vec![]) + } + }; + + let fields = fields_result?; + + if fields.is_empty() { + Ok(format!("record {} {{}}", to_wit_ident(&kebab_name))) + } else { + let indented_fields = fields + .iter() + .map(|f| format!(" {}", f)) + .collect::>() + .join(",\n"); + Ok(format!( + "record {} {{\n{}\n}}", + to_wit_ident(&kebab_name), + indented_fields + )) + } +} + +// Helper function to generate WIT definition for an enum +fn generate_enum_wit_definition( + e: &syn::ItemEnum, + name: &str, + kebab_name: &str, + dependencies: &mut HashSet, +) -> Result { + // Validate name + if let Err(e) = validate_name(&name, "Enum") { + return Err(e); + } + + let mut wit_fields = Vec::new(); + let mut is_simple_enum = true; + + for v in &e.variants { + let variant_orig_name = v.ident.to_string(); + + if let Err(e) = validate_name(&variant_orig_name, "Variant") { + return Err(e); + } + + let variant_kebab_name = to_kebab_case(&variant_orig_name); + + match &v.fields { + syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { + is_simple_enum = false; + let field = fields.unnamed.first().unwrap(); + let wit_type = rust_type_to_wit(&field.ty, dependencies)?; + wit_fields.push(format!( + "{}({})", + to_wit_ident(&variant_kebab_name), + wit_type + )); + } + syn::Fields::Unit => { + wit_fields.push(to_wit_ident(&variant_kebab_name)); + } + syn::Fields::Named(_) => { + bail!( + "Enum '{}' has variant '{}' with struct-like fields {{ ... }}, which is not supported in WIT. \ + WIT variants can only have unnamed single-value data or no data at all. \ + Consider refactoring to use a separate struct type or a single unnamed field.", + name, variant_orig_name + ); + } + syn::Fields::Unnamed(fields) => { + bail!( + "Enum '{}' has variant '{}' with {} unnamed fields, which is not supported in WIT. \ + WIT variants can only have a single unnamed field. \ + Consider wrapping multiple fields in a struct or tuple type.", + name, variant_orig_name, fields.unnamed.len() + ); + } + } + } + + let keyword = if is_simple_enum { "enum" } else { "variant" }; + + if wit_fields.is_empty() { + Ok(format!("{} {} {{}}", keyword, to_wit_ident(&kebab_name))) + } else { + let indented_fields = wit_fields + .iter() + .map(|f| format!(" {}", f)) + .collect::>() + .join(",\n"); + Ok(format!( + "{} {} {{\n{}\n}}", + keyword, + to_wit_ident(&kebab_name), + indented_fields + )) + } +} + +// Removed unused function collect_type_definitions_from_file +// This function was not being called anywhere in the codebase +#[allow(dead_code)] +fn _collect_type_definitions_from_file( file_path: &Path, type_definitions: &mut HashMap, // kebab-name -> WIT definition ) -> Result<()> { @@ -661,8 +878,7 @@ fn collect_type_definitions_from_file( // Validate name if let Err(e) = validate_name(&name, "Struct") { - warn!(name = %name, error = %e, "Skipping struct with invalid name"); - continue; + return Err(e.wrap_err(format!("Invalid struct name '{}'", name))); } let kebab_name = to_kebab_case(&name); @@ -678,8 +894,7 @@ fn collect_type_definitions_from_file( check_and_strip_leading_underscore(field_orig_name.clone()); if let Err(e) = validate_name(&stripped_field_orig_name, "Field") { - warn!(field_name = %field_orig_name, error = %e, "Skipping field with invalid name"); - continue; + return Err(e); } let field_kebab_name = to_kebab_case(&stripped_field_orig_name); @@ -697,7 +912,6 @@ fn collect_type_definitions_from_file( )); } Err(e) => { - warn!(field = %field_orig_name, error = %e, "Failed to convert field type"); return Err(e.wrap_err(format!( "Failed to convert field '{}' in struct '{}'", field_orig_name, name @@ -710,8 +924,12 @@ fn collect_type_definitions_from_file( } syn::Fields::Unit => Ok(Vec::new()), syn::Fields::Unnamed(_) => { - warn!(struct_name = %name, "Skipping tuple struct"); - continue; + bail!( + "Struct '{}' is a tuple struct, which is not supported in WIT. \ + WIT only supports record types with named fields. \ + Consider converting to a struct with named fields.", + name + ); } }; @@ -730,7 +948,7 @@ fn collect_type_definitions_from_file( type_definitions.insert(kebab_name, definition); } Err(e) => { - warn!(struct_name = %name, error = %e, "Failed to process struct"); + return Err(e); } } } @@ -743,20 +961,16 @@ fn collect_type_definitions_from_file( // Validate name if let Err(e) = validate_name(&name, "Enum") { - warn!(name = %name, error = %e, "Skipping enum with invalid name"); - continue; + return Err(e.wrap_err(format!("Invalid enum name '{}'", name))); } let kebab_name = to_kebab_case(&name); let mut variants_wit = Vec::new(); - let mut skip_enum = false; for v in &e.variants { let variant_orig_name = v.ident.to_string(); if let Err(e) = validate_name(&variant_orig_name, "Enum variant") { - warn!(variant = %variant_orig_name, error = %e, "Skipping variant with invalid name"); - skip_enum = true; - break; + return Err(e); } let variant_kebab_name = to_kebab_case(&variant_orig_name); @@ -775,24 +989,38 @@ fn collect_type_definitions_from_file( )); } Err(e) => { - warn!(variant = %variant_orig_name, error = %e, "Failed to convert variant type"); - skip_enum = true; - break; + return Err(e.wrap_err(format!( + "Failed to convert type for variant '{}' in enum '{}'", + variant_orig_name, name + ))); } } } syn::Fields::Unit => { variants_wit.push(format!(" {}", variant_wit_ident)); } - _ => { - warn!(enum_name = %kebab_name, variant_name = %variant_orig_name, "Skipping complex enum variant"); - skip_enum = true; - break; + syn::Fields::Named(_) => { + // Struct-like enum variants with named fields are not supported in WIT + bail!( + "Enum '{}' has variant '{}' with struct-like fields {{ ... }}, which is not supported in WIT. \ + WIT variants can only have unnamed single-value data or no data at all. \ + Consider refactoring to use a separate struct type or a single unnamed field.", + name, variant_orig_name + ); + } + syn::Fields::Unnamed(fields) => { + // Multiple unnamed fields (tuple variant with more than 1 field) + bail!( + "Enum '{}' has variant '{}' with {} unnamed fields, which is not supported in WIT. \ + WIT variants can only have a single unnamed field. \ + Consider wrapping multiple fields in a struct or tuple type.", + name, variant_orig_name, fields.unnamed.len() + ); } } } - if !skip_enum && !variants_wit.is_empty() { + if !variants_wit.is_empty() { let wit_ident = to_wit_ident(&kebab_name); let definition = format!( " variant {} {{\n{}\n }}", @@ -838,20 +1066,7 @@ fn process_rust_project(project_path: &Path, api_dir: &Path) -> Result Result Result>(); + let mut collected_types = HashSet::new(); + + // Iteratively collect type definitions and their dependencies + while !types_to_collect.is_empty() { + let current_batch = types_to_collect.clone(); + types_to_collect.clear(); + + for type_name in current_batch { + if collected_types.contains(&type_name) { + continue; + } + + // Try to find and collect this type definition from the source files + let mut found = false; + for file_path in &rust_files { + match collect_single_type_definition(file_path, &type_name) { + Ok(Some((wit_def, dependencies))) => { + found = true; + all_type_definitions.insert(type_name.clone(), wit_def); + collected_types.insert(type_name.clone()); + + // Add dependencies to be collected + for dep in dependencies { + if !is_wit_primitive_or_builtin(&dep) && !collected_types.contains(&dep) + { + types_to_collect.insert(dep); + } + } + break; // Found the type, no need to check other files + } + Ok(None) => { + // Type not in this file, continue searching + } + Err(e) => { + // Type was found but has an error (e.g., incompatible enum variant) + // Propagate this error immediately + return Err(e); + } + } + } + + if !found { + // Type not found in any file - this could be an issue + debug!(type_name = %type_name, "Type not found in any source file"); + } + } + } + + debug!(collected_count = %all_type_definitions.len(), "Collected type definitions in Pass 3"); + + // --- 4. Build dependency graph and topologically sort types --- + debug!("Pass 4: Building type dependency graph"); // Build a dependency map: type -> types it depends on let mut type_dependencies: HashMap> = HashMap::new(); @@ -1100,7 +1375,7 @@ fn process_rust_project(project_path: &Path, api_dir: &Path) -> Result Result = Vec::new(); for type_name in &sorted_types { @@ -1154,18 +1429,35 @@ fn process_rust_project(project_path: &Path, api_dir: &Path) -> Result = relevant_defs + .iter() + .map(|def| { + def.lines() + .map(|line| { + if line.is_empty() { + line.to_string() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n") + }) + .collect(); + content.push_str(&indented_defs.join("\n\n")); content.push('\n'); } - // Add signature structs + // Add signature structs with proper indentation if !signature_structs.is_empty() { content.push('\n'); // Separator debug!(count=%signature_structs.len(), "Adding signature structs to interface"); + // Signature structs are already indented, just join them content.push_str(&signature_structs.join("\n\n")); } @@ -1294,6 +1586,426 @@ fn rewrite_wit( Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_only_collects_used_types() -> Result<()> { + // Create a temporary directory for the test + let temp_dir = TempDir::new()?; + let src_dir = temp_dir.path().join("src"); + fs::create_dir_all(&src_dir)?; + + // Create a lib.rs with a handler that uses SimpleStruct but not UnusedStruct + let lib_content = r#" +use hyperware_macros::hyperprocess; + +pub struct SimpleStruct { + pub name: String, + pub value: u32, +} + +// This struct has incompatible enum variant but shouldn't be processed +pub enum UnusedEnum { + Variant1 { data: Vec }, // Struct-like variant - would fail if processed + Variant2(String), +} + +pub struct ProcessState; + +#[hyperprocess(wit_world = "test-world")] +impl ProcessState { + #[remote] + pub fn handler(&self, input: SimpleStruct) -> Result { + Ok("done".to_string()) + } +} +"#; + fs::write(src_dir.join("lib.rs"), lib_content)?; + + // Create a Cargo.toml + let cargo_content = r#" +[package] +name = "test-project" +version = "0.1.0" + +[package.metadata.component] +package = "test:component" +"#; + fs::write(temp_dir.path().join("Cargo.toml"), cargo_content)?; + + // Create the api directory + let api_dir = temp_dir.path().join("api"); + fs::create_dir_all(&api_dir)?; + + // Run the WIT generator + let result = process_rust_project(temp_dir.path(), &api_dir); + + // Debug: Check what files were created + eprintln!("Test directory: {:?}", temp_dir.path()); + eprintln!("Files in src/:"); + for entry in fs::read_dir(src_dir.clone()).unwrap() { + let entry = entry.unwrap(); + eprintln!(" - {:?}", entry.file_name()); + } + + // The generation should succeed because UnusedEnum is never processed + assert!( + result.is_ok(), + "WIT generation should succeed when unused types have incompatible variants" + ); + + // Check that the generated interface file exists and contains SimpleStruct but not UnusedEnum + let interface_files: Vec<_> = fs::read_dir(&api_dir)? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext == "wit") + .unwrap_or(false) + && entry + .file_name() + .to_str() + .map(|name| name != "test-world.wit" && name != "types-test-world.wit") + .unwrap_or(false) + }) + .collect(); + + assert!( + !interface_files.is_empty(), + "Should generate at least one interface file" + ); + + let interface_content = fs::read_to_string(interface_files[0].path())?; + assert!( + interface_content.contains("simple-struct"), + "Should contain SimpleStruct" + ); + assert!( + !interface_content.contains("unused-enum"), + "Should not contain UnusedEnum" + ); + + Ok(()) + } + + #[test] + fn test_collects_recursive_dependencies() -> Result<()> { + let temp_dir = TempDir::new()?; + let src_dir = temp_dir.path().join("src"); + fs::create_dir_all(&src_dir)?; + + // Create a lib.rs with nested type dependencies + let lib_content = r#" +use hyperware_macros::hyperprocess; + +pub struct LevelOne { + pub data: LevelTwo, +} + +pub struct LevelTwo { + pub items: Vec, +} + +pub struct LevelThree { + pub value: String, +} + +pub struct UnusedDeep { + pub field: String, +} + +pub struct ProcessState; + +#[hyperprocess(wit_world = "test-world")] +impl ProcessState { + #[remote] + pub fn handler(&self, input: LevelOne) -> Result<(), String> { + Ok(()) + } +} +"#; + fs::write(src_dir.join("lib.rs"), lib_content)?; + + let cargo_content = r#" +[package] +name = "test-project" +version = "0.1.0" + +[package.metadata.component] +package = "test:component" +"#; + fs::write(temp_dir.path().join("Cargo.toml"), cargo_content)?; + + // Create the api directory + let api_dir = temp_dir.path().join("api"); + fs::create_dir_all(&api_dir)?; + + let result = process_rust_project(temp_dir.path(), &api_dir); + + assert!( + result.is_ok(), + "Should successfully process recursive dependencies" + ); + + // Find the generated interface file + let interface_files: Vec<_> = fs::read_dir(&api_dir)? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext == "wit") + .unwrap_or(false) + && entry + .file_name() + .to_str() + .map(|name| name != "test-world.wit" && name != "types-test-world.wit") + .unwrap_or(false) + }) + .collect(); + + let interface_content = fs::read_to_string(interface_files[0].path())?; + + // Should contain all three levels of dependencies + assert!( + interface_content.contains("level-one"), + "Should contain LevelOne" + ); + assert!( + interface_content.contains("level-two"), + "Should contain LevelTwo" + ); + assert!( + interface_content.contains("level-three"), + "Should contain LevelThree" + ); + + // Should not contain unused types + assert!( + !interface_content.contains("unused-deep"), + "Should not contain UnusedDeep" + ); + + Ok(()) + } + + #[test] + fn test_fails_on_incompatible_used_type() -> Result<()> { + let temp_dir = TempDir::new()?; + let src_dir = temp_dir.path().join("src"); + fs::create_dir_all(&src_dir)?; + + // Create a lib.rs with a handler that uses an incompatible enum + let lib_content = r#" +use hyperware_macros::hyperprocess; + +pub enum BadEnum { + Variant { name: String, count: u32 }, // Struct-like variant - should fail +} + +pub struct ProcessState; + +#[hyperprocess(wit_world = "test-world")] +impl ProcessState { + #[remote] + pub fn handler(&self, input: BadEnum) -> Result<(), String> { + Ok(()) + } +} +"#; + fs::write(src_dir.join("lib.rs"), lib_content)?; + + let cargo_content = r#" +[package] +name = "test-project" +version = "0.1.0" + +[package.metadata.component] +package = "test:component" +"#; + fs::write(temp_dir.path().join("Cargo.toml"), cargo_content)?; + + // Create the api directory + let api_dir = temp_dir.path().join("api"); + fs::create_dir_all(&api_dir)?; + + let result = process_rust_project(temp_dir.path(), &api_dir); + + // Should fail because BadEnum is used and has incompatible variant + assert!( + result.is_err(), + "Should fail when used type has incompatible variant" + ); + + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("struct-like fields"), + "Error should mention struct-like fields" + ); + assert!( + error_msg.contains("BadEnum"), + "Error should mention the problematic enum name" + ); + + Ok(()) + } + + #[test] + fn test_clear_error_message_for_illegal_field_names() -> Result<()> { + let temp_dir = TempDir::new()?; + let src_dir = temp_dir.path().join("src"); + fs::create_dir_all(&src_dir)?; + + // Create a lib.rs with a struct that has fields with numbers + let lib_content = r#" +use hyperware_macros::hyperprocess; + +pub struct TestStruct { + pub field1: String, // This will trigger the error + pub data2: u32, // This too +} + +pub struct ProcessState; + +#[hyperprocess(wit_world = "test-world")] +impl ProcessState { + #[remote] + pub fn handler(&self, input: TestStruct) -> Result<(), String> { + Ok(()) + } +} +"#; + fs::write(src_dir.join("lib.rs"), lib_content)?; + + let cargo_content = r#" +[package] +name = "test-project" +version = "0.1.0" + +[package.metadata.component] +package = "test:component" +"#; + fs::write(temp_dir.path().join("Cargo.toml"), cargo_content)?; + + // Create the api directory + let api_dir = temp_dir.path().join("api"); + fs::create_dir_all(&api_dir)?; + + let result = process_rust_project(temp_dir.path(), &api_dir); + + // Should fail with our improved error message + assert!( + result.is_err(), + "Should fail when field names contain numbers" + ); + + let error_msg = result.unwrap_err().to_string(); + + // Check that the error message contains our helpful information + assert!( + error_msg.contains("contains numbers, which are not allowed in WIT identifiers"), + "Error should explain that numbers are not allowed" + ); + assert!( + error_msg.contains("WIT (WebAssembly Interface Types) has strict naming rules"), + "Error should mention WIT naming rules" + ); + assert!( + error_msg.contains("Suggestion: Rename"), + "Error should provide suggestions" + ); + assert!( + error_msg.contains("field1"), + "Error should mention the problematic field name" + ); + assert!( + error_msg.contains("Examples:"), + "Error should provide examples of how to fix" + ); + + Ok(()) + } + + #[test] + fn test_clear_error_message_for_stream_keyword() -> Result<()> { + let temp_dir = TempDir::new()?; + let src_dir = temp_dir.path().join("src"); + fs::create_dir_all(&src_dir)?; + + // Create a lib.rs with a struct that has 'stream' in the name + let lib_content = r#" +use hyperware_macros::hyperprocess; + +pub struct DataStream { // This will trigger the error + pub data: String, +} + +pub struct ProcessState; + +#[hyperprocess(wit_world = "test-world")] +impl ProcessState { + #[remote] + pub fn handler(&self, input: DataStream) -> Result<(), String> { + Ok(()) + } +} +"#; + fs::write(src_dir.join("lib.rs"), lib_content)?; + + let cargo_content = r#" +[package] +name = "test-project" +version = "0.1.0" + +[package.metadata.component] +package = "test:component" +"#; + fs::write(temp_dir.path().join("Cargo.toml"), cargo_content)?; + + // Create the api directory + let api_dir = temp_dir.path().join("api"); + fs::create_dir_all(&api_dir)?; + + let result = process_rust_project(temp_dir.path(), &api_dir); + + // Should fail with our improved error message + assert!(result.is_err(), "Should fail when name contains 'stream'"); + + let error_msg = result.unwrap_err().to_string(); + + // Check that the error message contains our helpful information + assert!( + error_msg.contains("contains 'stream', which is a reserved keyword in WIT"), + "Error should explain that 'stream' is reserved" + ); + assert!( + error_msg.contains("'stream' is reserved for future WIT streaming functionality"), + "Error should explain why stream is reserved" + ); + assert!( + error_msg.contains("Suggestion: Use an alternative term"), + "Error should provide alternatives" + ); + assert!( + error_msg.contains("DataStream"), + "Error should mention the problematic type name" + ); + assert!( + error_msg.contains("flow"), + "Error should suggest 'flow' as an alternative" + ); + + Ok(()) + } +} + fn generate_wit_file( world_name: &str, new_imports: &Vec, From 0ad50966c76267e8ccbeaa7e8c0c4af45808d301 Mon Sep 17 00:00:00 2001 From: hosted-fornet Date: Thu, 25 Sep 2025 13:23:04 -0700 Subject: [PATCH 2/3] build: make sure enum variants match back and front --- src/build/caller_utils_ts_generator.rs | 16 ++++++++++++---- src/build/wit_generator.rs | 12 +++--------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/build/caller_utils_ts_generator.rs b/src/build/caller_utils_ts_generator.rs index ff459b1c..8329e509 100644 --- a/src/build/caller_utils_ts_generator.rs +++ b/src/build/caller_utils_ts_generator.rs @@ -25,11 +25,19 @@ pub fn to_pascal_case(s: &str) -> String { // Strip % prefix if present let s = strip_wit_escape(s); - let parts = s.split('-'); + let parts: Vec<&str> = s.split('-').collect(); let mut result = String::new(); for part in parts { - if !part.is_empty() { + if part.is_empty() { + continue; + } + + // Single letter parts should be uppercased entirely (part of an acronym) + if part.len() == 1 { + result.push(part.chars().next().unwrap().to_uppercase().next().unwrap()); + } else { + // Multi-letter parts: capitalize first letter, keep the rest as-is let mut chars = part.chars(); if let Some(first_char) = chars.next() { result.push(first_char.to_uppercase().next().unwrap()); @@ -524,8 +532,8 @@ fn generate_typescript_enum(enum_def: &WitEnum) -> String { for case in &enum_def.cases { let case_pascal = to_pascal_case(case); - // Use the original kebab-case value as the string value - enum_str.push_str(&format!(" {} = \"{}\",\n", case_pascal, case)); + // Use the PascalCase value as the string value to match the original Rust enum + enum_str.push_str(&format!(" {} = \"{}\",\n", case_pascal, case_pascal)); } enum_str.push_str("}"); diff --git a/src/build/wit_generator.rs b/src/build/wit_generator.rs index f2bbada6..0b16141f 100644 --- a/src/build/wit_generator.rs +++ b/src/build/wit_generator.rs @@ -65,19 +65,13 @@ fn to_kebab_case(s: &str) -> String { return s.replace('_', "-"); } - let mut result = String::with_capacity(s.len() + 5); // Extra capacity for hyphens + let mut result = String::with_capacity(s.len() + 10); // Extra capacity for hyphens let chars: Vec = s.chars().collect(); for (i, &c) in chars.iter().enumerate() { if c.is_uppercase() { - // Add hyphen if: - // 1. Not the first character - // 2. Previous character is lowercase - // 3. Or next character is lowercase (to handle acronyms like HTML) - if i > 0 - && (chars[i - 1].is_lowercase() - || (i < chars.len() - 1 && chars[i + 1].is_lowercase())) - { + // Add hyphen before each uppercase letter except at the beginning + if i > 0 { result.push('-'); } result.push(c.to_lowercase().next().unwrap()); From 5dd89a341faeca593093f20730e9495e3f4a8d82 Mon Sep 17 00:00:00 2001 From: hosted-fornet Date: Thu, 25 Sep 2025 15:25:52 -0700 Subject: [PATCH 3/3] add dep for tests --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index d9d39d7b..448aff0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,3 +83,6 @@ name = "kit" path = "src/main.rs" [lib] + +[dev-dependencies] +tempfile = "3"