1use crate::ast::*;
2use std::collections::{BTreeMap, HashSet};
3
4#[derive(Debug, Clone)]
6pub struct TypeScriptOutput {
7 pub interfaces: String,
8 pub stack_definition: String,
9 pub imports: String,
10}
11
12impl TypeScriptOutput {
13 pub fn full_file(&self) -> String {
14 format!(
15 "{}\n\n{}\n\n{}",
16 self.imports, self.interfaces, self.stack_definition
17 )
18 }
19}
20
21#[derive(Debug, Clone)]
23pub struct TypeScriptConfig {
24 pub package_name: String,
25 pub generate_helpers: bool,
26 pub interface_prefix: String,
27 pub export_const_name: String,
28}
29
30impl Default for TypeScriptConfig {
31 fn default() -> Self {
32 Self {
33 package_name: "hyperstack-react".to_string(),
34 generate_helpers: true,
35 interface_prefix: "".to_string(),
36 export_const_name: "STACK".to_string(),
37 }
38 }
39}
40
41pub trait TypeScriptGenerator {
43 fn generate_typescript(&self, config: &TypeScriptConfig) -> String;
44}
45
46pub trait TypeScriptInterfaceGenerator {
48 fn generate_interface(&self, name: &str, config: &TypeScriptConfig) -> String;
49}
50
51pub trait TypeScriptTypeMapper {
53 fn to_typescript_type(&self) -> String;
54}
55
56pub struct TypeScriptCompiler<S> {
58 spec: TypedStreamSpec<S>,
59 entity_name: String,
60 config: TypeScriptConfig,
61 idl: Option<serde_json::Value>, handlers_json: Option<serde_json::Value>, }
64
65impl<S> TypeScriptCompiler<S> {
66 pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
67 Self {
68 spec,
69 entity_name,
70 config: TypeScriptConfig::default(),
71 idl: None,
72 handlers_json: None,
73 }
74 }
75
76 pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
77 self.config = config;
78 self
79 }
80
81 pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
82 self.idl = idl;
83 self
84 }
85
86 pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
87 self.handlers_json = handlers;
88 self
89 }
90
91 pub fn compile(&self) -> TypeScriptOutput {
92 let imports = self.generate_imports();
93 let interfaces = self.generate_interfaces();
94 let stack_definition = self.generate_stack_definition();
95
96 TypeScriptOutput {
97 imports,
98 interfaces,
99 stack_definition,
100 }
101 }
102
103 fn generate_imports(&self) -> String {
104 format!(
105 "import {{ defineStack, createStateView, createListView }} from '{}';",
106 self.config.package_name
107 )
108 }
109
110 fn generate_interfaces(&self) -> String {
111 let mut interfaces = Vec::new();
112 let mut processed_types = HashSet::new();
113 let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
114
115 for handler in &self.spec.handlers {
117 let interface_sections = self.extract_interface_sections(handler);
118
119 for (section_name, mut fields) in interface_sections {
120 all_sections
121 .entry(section_name)
122 .or_default()
123 .append(&mut fields);
124 }
125 }
126
127 for (section_name, fields) in all_sections {
129 if processed_types.insert(section_name.clone()) {
130 let deduplicated_fields = self.deduplicate_fields(fields);
131 let interface =
132 self.generate_interface_from_fields(§ion_name, &deduplicated_fields);
133 interfaces.push(interface);
134 }
135 }
136
137 let main_interface = self.generate_main_entity_interface();
139 interfaces.push(main_interface);
140
141 let nested_interfaces = self.generate_nested_interfaces();
143 interfaces.extend(nested_interfaces);
144
145 if self.has_event_types() {
147 interfaces.push(self.generate_event_wrapper_interface());
148 }
149
150 interfaces.join("\n\n")
151 }
152
153 fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
154 let mut seen = HashSet::new();
155 let mut unique_fields = Vec::new();
156
157 fields.sort_by(|a, b| a.name.cmp(&b.name));
159
160 for field in fields {
161 if seen.insert(field.name.clone()) {
162 unique_fields.push(field);
163 }
164 }
165
166 unique_fields
167 }
168
169 fn extract_interface_sections(
170 &self,
171 handler: &TypedHandlerSpec<S>,
172 ) -> BTreeMap<String, Vec<TypeScriptField>> {
173 let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
174
175 for mapping in &handler.mappings {
176 let parts: Vec<&str> = mapping.target_path.split('.').collect();
177
178 if parts.len() > 1 {
179 let section_name = parts[0];
181 let field_name = parts[1];
182
183 let ts_field = TypeScriptField {
184 name: field_name.to_string(),
185 ts_type: self.mapping_to_typescript_type(mapping),
186 optional: self.is_field_optional(mapping),
187 description: None,
188 };
189
190 sections
191 .entry(section_name.to_string())
192 .or_default()
193 .push(ts_field);
194 } else {
195 let ts_field = TypeScriptField {
197 name: mapping.target_path.clone(),
198 ts_type: self.mapping_to_typescript_type(mapping),
199 optional: self.is_field_optional(mapping),
200 description: None,
201 };
202
203 sections
204 .entry("Root".to_string())
205 .or_default()
206 .push(ts_field);
207 }
208 }
209
210 self.add_unmapped_fields(&mut sections);
213
214 sections
215 }
216
217 fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
218 if !self.spec.sections.is_empty() {
220 for section in &self.spec.sections {
222 let section_fields = sections.entry(section.name.clone()).or_default();
223
224 for field_info in §ion.fields {
225 let already_exists = section_fields.iter().any(|f| {
227 f.name == field_info.field_name
228 || f.name == to_camel_case(&field_info.field_name)
229 });
230
231 if !already_exists {
232 section_fields.push(TypeScriptField {
233 name: field_info.field_name.clone(),
234 ts_type: self.field_type_info_to_typescript(field_info),
235 optional: field_info.is_optional,
236 description: None,
237 });
238 }
239 }
240 }
241 } else {
242 for (field_path, field_type_info) in &self.spec.field_mappings {
244 let parts: Vec<&str> = field_path.split('.').collect();
245 if parts.len() > 1 {
246 let section_name = parts[0];
247 let field_name = parts[1];
248
249 let section_fields = sections.entry(section_name.to_string()).or_default();
250
251 let already_exists = section_fields
252 .iter()
253 .any(|f| f.name == field_name || f.name == to_camel_case(field_name));
254
255 if !already_exists {
256 section_fields.push(TypeScriptField {
257 name: field_name.to_string(),
258 ts_type: self.base_type_to_typescript(
259 &field_type_info.base_type,
260 field_type_info.is_array,
261 ),
262 optional: field_type_info.is_optional,
263 description: None,
264 });
265 }
266 }
267 }
268 }
269 }
270
271 fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
272 let interface_name = if name == "Root" {
274 format!(
275 "{}{}",
276 self.config.interface_prefix,
277 to_pascal_case(&self.entity_name)
278 )
279 } else {
280 let base_name = if self.entity_name.contains("Game") {
283 "Game"
284 } else {
285 &self.entity_name
286 };
287 format!(
288 "{}{}{}",
289 self.config.interface_prefix,
290 base_name,
291 to_pascal_case(name)
292 )
293 };
294
295 let field_definitions: Vec<String> = fields
296 .iter()
297 .map(|field| {
298 let optional_marker = if field.optional { "?" } else { "" };
299 let field_name = to_camel_case(&field.name);
301 format!(" {}{}: {};", field_name, optional_marker, field.ts_type)
302 })
303 .collect();
304
305 format!(
306 "export interface {} {{\n{}\n}}",
307 interface_name,
308 field_definitions.join("\n")
309 )
310 }
311
312 fn generate_main_entity_interface(&self) -> String {
313 let entity_name = to_pascal_case(&self.entity_name);
314
315 let mut sections = BTreeMap::new();
317
318 for handler in &self.spec.handlers {
319 for mapping in &handler.mappings {
320 let parts: Vec<&str> = mapping.target_path.split('.').collect();
321 if parts.len() > 1 {
322 sections.insert(parts[0], true);
323 }
324 }
325 }
326
327 if !self.spec.sections.is_empty() {
329 for section in &self.spec.sections {
330 sections.insert(§ion.name, true);
331 }
332 } else {
333 for mapping in &self.spec.handlers {
335 for field_mapping in &mapping.mappings {
336 let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
337 if parts.len() > 1 {
338 sections.insert(parts[0], true);
339 }
340 }
341 }
342 }
343
344 let mut fields = Vec::new();
345
346 for section in sections.keys() {
348 let base_name = if self.entity_name.contains("Game") {
350 "Game"
351 } else {
352 &self.entity_name
353 };
354 let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
355 fields.push(format!(
356 " {}: {};",
357 to_camel_case(section),
358 section_interface_name
359 ));
360 }
361
362 if fields.is_empty() {
364 fields.push(" // Generated interface - extend as needed".to_string());
365 }
366
367 format!(
368 "export interface {} {{\n{}\n}}",
369 entity_name,
370 fields.join("\n")
371 )
372 }
373
374 fn generate_stack_definition(&self) -> String {
375 let stack_name = to_kebab_case(&self.entity_name);
376 let entity_pascal = to_pascal_case(&self.entity_name);
377 let export_name = format!(
378 "{}_{}",
379 self.entity_name.to_uppercase(),
380 self.config.export_const_name
381 );
382
383 let _views = self.generate_view_definitions();
384 let helpers = if self.config.generate_helpers {
385 self.generate_helper_functions()
386 } else {
387 String::new()
388 };
389
390 let helpers_section = if helpers.is_empty() {
391 String::new()
392 } else {
393 format!(",\n helpers: {{\n{}\n }}", helpers)
394 };
395
396 format!(
397 r#"export const {} = defineStack({{
398 name: '{}',
399 views: {{
400 {}: {{
401 state: createStateView<{}>('{}/state'),
402 list: createListView<{}>('{}/list')
403 }}
404 }}{}
405}});"#,
406 export_name,
407 stack_name,
408 to_camel_case(&self.entity_name),
409 entity_pascal,
410 self.entity_name,
411 entity_pascal,
412 self.entity_name,
413 helpers_section
414 )
415 }
416
417 fn generate_view_definitions(&self) -> String {
418 to_camel_case(&self.entity_name)
421 }
422
423 fn generate_helper_functions(&self) -> String {
424 let mut helpers = Vec::new();
425
426 for handler in &self.spec.handlers {
428 for mapping in &handler.mappings {
429 if let Some(helper) = self.generate_helper_for_mapping(mapping) {
430 helpers.push(helper);
431 }
432 }
433 }
434
435 helpers.join(",\n")
436 }
437
438 fn generate_helper_for_mapping(&self, mapping: &TypedFieldMapping<S>) -> Option<String> {
439 if let Some(transform) = &mapping.transform {
441 match transform {
442 Transformation::HexEncode => {
443 let helper_name = format!(
444 "format{}",
445 to_pascal_case(&mapping.target_path.replace(".", ""))
446 );
447 Some(format!(
448 " {}: (value: string) => value.startsWith('0x') ? value : `0x${{value}}`",
449 helper_name
450 ))
451 }
452 Transformation::HexDecode => {
453 let helper_name = format!(
454 "decode{}",
455 to_pascal_case(&mapping.target_path.replace(".", ""))
456 );
457 Some(format!(
458 " {}: (value: string) => value.startsWith('0x') ? value.slice(2) : value",
459 helper_name
460 ))
461 }
462 _ => None,
463 }
464 } else {
465 None
466 }
467 }
468
469 fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
470 if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
472 let ts_type = self.field_type_info_to_typescript(field_info);
473
474 if matches!(mapping.population, PopulationStrategy::Append) {
476 return if ts_type.ends_with("[]") {
477 ts_type
478 } else {
479 format!("{}[]", ts_type)
480 };
481 }
482
483 return ts_type;
484 }
485
486 match &mapping.population {
488 PopulationStrategy::Append => {
489 match &mapping.source {
491 MappingSource::AsEvent { .. } => "any[]".to_string(),
492 _ => "any[]".to_string(),
493 }
494 }
495 _ => {
496 let base_type = match &mapping.source {
498 MappingSource::FromSource { .. } => {
499 self.infer_type_from_field_name(&mapping.target_path)
500 }
501 MappingSource::Constant(value) => value_to_typescript_type(value),
502 MappingSource::AsEvent { .. } => "any".to_string(),
503 _ => "any".to_string(),
504 };
505
506 if let Some(transform) = &mapping.transform {
508 match transform {
509 Transformation::HexEncode | Transformation::HexDecode => {
510 "string".to_string()
511 }
512 Transformation::Base58Encode | Transformation::Base58Decode => {
513 "string".to_string()
514 }
515 Transformation::ToString => "string".to_string(),
516 Transformation::ToNumber => "number".to_string(),
517 }
518 } else {
519 base_type
520 }
521 }
522 }
523 }
524
525 fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
527 if let Some(resolved) = &field_info.resolved_type {
529 let interface_name = self.resolved_type_to_interface_name(resolved);
530
531 let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
533 {
534 format!("EventWrapper<{}>", interface_name)
535 } else {
536 interface_name
537 };
538
539 let with_array = if field_info.is_array {
541 format!("{}[]", base_type)
542 } else {
543 base_type
544 };
545
546 return with_array;
547 }
548
549 if field_info.base_type == BaseType::Any
552 || (field_info.base_type == BaseType::Array
553 && field_info.inner_type.as_deref() == Some("Value"))
554 {
555 if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
556 return if field_info.is_array {
557 format!("{}[]", event_type)
558 } else if field_info.is_optional {
559 format!("{} | null", event_type)
560 } else {
561 event_type
562 };
563 }
564 }
565
566 self.base_type_to_typescript(&field_info.base_type, field_info.is_array)
568 }
569
570 fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
572 let handlers = self.handlers_json.as_ref()?.as_array()?;
574
575 for handler in handlers {
577 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
578 for mapping in mappings {
579 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
580 let target_parts: Vec<&str> = target_path.split('.').collect();
582 if let Some(target_field) = target_parts.last() {
583 if *target_field == field_name {
584 if let Some(source) = mapping.get("source") {
586 if self.extract_event_data(source).is_some() {
587 return Some(format!(
589 "{}Event",
590 to_pascal_case(field_name)
591 ));
592 }
593 }
594 }
595 }
596 }
597 }
598 }
599 }
600 None
601 }
602
603 fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
605 to_pascal_case(&resolved.type_name)
606 }
607
608 fn generate_nested_interfaces(&self) -> Vec<String> {
610 let mut interfaces = Vec::new();
611 let mut generated_types = HashSet::new();
612
613 for section in &self.spec.sections {
615 for field_info in §ion.fields {
616 if let Some(resolved) = &field_info.resolved_type {
617 let type_name = resolved.type_name.clone();
618
619 if generated_types.insert(type_name) {
621 let interface = self.generate_interface_for_resolved_type(resolved);
622 interfaces.push(interface);
623 }
624 }
625 }
626 }
627
628 interfaces.extend(self.generate_event_interfaces(&mut generated_types));
630
631 if let Some(idl_value) = &self.idl {
633 if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
634 for type_def in types_array {
635 if let (Some(type_name), Some(type_obj)) = (
636 type_def.get("name").and_then(|v| v.as_str()),
637 type_def.get("type").and_then(|v| v.as_object()),
638 ) {
639 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
640 if generated_types.insert(type_name.to_string()) {
642 if let Some(variants) =
643 type_obj.get("variants").and_then(|v| v.as_array())
644 {
645 let variant_names: Vec<String> = variants
646 .iter()
647 .filter_map(|v| {
648 v.get("name")
649 .and_then(|n| n.as_str())
650 .map(|s| s.to_string())
651 })
652 .collect();
653
654 if !variant_names.is_empty() {
655 let interface_name = to_pascal_case(type_name);
656 let variant_strings: Vec<String> = variant_names
657 .iter()
658 .map(|v| format!("\"{}\"", to_pascal_case(v)))
659 .collect();
660
661 let enum_type = format!(
662 "export type {} = {};",
663 interface_name,
664 variant_strings.join(" | ")
665 );
666 interfaces.push(enum_type);
667 }
668 }
669 }
670 }
671 }
672 }
673 }
674 }
675
676 interfaces
677 }
678
679 fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
681 let mut interfaces = Vec::new();
682
683 let handlers = match &self.handlers_json {
685 Some(h) => h.as_array(),
686 None => return interfaces,
687 };
688
689 let handlers_array = match handlers {
690 Some(arr) => arr,
691 None => return interfaces,
692 };
693
694 for handler in handlers_array {
696 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
698 for mapping in mappings {
699 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
700 if target_path.contains(".events.") || target_path.starts_with("events.") {
702 if let Some(source) = mapping.get("source") {
704 if let Some(event_data) = self.extract_event_data(source) {
705 if let Some(handler_source) = handler.get("source") {
707 if let Some(instruction_name) =
708 self.extract_instruction_name(handler_source)
709 {
710 let event_field_name =
712 target_path.split('.').next_back().unwrap_or("");
713 let interface_name = format!(
714 "{}Event",
715 to_pascal_case(event_field_name)
716 );
717
718 if generated_types.insert(interface_name.clone()) {
720 if let Some(interface) = self
721 .generate_event_interface_from_idl(
722 &interface_name,
723 &instruction_name,
724 &event_data,
725 )
726 {
727 interfaces.push(interface);
728 }
729 }
730 }
731 }
732 }
733 }
734 }
735 }
736 }
737 }
738 }
739
740 interfaces
741 }
742
743 fn extract_event_data(
745 &self,
746 source: &serde_json::Value,
747 ) -> Option<Vec<(String, Option<String>)>> {
748 if let Some(as_event) = source.get("AsEvent") {
749 if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
750 let mut event_fields = Vec::new();
751 for field in fields {
752 if let Some(from_source) = field.get("FromSource") {
753 if let Some(path) = from_source
754 .get("path")
755 .and_then(|p| p.get("segments"))
756 .and_then(|s| s.as_array())
757 {
758 if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
760 let transform = from_source
761 .get("transform")
762 .and_then(|t| t.as_str())
763 .map(|s| s.to_string());
764 event_fields.push((field_name.to_string(), transform));
765 }
766 }
767 }
768 }
769 return Some(event_fields);
770 }
771 }
772 None
773 }
774
775 fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
777 if let Some(source_obj) = source.get("Source") {
778 if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
779 if let Some(instruction_part) = type_name.strip_suffix("IxState") {
781 return Some(to_snake_case(instruction_part));
782 }
783 }
784 }
785 None
786 }
787
788 fn generate_event_interface_from_idl(
790 &self,
791 interface_name: &str,
792 instruction_name: &str,
793 captured_fields: &[(String, Option<String>)],
794 ) -> Option<String> {
795 if captured_fields.is_empty() {
797 return Some(format!("export interface {} {{}}", interface_name));
798 }
799
800 let idl_value = self.idl.as_ref()?;
801 let instructions = idl_value.get("instructions")?.as_array()?;
802
803 for instruction in instructions {
805 if let Some(name) = instruction.get("name").and_then(|n| n.as_str()) {
806 if name == instruction_name {
807 if let Some(args) = instruction.get("args").and_then(|a| a.as_array()) {
809 let mut fields = Vec::new();
810
811 for (field_name, transform) in captured_fields {
813 for arg in args {
815 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
816 if arg_name == field_name {
817 if let Some(arg_type) = arg.get("type") {
818 let ts_type = self.idl_type_to_typescript(
819 arg_type,
820 transform.as_deref(),
821 );
822 let camel_name = to_camel_case(field_name);
823 fields.push(format!(" {}: {};", camel_name, ts_type));
824 }
825 break;
826 }
827 }
828 }
829 }
830
831 if !fields.is_empty() {
832 return Some(format!(
833 "export interface {} {{\n{}\n}}",
834 interface_name,
835 fields.join("\n")
836 ));
837 }
838 }
839 }
840 }
841 }
842
843 None
844 }
845
846 fn idl_type_to_typescript(
848 &self,
849 idl_type: &serde_json::Value,
850 transform: Option<&str>,
851 ) -> String {
852 #![allow(clippy::only_used_in_recursion)]
853 if transform == Some("HexEncode") {
855 return "string".to_string();
856 }
857
858 if let Some(type_str) = idl_type.as_str() {
860 return match type_str {
861 "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" => {
862 "number".to_string()
863 }
864 "f32" | "f64" => "number".to_string(),
865 "bool" => "boolean".to_string(),
866 "string" => "string".to_string(),
867 "pubkey" | "publicKey" => "string".to_string(),
868 "bytes" => "string".to_string(),
869 _ => "any".to_string(),
870 };
871 }
872
873 if let Some(type_obj) = idl_type.as_object() {
875 if let Some(option_type) = type_obj.get("option") {
876 let inner = self.idl_type_to_typescript(option_type, None);
877 return format!("{} | null", inner);
878 }
879 if let Some(vec_type) = type_obj.get("vec") {
880 let inner = self.idl_type_to_typescript(vec_type, None);
881 return format!("{}[]", inner);
882 }
883 }
884
885 "any".to_string()
886 }
887
888 fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
890 let interface_name = to_pascal_case(&resolved.type_name);
891
892 if resolved.is_enum {
894 let variants: Vec<String> = resolved
895 .enum_variants
896 .iter()
897 .map(|v| format!("\"{}\"", to_pascal_case(v)))
898 .collect();
899
900 return format!("export type {} = {};", interface_name, variants.join(" | "));
901 }
902
903 let fields: Vec<String> = resolved
905 .fields
906 .iter()
907 .map(|field| {
908 let field_name = to_camel_case(&field.field_name);
909 let optional_marker = if field.is_optional { "?" } else { "" };
910 let ts_type = self.resolved_field_to_typescript(field);
911 format!(" {}{}: {};", field_name, optional_marker, ts_type)
912 })
913 .collect();
914
915 format!(
916 "export interface {} {{\n{}\n}}",
917 interface_name,
918 fields.join("\n")
919 )
920 }
921
922 fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
924 let base_ts = self.base_type_to_typescript(&field.base_type, false);
925
926 if field.is_array {
927 format!("{}[]", base_ts)
928 } else {
929 base_ts
930 }
931 }
932
933 fn has_event_types(&self) -> bool {
935 for section in &self.spec.sections {
936 for field_info in §ion.fields {
937 if let Some(resolved) = &field_info.resolved_type {
938 if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
939 return true;
940 }
941 }
942 }
943 }
944 false
945 }
946
947 fn generate_event_wrapper_interface(&self) -> String {
949 r#"/**
950 * Wrapper for event data that includes context metadata.
951 * Events are automatically wrapped in this structure at runtime.
952 */
953export interface EventWrapper<T> {
954 /** Unix timestamp when the event was processed */
955 timestamp: number;
956 /** The event-specific data */
957 data: T;
958 /** Optional blockchain slot number */
959 slot?: number;
960 /** Optional transaction signature */
961 signature?: string;
962}"#
963 .to_string()
964 }
965
966 fn infer_type_from_field_name(&self, field_name: &str) -> String {
967 let lower_name = field_name.to_lowercase();
968
969 if lower_name.contains("events.") {
971 return "any".to_string();
973 }
974
975 if lower_name.contains("id")
977 || lower_name.contains("count")
978 || lower_name.contains("number")
979 || lower_name.contains("timestamp")
980 || lower_name.contains("time")
981 || lower_name.contains("at")
982 || lower_name.contains("volume")
983 || lower_name.contains("amount")
984 || lower_name.contains("ev")
985 || lower_name.contains("fee")
986 || lower_name.contains("payout")
987 || lower_name.contains("distributed")
988 || lower_name.contains("claimable")
989 || lower_name.contains("total")
990 || lower_name.contains("rate")
991 || lower_name.contains("ratio")
992 || lower_name.contains("current")
993 || lower_name.contains("state")
994 {
995 "number".to_string()
996 } else if lower_name.contains("status")
997 || lower_name.contains("hash")
998 || lower_name.contains("address")
999 || lower_name.contains("key")
1000 {
1001 "string".to_string()
1002 } else {
1003 "any".to_string()
1004 }
1005 }
1006
1007 fn is_field_optional(&self, mapping: &TypedFieldMapping<S>) -> bool {
1008 match &mapping.source {
1010 MappingSource::Constant(_) => false,
1012 MappingSource::AsEvent { .. } => true,
1014 MappingSource::FromSource { .. } => true,
1016 _ => true,
1018 }
1019 }
1020
1021 fn base_type_to_typescript(&self, base_type: &BaseType, is_array: bool) -> String {
1023 let base_ts_type = match base_type {
1024 BaseType::Integer => "number",
1025 BaseType::Float => "number",
1026 BaseType::String => "string",
1027 BaseType::Boolean => "boolean",
1028 BaseType::Timestamp => "number", BaseType::Binary => "string", BaseType::Pubkey => "string", BaseType::Array => "any[]", BaseType::Object => "Record<string, any>", BaseType::Any => "any",
1034 };
1035
1036 if is_array && !matches!(base_type, BaseType::Array) {
1037 format!("{}[]", base_ts_type)
1038 } else {
1039 base_ts_type.to_string()
1040 }
1041 }
1042}
1043
1044#[derive(Debug, Clone)]
1046struct TypeScriptField {
1047 name: String,
1048 ts_type: String,
1049 optional: bool,
1050 #[allow(dead_code)]
1051 description: Option<String>,
1052}
1053
1054fn value_to_typescript_type(value: &serde_json::Value) -> String {
1056 match value {
1057 serde_json::Value::Number(_) => "number".to_string(),
1058 serde_json::Value::String(_) => "string".to_string(),
1059 serde_json::Value::Bool(_) => "boolean".to_string(),
1060 serde_json::Value::Array(_) => "any[]".to_string(),
1061 serde_json::Value::Object(_) => "Record<string, any>".to_string(),
1062 serde_json::Value::Null => "null".to_string(),
1063 }
1064}
1065
1066fn to_pascal_case(s: &str) -> String {
1068 s.split(['_', '-', '.'])
1069 .map(|word| {
1070 let mut chars = word.chars();
1071 match chars.next() {
1072 None => String::new(),
1073 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1074 }
1075 })
1076 .collect()
1077}
1078
1079fn to_camel_case(s: &str) -> String {
1081 let pascal = to_pascal_case(s);
1082 let mut chars = pascal.chars();
1083 match chars.next() {
1084 None => String::new(),
1085 Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
1086 }
1087}
1088
1089fn to_snake_case(s: &str) -> String {
1091 let mut result = String::new();
1092
1093 for ch in s.chars() {
1094 if ch.is_uppercase() {
1095 if !result.is_empty() {
1096 result.push('_');
1097 }
1098 result.push(ch.to_lowercase().next().unwrap());
1099 } else {
1100 result.push(ch);
1101 }
1102 }
1103
1104 result
1105}
1106
1107fn to_kebab_case(s: &str) -> String {
1109 let mut result = String::new();
1110
1111 for ch in s.chars() {
1112 if ch.is_uppercase() && !result.is_empty() {
1113 result.push('-');
1114 }
1115 result.push(ch.to_lowercase().next().unwrap());
1116 }
1117
1118 result
1119}
1120
1121pub fn generate_typescript_from_spec_fn<F, S>(
1124 spec_fn: F,
1125 entity_name: String,
1126 config: Option<TypeScriptConfig>,
1127) -> Result<TypeScriptOutput, String>
1128where
1129 F: Fn() -> TypedStreamSpec<S>,
1130{
1131 let spec = spec_fn();
1132 let compiler =
1133 TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
1134
1135 Ok(compiler.compile())
1136}
1137
1138pub fn write_typescript_to_file(
1140 output: &TypeScriptOutput,
1141 path: &std::path::Path,
1142) -> Result<(), std::io::Error> {
1143 std::fs::write(path, output.full_file())
1144}
1145
1146pub fn compile_serializable_spec(
1149 spec: SerializableStreamSpec,
1150 entity_name: String,
1151 config: Option<TypeScriptConfig>,
1152) -> Result<TypeScriptOutput, String> {
1153 let idl = spec
1155 .idl
1156 .as_ref()
1157 .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
1158
1159 let handlers = serde_json::to_value(&spec.handlers).ok();
1161
1162 let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
1165
1166 let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
1167 .with_idl(idl)
1168 .with_handlers_json(handlers)
1169 .with_config(config.unwrap_or_default());
1170
1171 Ok(compiler.compile())
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176 use super::*;
1177
1178 #[test]
1179 fn test_case_conversions() {
1180 assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
1181 assert_eq!(to_camel_case("settlement_game"), "settlementGame");
1182 assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
1183 }
1184
1185 #[test]
1186 fn test_value_to_typescript_type() {
1187 assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
1188 assert_eq!(
1189 value_to_typescript_type(&serde_json::json!("hello")),
1190 "string"
1191 );
1192 assert_eq!(
1193 value_to_typescript_type(&serde_json::json!(true)),
1194 "boolean"
1195 );
1196 assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
1197 }
1198}