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 {
130 if !is_root_section(§ion_name) && processed_types.insert(section_name.clone()) {
131 let deduplicated_fields = self.deduplicate_fields(fields);
132 let interface =
133 self.generate_interface_from_fields(§ion_name, &deduplicated_fields);
134 interfaces.push(interface);
135 }
136 }
137
138 let main_interface = self.generate_main_entity_interface();
140 interfaces.push(main_interface);
141
142 let nested_interfaces = self.generate_nested_interfaces();
144 interfaces.extend(nested_interfaces);
145
146 if self.has_event_types() {
148 interfaces.push(self.generate_event_wrapper_interface());
149 }
150
151 interfaces.join("\n\n")
152 }
153
154 fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
155 let mut seen = HashSet::new();
156 let mut unique_fields = Vec::new();
157
158 fields.sort_by(|a, b| a.name.cmp(&b.name));
160
161 for field in fields {
162 if seen.insert(field.name.clone()) {
163 unique_fields.push(field);
164 }
165 }
166
167 unique_fields
168 }
169
170 fn extract_interface_sections(
171 &self,
172 handler: &TypedHandlerSpec<S>,
173 ) -> BTreeMap<String, Vec<TypeScriptField>> {
174 let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
175
176 for mapping in &handler.mappings {
177 let parts: Vec<&str> = mapping.target_path.split('.').collect();
178
179 if parts.len() > 1 {
180 let section_name = parts[0];
182 let field_name = parts[1];
183
184 let ts_field = TypeScriptField {
185 name: field_name.to_string(),
186 ts_type: self.mapping_to_typescript_type(mapping),
187 optional: self.is_field_optional(mapping),
188 description: None,
189 };
190
191 sections
192 .entry(section_name.to_string())
193 .or_default()
194 .push(ts_field);
195 } else {
196 let ts_field = TypeScriptField {
198 name: mapping.target_path.clone(),
199 ts_type: self.mapping_to_typescript_type(mapping),
200 optional: self.is_field_optional(mapping),
201 description: None,
202 };
203
204 sections
205 .entry("Root".to_string())
206 .or_default()
207 .push(ts_field);
208 }
209 }
210
211 self.add_unmapped_fields(&mut sections);
214
215 sections
216 }
217
218 fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
219 if !self.spec.sections.is_empty() {
221 for section in &self.spec.sections {
223 let section_fields = sections.entry(section.name.clone()).or_default();
224
225 for field_info in §ion.fields {
226 let already_exists = section_fields.iter().any(|f| {
228 f.name == field_info.field_name
229 || f.name == to_camel_case(&field_info.field_name)
230 });
231
232 if !already_exists {
233 section_fields.push(TypeScriptField {
234 name: field_info.field_name.clone(),
235 ts_type: self.field_type_info_to_typescript(field_info),
236 optional: field_info.is_optional,
237 description: None,
238 });
239 }
240 }
241 }
242 } else {
243 for (field_path, field_type_info) in &self.spec.field_mappings {
245 let parts: Vec<&str> = field_path.split('.').collect();
246 if parts.len() > 1 {
247 let section_name = parts[0];
248 let field_name = parts[1];
249
250 let section_fields = sections.entry(section_name.to_string()).or_default();
251
252 let already_exists = section_fields
253 .iter()
254 .any(|f| f.name == field_name || f.name == to_camel_case(field_name));
255
256 if !already_exists {
257 section_fields.push(TypeScriptField {
258 name: field_name.to_string(),
259 ts_type: self.base_type_to_typescript(
260 &field_type_info.base_type,
261 field_type_info.is_array,
262 ),
263 optional: field_type_info.is_optional,
264 description: None,
265 });
266 }
267 }
268 }
269 }
270 }
271
272 fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
273 let interface_name = if name == "Root" {
275 format!(
276 "{}{}",
277 self.config.interface_prefix,
278 to_pascal_case(&self.entity_name)
279 )
280 } else {
281 let base_name = if self.entity_name.contains("Game") {
284 "Game"
285 } else {
286 &self.entity_name
287 };
288 format!(
289 "{}{}{}",
290 self.config.interface_prefix,
291 base_name,
292 to_pascal_case(name)
293 )
294 };
295
296 let field_definitions: Vec<String> = fields
297 .iter()
298 .map(|field| {
299 let optional_marker = if field.optional { "?" } else { "" };
300 let field_name = to_camel_case(&field.name);
302 format!(" {}{}: {};", field_name, optional_marker, field.ts_type)
303 })
304 .collect();
305
306 format!(
307 "export interface {} {{\n{}\n}}",
308 interface_name,
309 field_definitions.join("\n")
310 )
311 }
312
313 fn generate_main_entity_interface(&self) -> String {
314 let entity_name = to_pascal_case(&self.entity_name);
315
316 let mut sections = BTreeMap::new();
318
319 for handler in &self.spec.handlers {
320 for mapping in &handler.mappings {
321 let parts: Vec<&str> = mapping.target_path.split('.').collect();
322 if parts.len() > 1 {
323 sections.insert(parts[0], true);
324 }
325 }
326 }
327
328 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 {
334 for field_mapping in &mapping.mappings {
335 let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
336 if parts.len() > 1 {
337 sections.insert(parts[0], true);
338 }
339 }
340 }
341 }
342
343 let mut fields = Vec::new();
344
345 for section in sections.keys() {
347 if !is_root_section(section) {
348 let base_name = if self.entity_name.contains("Game") {
349 "Game"
350 } else {
351 &self.entity_name
352 };
353 let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
354 fields.push(format!(
355 " {}: {};",
356 to_camel_case(section),
357 section_interface_name
358 ));
359 }
360 }
361
362 for section in &self.spec.sections {
364 if is_root_section(§ion.name) {
365 for field in §ion.fields {
366 let field_name = to_camel_case(&field.field_name);
367 let ts_type = self.field_type_info_to_typescript(field);
368 let optional_marker = if field.is_optional { "?" } else { "" };
369 fields.push(format!(" {}{}: {};", field_name, optional_marker, ts_type));
370 }
371 }
372 }
373
374 if fields.is_empty() {
375 fields.push(" // Generated interface - extend as needed".to_string());
376 }
377
378 format!(
379 "export interface {} {{\n{}\n}}",
380 entity_name,
381 fields.join("\n")
382 )
383 }
384
385 fn generate_stack_definition(&self) -> String {
386 let stack_name = to_kebab_case(&self.entity_name);
387 let entity_pascal = to_pascal_case(&self.entity_name);
388 let export_name = format!(
389 "{}_{}",
390 self.entity_name.to_uppercase(),
391 self.config.export_const_name
392 );
393
394 let _views = self.generate_view_definitions();
395 let helpers = if self.config.generate_helpers {
396 self.generate_helper_functions()
397 } else {
398 String::new()
399 };
400
401 let helpers_section = if helpers.is_empty() {
402 String::new()
403 } else {
404 format!(",\n helpers: {{\n{}\n }}", helpers)
405 };
406
407 format!(
408 r#"export const {} = defineStack({{
409 name: '{}',
410 views: {{
411 {}: {{
412 state: createStateView<{}>('{}/state'),
413 list: createListView<{}>('{}/list')
414 }}
415 }}{}
416}});"#,
417 export_name,
418 stack_name,
419 to_camel_case(&self.entity_name),
420 entity_pascal,
421 self.entity_name,
422 entity_pascal,
423 self.entity_name,
424 helpers_section
425 )
426 }
427
428 fn generate_view_definitions(&self) -> String {
429 to_camel_case(&self.entity_name)
432 }
433
434 fn generate_helper_functions(&self) -> String {
435 let mut helpers = Vec::new();
436
437 for handler in &self.spec.handlers {
439 for mapping in &handler.mappings {
440 if let Some(helper) = self.generate_helper_for_mapping(mapping) {
441 helpers.push(helper);
442 }
443 }
444 }
445
446 helpers.join(",\n")
447 }
448
449 fn generate_helper_for_mapping(&self, mapping: &TypedFieldMapping<S>) -> Option<String> {
450 if let Some(transform) = &mapping.transform {
452 match transform {
453 Transformation::HexEncode => {
454 let helper_name = format!(
455 "format{}",
456 to_pascal_case(&mapping.target_path.replace(".", ""))
457 );
458 Some(format!(
459 " {}: (value: string) => value.startsWith('0x') ? value : `0x${{value}}`",
460 helper_name
461 ))
462 }
463 Transformation::HexDecode => {
464 let helper_name = format!(
465 "decode{}",
466 to_pascal_case(&mapping.target_path.replace(".", ""))
467 );
468 Some(format!(
469 " {}: (value: string) => value.startsWith('0x') ? value.slice(2) : value",
470 helper_name
471 ))
472 }
473 _ => None,
474 }
475 } else {
476 None
477 }
478 }
479
480 fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
481 if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
483 let ts_type = self.field_type_info_to_typescript(field_info);
484
485 if matches!(mapping.population, PopulationStrategy::Append) {
487 return if ts_type.ends_with("[]") {
488 ts_type
489 } else {
490 format!("{}[]", ts_type)
491 };
492 }
493
494 return ts_type;
495 }
496
497 match &mapping.population {
499 PopulationStrategy::Append => {
500 match &mapping.source {
502 MappingSource::AsEvent { .. } => "any[]".to_string(),
503 _ => "any[]".to_string(),
504 }
505 }
506 _ => {
507 let base_type = match &mapping.source {
509 MappingSource::FromSource { .. } => {
510 self.infer_type_from_field_name(&mapping.target_path)
511 }
512 MappingSource::Constant(value) => value_to_typescript_type(value),
513 MappingSource::AsEvent { .. } => "any".to_string(),
514 _ => "any".to_string(),
515 };
516
517 if let Some(transform) = &mapping.transform {
519 match transform {
520 Transformation::HexEncode | Transformation::HexDecode => {
521 "string".to_string()
522 }
523 Transformation::Base58Encode | Transformation::Base58Decode => {
524 "string".to_string()
525 }
526 Transformation::ToString => "string".to_string(),
527 Transformation::ToNumber => "number".to_string(),
528 }
529 } else {
530 base_type
531 }
532 }
533 }
534 }
535
536 fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
538 if let Some(resolved) = &field_info.resolved_type {
540 let interface_name = self.resolved_type_to_interface_name(resolved);
541
542 let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
544 {
545 format!("EventWrapper<{}>", interface_name)
546 } else {
547 interface_name
548 };
549
550 let with_array = if field_info.is_array {
552 format!("{}[]", base_type)
553 } else {
554 base_type
555 };
556
557 return with_array;
558 }
559
560 if field_info.base_type == BaseType::Any
563 || (field_info.base_type == BaseType::Array
564 && field_info.inner_type.as_deref() == Some("Value"))
565 {
566 if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
567 return if field_info.is_array {
568 format!("{}[]", event_type)
569 } else if field_info.is_optional {
570 format!("{} | null", event_type)
571 } else {
572 event_type
573 };
574 }
575 }
576
577 self.base_type_to_typescript(&field_info.base_type, field_info.is_array)
579 }
580
581 fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
583 let handlers = self.handlers_json.as_ref()?.as_array()?;
585
586 for handler in handlers {
588 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
589 for mapping in mappings {
590 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
591 let target_parts: Vec<&str> = target_path.split('.').collect();
593 if let Some(target_field) = target_parts.last() {
594 if *target_field == field_name {
595 if let Some(source) = mapping.get("source") {
597 if self.extract_event_data(source).is_some() {
598 return Some(format!(
600 "{}Event",
601 to_pascal_case(field_name)
602 ));
603 }
604 }
605 }
606 }
607 }
608 }
609 }
610 }
611 None
612 }
613
614 fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
616 to_pascal_case(&resolved.type_name)
617 }
618
619 fn generate_nested_interfaces(&self) -> Vec<String> {
621 let mut interfaces = Vec::new();
622 let mut generated_types = HashSet::new();
623
624 for section in &self.spec.sections {
626 for field_info in §ion.fields {
627 if let Some(resolved) = &field_info.resolved_type {
628 let type_name = resolved.type_name.clone();
629
630 if generated_types.insert(type_name) {
632 let interface = self.generate_interface_for_resolved_type(resolved);
633 interfaces.push(interface);
634 }
635 }
636 }
637 }
638
639 interfaces.extend(self.generate_event_interfaces(&mut generated_types));
641
642 if let Some(idl_value) = &self.idl {
644 if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
645 for type_def in types_array {
646 if let (Some(type_name), Some(type_obj)) = (
647 type_def.get("name").and_then(|v| v.as_str()),
648 type_def.get("type").and_then(|v| v.as_object()),
649 ) {
650 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
651 if generated_types.insert(type_name.to_string()) {
653 if let Some(variants) =
654 type_obj.get("variants").and_then(|v| v.as_array())
655 {
656 let variant_names: Vec<String> = variants
657 .iter()
658 .filter_map(|v| {
659 v.get("name")
660 .and_then(|n| n.as_str())
661 .map(|s| s.to_string())
662 })
663 .collect();
664
665 if !variant_names.is_empty() {
666 let interface_name = to_pascal_case(type_name);
667 let variant_strings: Vec<String> = variant_names
668 .iter()
669 .map(|v| format!("\"{}\"", to_pascal_case(v)))
670 .collect();
671
672 let enum_type = format!(
673 "export type {} = {};",
674 interface_name,
675 variant_strings.join(" | ")
676 );
677 interfaces.push(enum_type);
678 }
679 }
680 }
681 }
682 }
683 }
684 }
685 }
686
687 interfaces
688 }
689
690 fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
692 let mut interfaces = Vec::new();
693
694 let handlers = match &self.handlers_json {
696 Some(h) => h.as_array(),
697 None => return interfaces,
698 };
699
700 let handlers_array = match handlers {
701 Some(arr) => arr,
702 None => return interfaces,
703 };
704
705 for handler in handlers_array {
707 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
709 for mapping in mappings {
710 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
711 if target_path.contains(".events.") || target_path.starts_with("events.") {
713 if let Some(source) = mapping.get("source") {
715 if let Some(event_data) = self.extract_event_data(source) {
716 if let Some(handler_source) = handler.get("source") {
718 if let Some(instruction_name) =
719 self.extract_instruction_name(handler_source)
720 {
721 let event_field_name =
723 target_path.split('.').next_back().unwrap_or("");
724 let interface_name = format!(
725 "{}Event",
726 to_pascal_case(event_field_name)
727 );
728
729 if generated_types.insert(interface_name.clone()) {
731 if let Some(interface) = self
732 .generate_event_interface_from_idl(
733 &interface_name,
734 &instruction_name,
735 &event_data,
736 )
737 {
738 interfaces.push(interface);
739 }
740 }
741 }
742 }
743 }
744 }
745 }
746 }
747 }
748 }
749 }
750
751 interfaces
752 }
753
754 fn extract_event_data(
756 &self,
757 source: &serde_json::Value,
758 ) -> Option<Vec<(String, Option<String>)>> {
759 if let Some(as_event) = source.get("AsEvent") {
760 if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
761 let mut event_fields = Vec::new();
762 for field in fields {
763 if let Some(from_source) = field.get("FromSource") {
764 if let Some(path) = from_source
765 .get("path")
766 .and_then(|p| p.get("segments"))
767 .and_then(|s| s.as_array())
768 {
769 if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
771 let transform = from_source
772 .get("transform")
773 .and_then(|t| t.as_str())
774 .map(|s| s.to_string());
775 event_fields.push((field_name.to_string(), transform));
776 }
777 }
778 }
779 }
780 return Some(event_fields);
781 }
782 }
783 None
784 }
785
786 fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
788 if let Some(source_obj) = source.get("Source") {
789 if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
790 if let Some(instruction_part) = type_name.strip_suffix("IxState") {
792 return Some(to_snake_case(instruction_part));
793 }
794 }
795 }
796 None
797 }
798
799 fn generate_event_interface_from_idl(
801 &self,
802 interface_name: &str,
803 instruction_name: &str,
804 captured_fields: &[(String, Option<String>)],
805 ) -> Option<String> {
806 if captured_fields.is_empty() {
808 return Some(format!("export interface {} {{}}", interface_name));
809 }
810
811 let idl_value = self.idl.as_ref()?;
812 let instructions = idl_value.get("instructions")?.as_array()?;
813
814 for instruction in instructions {
816 if let Some(name) = instruction.get("name").and_then(|n| n.as_str()) {
817 if name == instruction_name {
818 if let Some(args) = instruction.get("args").and_then(|a| a.as_array()) {
820 let mut fields = Vec::new();
821
822 for (field_name, transform) in captured_fields {
824 for arg in args {
826 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
827 if arg_name == field_name {
828 if let Some(arg_type) = arg.get("type") {
829 let ts_type = self.idl_type_to_typescript(
830 arg_type,
831 transform.as_deref(),
832 );
833 let camel_name = to_camel_case(field_name);
834 fields.push(format!(" {}: {};", camel_name, ts_type));
835 }
836 break;
837 }
838 }
839 }
840 }
841
842 if !fields.is_empty() {
843 return Some(format!(
844 "export interface {} {{\n{}\n}}",
845 interface_name,
846 fields.join("\n")
847 ));
848 }
849 }
850 }
851 }
852 }
853
854 None
855 }
856
857 fn idl_type_to_typescript(
859 &self,
860 idl_type: &serde_json::Value,
861 transform: Option<&str>,
862 ) -> String {
863 #![allow(clippy::only_used_in_recursion)]
864 if transform == Some("HexEncode") {
866 return "string".to_string();
867 }
868
869 if let Some(type_str) = idl_type.as_str() {
871 return match type_str {
872 "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" => {
873 "number".to_string()
874 }
875 "f32" | "f64" => "number".to_string(),
876 "bool" => "boolean".to_string(),
877 "string" => "string".to_string(),
878 "pubkey" | "publicKey" => "string".to_string(),
879 "bytes" => "string".to_string(),
880 _ => "any".to_string(),
881 };
882 }
883
884 if let Some(type_obj) = idl_type.as_object() {
886 if let Some(option_type) = type_obj.get("option") {
887 let inner = self.idl_type_to_typescript(option_type, None);
888 return format!("{} | null", inner);
889 }
890 if let Some(vec_type) = type_obj.get("vec") {
891 let inner = self.idl_type_to_typescript(vec_type, None);
892 return format!("{}[]", inner);
893 }
894 }
895
896 "any".to_string()
897 }
898
899 fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
901 let interface_name = to_pascal_case(&resolved.type_name);
902
903 if resolved.is_enum {
905 let variants: Vec<String> = resolved
906 .enum_variants
907 .iter()
908 .map(|v| format!("\"{}\"", to_pascal_case(v)))
909 .collect();
910
911 return format!("export type {} = {};", interface_name, variants.join(" | "));
912 }
913
914 let fields: Vec<String> = resolved
916 .fields
917 .iter()
918 .map(|field| {
919 let field_name = to_camel_case(&field.field_name);
920 let optional_marker = if field.is_optional { "?" } else { "" };
921 let ts_type = self.resolved_field_to_typescript(field);
922 format!(" {}{}: {};", field_name, optional_marker, ts_type)
923 })
924 .collect();
925
926 format!(
927 "export interface {} {{\n{}\n}}",
928 interface_name,
929 fields.join("\n")
930 )
931 }
932
933 fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
935 let base_ts = self.base_type_to_typescript(&field.base_type, false);
936
937 if field.is_array {
938 format!("{}[]", base_ts)
939 } else {
940 base_ts
941 }
942 }
943
944 fn has_event_types(&self) -> bool {
946 for section in &self.spec.sections {
947 for field_info in §ion.fields {
948 if let Some(resolved) = &field_info.resolved_type {
949 if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
950 return true;
951 }
952 }
953 }
954 }
955 false
956 }
957
958 fn generate_event_wrapper_interface(&self) -> String {
960 r#"/**
961 * Wrapper for event data that includes context metadata.
962 * Events are automatically wrapped in this structure at runtime.
963 */
964export interface EventWrapper<T> {
965 /** Unix timestamp when the event was processed */
966 timestamp: number;
967 /** The event-specific data */
968 data: T;
969 /** Optional blockchain slot number */
970 slot?: number;
971 /** Optional transaction signature */
972 signature?: string;
973}"#
974 .to_string()
975 }
976
977 fn infer_type_from_field_name(&self, field_name: &str) -> String {
978 let lower_name = field_name.to_lowercase();
979
980 if lower_name.contains("events.") {
982 return "any".to_string();
984 }
985
986 if lower_name.contains("id")
988 || lower_name.contains("count")
989 || lower_name.contains("number")
990 || lower_name.contains("timestamp")
991 || lower_name.contains("time")
992 || lower_name.contains("at")
993 || lower_name.contains("volume")
994 || lower_name.contains("amount")
995 || lower_name.contains("ev")
996 || lower_name.contains("fee")
997 || lower_name.contains("payout")
998 || lower_name.contains("distributed")
999 || lower_name.contains("claimable")
1000 || lower_name.contains("total")
1001 || lower_name.contains("rate")
1002 || lower_name.contains("ratio")
1003 || lower_name.contains("current")
1004 || lower_name.contains("state")
1005 {
1006 "number".to_string()
1007 } else if lower_name.contains("status")
1008 || lower_name.contains("hash")
1009 || lower_name.contains("address")
1010 || lower_name.contains("key")
1011 {
1012 "string".to_string()
1013 } else {
1014 "any".to_string()
1015 }
1016 }
1017
1018 fn is_field_optional(&self, mapping: &TypedFieldMapping<S>) -> bool {
1019 match &mapping.source {
1021 MappingSource::Constant(_) => false,
1023 MappingSource::AsEvent { .. } => true,
1025 MappingSource::FromSource { .. } => true,
1027 _ => true,
1029 }
1030 }
1031
1032 fn base_type_to_typescript(&self, base_type: &BaseType, is_array: bool) -> String {
1034 let base_ts_type = match base_type {
1035 BaseType::Integer => "number",
1036 BaseType::Float => "number",
1037 BaseType::String => "string",
1038 BaseType::Boolean => "boolean",
1039 BaseType::Timestamp => "number", BaseType::Binary => "string", BaseType::Pubkey => "string", BaseType::Array => "any[]", BaseType::Object => "Record<string, any>", BaseType::Any => "any",
1045 };
1046
1047 if is_array && !matches!(base_type, BaseType::Array) {
1048 format!("{}[]", base_ts_type)
1049 } else {
1050 base_ts_type.to_string()
1051 }
1052 }
1053}
1054
1055#[derive(Debug, Clone)]
1057struct TypeScriptField {
1058 name: String,
1059 ts_type: String,
1060 optional: bool,
1061 #[allow(dead_code)]
1062 description: Option<String>,
1063}
1064
1065fn value_to_typescript_type(value: &serde_json::Value) -> String {
1067 match value {
1068 serde_json::Value::Number(_) => "number".to_string(),
1069 serde_json::Value::String(_) => "string".to_string(),
1070 serde_json::Value::Bool(_) => "boolean".to_string(),
1071 serde_json::Value::Array(_) => "any[]".to_string(),
1072 serde_json::Value::Object(_) => "Record<string, any>".to_string(),
1073 serde_json::Value::Null => "null".to_string(),
1074 }
1075}
1076
1077fn to_pascal_case(s: &str) -> String {
1079 s.split(['_', '-', '.'])
1080 .map(|word| {
1081 let mut chars = word.chars();
1082 match chars.next() {
1083 None => String::new(),
1084 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1085 }
1086 })
1087 .collect()
1088}
1089
1090fn to_camel_case(s: &str) -> String {
1092 let pascal = to_pascal_case(s);
1093 let mut chars = pascal.chars();
1094 match chars.next() {
1095 None => String::new(),
1096 Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
1097 }
1098}
1099
1100fn to_snake_case(s: &str) -> String {
1102 let mut result = String::new();
1103
1104 for ch in s.chars() {
1105 if ch.is_uppercase() {
1106 if !result.is_empty() {
1107 result.push('_');
1108 }
1109 result.push(ch.to_lowercase().next().unwrap());
1110 } else {
1111 result.push(ch);
1112 }
1113 }
1114
1115 result
1116}
1117
1118fn is_root_section(name: &str) -> bool {
1120 name.eq_ignore_ascii_case("root")
1121}
1122
1123fn to_kebab_case(s: &str) -> String {
1125 let mut result = String::new();
1126
1127 for ch in s.chars() {
1128 if ch.is_uppercase() && !result.is_empty() {
1129 result.push('-');
1130 }
1131 result.push(ch.to_lowercase().next().unwrap());
1132 }
1133
1134 result
1135}
1136
1137pub fn generate_typescript_from_spec_fn<F, S>(
1140 spec_fn: F,
1141 entity_name: String,
1142 config: Option<TypeScriptConfig>,
1143) -> Result<TypeScriptOutput, String>
1144where
1145 F: Fn() -> TypedStreamSpec<S>,
1146{
1147 let spec = spec_fn();
1148 let compiler =
1149 TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
1150
1151 Ok(compiler.compile())
1152}
1153
1154pub fn write_typescript_to_file(
1156 output: &TypeScriptOutput,
1157 path: &std::path::Path,
1158) -> Result<(), std::io::Error> {
1159 std::fs::write(path, output.full_file())
1160}
1161
1162pub fn compile_serializable_spec(
1165 spec: SerializableStreamSpec,
1166 entity_name: String,
1167 config: Option<TypeScriptConfig>,
1168) -> Result<TypeScriptOutput, String> {
1169 let idl = spec
1171 .idl
1172 .as_ref()
1173 .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
1174
1175 let handlers = serde_json::to_value(&spec.handlers).ok();
1177
1178 let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
1181
1182 let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
1183 .with_idl(idl)
1184 .with_handlers_json(handlers)
1185 .with_config(config.unwrap_or_default());
1186
1187 Ok(compiler.compile())
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192 use super::*;
1193
1194 #[test]
1195 fn test_case_conversions() {
1196 assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
1197 assert_eq!(to_camel_case("settlement_game"), "settlementGame");
1198 assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
1199 }
1200
1201 #[test]
1202 fn test_value_to_typescript_type() {
1203 assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
1204 assert_eq!(
1205 value_to_typescript_type(&serde_json::json!("hello")),
1206 "string"
1207 );
1208 assert_eq!(
1209 value_to_typescript_type(&serde_json::json!(true)),
1210 "boolean"
1211 );
1212 assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
1213 }
1214}