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>, views: Vec<ViewDef>, }
65
66impl<S> TypeScriptCompiler<S> {
67 pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
68 Self {
69 spec,
70 entity_name,
71 config: TypeScriptConfig::default(),
72 idl: None,
73 handlers_json: None,
74 views: Vec::new(),
75 }
76 }
77
78 pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
79 self.config = config;
80 self
81 }
82
83 pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
84 self.idl = idl;
85 self
86 }
87
88 pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
89 self.handlers_json = handlers;
90 self
91 }
92
93 pub fn with_views(mut self, views: Vec<ViewDef>) -> Self {
94 self.views = views;
95 self
96 }
97
98 pub fn compile(&self) -> TypeScriptOutput {
99 let imports = self.generate_imports();
100 let interfaces = self.generate_interfaces();
101 let stack_definition = self.generate_stack_definition();
102
103 TypeScriptOutput {
104 imports,
105 interfaces,
106 stack_definition,
107 }
108 }
109
110 fn generate_imports(&self) -> String {
111 String::new()
113 }
114
115 fn generate_view_helpers(&self) -> String {
116 r#"// ============================================================================
117// View Definition Types (framework-agnostic)
118// ============================================================================
119
120/** View definition with embedded entity type */
121export interface ViewDef<T, TMode extends 'state' | 'list'> {
122 readonly mode: TMode;
123 readonly view: string;
124 /** Phantom field for type inference - not present at runtime */
125 readonly _entity?: T;
126}
127
128/** Helper to create typed state view definitions (keyed lookups) */
129function stateView<T>(view: string): ViewDef<T, 'state'> {
130 return { mode: 'state', view } as const;
131}
132
133/** Helper to create typed list view definitions (collections) */
134function listView<T>(view: string): ViewDef<T, 'list'> {
135 return { mode: 'list', view } as const;
136}"#
137 .to_string()
138 }
139
140 fn generate_interfaces(&self) -> String {
141 let mut interfaces = Vec::new();
142 let mut processed_types = HashSet::new();
143 let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
144
145 for handler in &self.spec.handlers {
147 let interface_sections = self.extract_interface_sections_from_handler(handler);
148
149 for (section_name, mut fields) in interface_sections {
150 all_sections
151 .entry(section_name)
152 .or_default()
153 .append(&mut fields);
154 }
155 }
156
157 self.add_unmapped_fields(&mut all_sections);
160
161 for (section_name, fields) in all_sections {
164 if !is_root_section(§ion_name) && processed_types.insert(section_name.clone()) {
165 let deduplicated_fields = self.deduplicate_fields(fields);
166 let interface =
167 self.generate_interface_from_fields(§ion_name, &deduplicated_fields);
168 interfaces.push(interface);
169 }
170 }
171
172 let main_interface = self.generate_main_entity_interface();
174 interfaces.push(main_interface);
175
176 let nested_interfaces = self.generate_nested_interfaces();
178 interfaces.extend(nested_interfaces);
179
180 if self.has_event_types() {
182 interfaces.push(self.generate_event_wrapper_interface());
183 }
184
185 interfaces.join("\n\n")
186 }
187
188 fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
189 let mut seen = HashSet::new();
190 let mut unique_fields = Vec::new();
191
192 fields.sort_by(|a, b| a.name.cmp(&b.name));
194
195 for field in fields {
196 if seen.insert(field.name.clone()) {
197 unique_fields.push(field);
198 }
199 }
200
201 unique_fields
202 }
203
204 fn extract_interface_sections_from_handler(
205 &self,
206 handler: &TypedHandlerSpec<S>,
207 ) -> BTreeMap<String, Vec<TypeScriptField>> {
208 let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
209
210 for mapping in &handler.mappings {
211 let parts: Vec<&str> = mapping.target_path.split('.').collect();
212
213 if parts.len() > 1 {
214 let section_name = parts[0];
215 let field_name = parts[1];
216
217 let ts_field = TypeScriptField {
218 name: field_name.to_string(),
219 ts_type: self.mapping_to_typescript_type(mapping),
220 optional: self.is_field_optional(mapping),
221 description: None,
222 };
223
224 sections
225 .entry(section_name.to_string())
226 .or_default()
227 .push(ts_field);
228 } else {
229 let ts_field = TypeScriptField {
230 name: mapping.target_path.clone(),
231 ts_type: self.mapping_to_typescript_type(mapping),
232 optional: self.is_field_optional(mapping),
233 description: None,
234 };
235
236 sections
237 .entry("Root".to_string())
238 .or_default()
239 .push(ts_field);
240 }
241 }
242
243 sections
244 }
245
246 fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
247 if !self.spec.sections.is_empty() {
249 for section in &self.spec.sections {
251 let section_fields = sections.entry(section.name.clone()).or_default();
252
253 for field_info in §ion.fields {
254 let already_exists = section_fields
256 .iter()
257 .any(|f| f.name == field_info.field_name);
258
259 if !already_exists {
260 section_fields.push(TypeScriptField {
261 name: field_info.field_name.clone(),
262 ts_type: self.field_type_info_to_typescript(field_info),
263 optional: field_info.is_optional,
264 description: None,
265 });
266 }
267 }
268 }
269 } else {
270 for (field_path, field_type_info) in &self.spec.field_mappings {
272 let parts: Vec<&str> = field_path.split('.').collect();
273 if parts.len() > 1 {
274 let section_name = parts[0];
275 let field_name = parts[1];
276
277 let section_fields = sections.entry(section_name.to_string()).or_default();
278
279 let already_exists = section_fields.iter().any(|f| f.name == field_name);
280
281 if !already_exists {
282 section_fields.push(TypeScriptField {
283 name: field_name.to_string(),
284 ts_type: self.base_type_to_typescript(
285 &field_type_info.base_type,
286 field_type_info.is_array,
287 ),
288 optional: field_type_info.is_optional,
289 description: None,
290 });
291 }
292 }
293 }
294 }
295 }
296
297 fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
298 let interface_name = if name == "Root" {
300 format!(
301 "{}{}",
302 self.config.interface_prefix,
303 to_pascal_case(&self.entity_name)
304 )
305 } else {
306 let base_name = if self.entity_name.contains("Game") {
309 "Game"
310 } else {
311 &self.entity_name
312 };
313 format!(
314 "{}{}{}",
315 self.config.interface_prefix,
316 base_name,
317 to_pascal_case(name)
318 )
319 };
320
321 let field_definitions: Vec<String> = fields
324 .iter()
325 .map(|field| {
326 let ts_type = if field.optional {
327 format!("{} | null", field.ts_type)
329 } else {
330 field.ts_type.clone()
331 };
332 format!(" {}?: {};", field.name, ts_type)
333 })
334 .collect();
335
336 format!(
337 "export interface {} {{\n{}\n}}",
338 interface_name,
339 field_definitions.join("\n")
340 )
341 }
342
343 fn generate_main_entity_interface(&self) -> String {
344 let entity_name = to_pascal_case(&self.entity_name);
345
346 let mut sections = BTreeMap::new();
348
349 for handler in &self.spec.handlers {
350 for mapping in &handler.mappings {
351 let parts: Vec<&str> = mapping.target_path.split('.').collect();
352 if parts.len() > 1 {
353 sections.insert(parts[0], true);
354 }
355 }
356 }
357
358 if !self.spec.sections.is_empty() {
359 for section in &self.spec.sections {
360 sections.insert(§ion.name, true);
361 }
362 } else {
363 for mapping in &self.spec.handlers {
364 for field_mapping in &mapping.mappings {
365 let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
366 if parts.len() > 1 {
367 sections.insert(parts[0], true);
368 }
369 }
370 }
371 }
372
373 let mut fields = Vec::new();
374
375 for section in sections.keys() {
378 if !is_root_section(section) {
379 let base_name = if self.entity_name.contains("Game") {
380 "Game"
381 } else {
382 &self.entity_name
383 };
384 let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
385 fields.push(format!(" {}?: {};", section, section_interface_name));
387 }
388 }
389
390 for section in &self.spec.sections {
393 if is_root_section(§ion.name) {
394 for field in §ion.fields {
395 let base_ts_type = self.field_type_info_to_typescript(field);
396 let ts_type = if field.is_optional {
397 format!("{} | null", base_ts_type)
398 } else {
399 base_ts_type
400 };
401 fields.push(format!(" {}?: {};", field.field_name, ts_type));
402 }
403 }
404 }
405
406 if fields.is_empty() {
407 fields.push(" // Generated interface - extend as needed".to_string());
408 }
409
410 format!(
411 "export interface {} {{\n{}\n}}",
412 entity_name,
413 fields.join("\n")
414 )
415 }
416
417 fn generate_stack_definition(&self) -> String {
418 let stack_name = to_kebab_case(&self.entity_name);
419 let entity_pascal = to_pascal_case(&self.entity_name);
420 let export_name = format!(
421 "{}_{}",
422 self.entity_name.to_uppercase(),
423 self.config.export_const_name
424 );
425
426 let view_helpers = self.generate_view_helpers();
427 let derived_views = self.generate_derived_view_entries();
428
429 format!(
430 r#"{}
431
432// ============================================================================
433// Stack Definition
434// ============================================================================
435
436/** Stack definition for {} */
437export const {} = {{
438 name: '{}',
439 views: {{
440 {}: {{
441 state: stateView<{}>('{}/state'),
442 list: listView<{}>('{}/list'),{}
443 }},
444 }},
445}} as const;
446
447/** Type alias for the stack */
448export type {}Stack = typeof {};
449
450/** Default export for convenience */
451export default {};"#,
452 view_helpers,
453 entity_pascal,
454 export_name,
455 stack_name,
456 self.entity_name,
457 entity_pascal,
458 self.entity_name,
459 entity_pascal,
460 self.entity_name,
461 derived_views,
462 entity_pascal,
463 export_name,
464 export_name
465 )
466 }
467
468 fn generate_derived_view_entries(&self) -> String {
469 let derived_views: Vec<&ViewDef> = self
470 .views
471 .iter()
472 .filter(|v| {
473 !v.id.ends_with("/state")
474 && !v.id.ends_with("/list")
475 && v.id.starts_with(&self.entity_name)
476 })
477 .collect();
478
479 if derived_views.is_empty() {
480 return String::new();
481 }
482
483 let entity_pascal = to_pascal_case(&self.entity_name);
484 let mut entries = Vec::new();
485
486 for view in derived_views {
487 let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
488
489 entries.push(format!(
490 "\n {}: listView<{}>('{}'),",
491 view_name, entity_pascal, view.id
492 ));
493 }
494
495 entries.join("")
496 }
497
498 fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
499 if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
501 let ts_type = self.field_type_info_to_typescript(field_info);
502
503 if matches!(mapping.population, PopulationStrategy::Append) {
505 return if ts_type.ends_with("[]") {
506 ts_type
507 } else {
508 format!("{}[]", ts_type)
509 };
510 }
511
512 return ts_type;
513 }
514
515 match &mapping.population {
517 PopulationStrategy::Append => {
518 match &mapping.source {
520 MappingSource::AsEvent { .. } => "any[]".to_string(),
521 _ => "any[]".to_string(),
522 }
523 }
524 _ => {
525 let base_type = match &mapping.source {
527 MappingSource::FromSource { .. } => {
528 self.infer_type_from_field_name(&mapping.target_path)
529 }
530 MappingSource::Constant(value) => value_to_typescript_type(value),
531 MappingSource::AsEvent { .. } => "any".to_string(),
532 _ => "any".to_string(),
533 };
534
535 if let Some(transform) = &mapping.transform {
537 match transform {
538 Transformation::HexEncode | Transformation::HexDecode => {
539 "string".to_string()
540 }
541 Transformation::Base58Encode | Transformation::Base58Decode => {
542 "string".to_string()
543 }
544 Transformation::ToString => "string".to_string(),
545 Transformation::ToNumber => "number".to_string(),
546 }
547 } else {
548 base_type
549 }
550 }
551 }
552 }
553
554 fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
556 if let Some(resolved) = &field_info.resolved_type {
558 let interface_name = self.resolved_type_to_interface_name(resolved);
559
560 let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
562 {
563 format!("EventWrapper<{}>", interface_name)
564 } else {
565 interface_name
566 };
567
568 let with_array = if field_info.is_array {
570 format!("{}[]", base_type)
571 } else {
572 base_type
573 };
574
575 return with_array;
576 }
577
578 if field_info.base_type == BaseType::Any
581 || (field_info.base_type == BaseType::Array
582 && field_info.inner_type.as_deref() == Some("Value"))
583 {
584 if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
585 return if field_info.is_array {
586 format!("{}[]", event_type)
587 } else if field_info.is_optional {
588 format!("{} | null", event_type)
589 } else {
590 event_type
591 };
592 }
593 }
594
595 self.base_type_to_typescript(&field_info.base_type, field_info.is_array)
597 }
598
599 fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
601 let handlers = self.handlers_json.as_ref()?.as_array()?;
603
604 for handler in handlers {
606 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
607 for mapping in mappings {
608 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
609 let target_parts: Vec<&str> = target_path.split('.').collect();
611 if let Some(target_field) = target_parts.last() {
612 if *target_field == field_name {
613 if let Some(source) = mapping.get("source") {
615 if self.extract_event_data(source).is_some() {
616 return Some(format!(
618 "{}Event",
619 to_pascal_case(field_name)
620 ));
621 }
622 }
623 }
624 }
625 }
626 }
627 }
628 }
629 None
630 }
631
632 fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
634 to_pascal_case(&resolved.type_name)
635 }
636
637 fn generate_nested_interfaces(&self) -> Vec<String> {
639 let mut interfaces = Vec::new();
640 let mut generated_types = HashSet::new();
641
642 for section in &self.spec.sections {
644 for field_info in §ion.fields {
645 if let Some(resolved) = &field_info.resolved_type {
646 let type_name = resolved.type_name.clone();
647
648 if generated_types.insert(type_name) {
650 let interface = self.generate_interface_for_resolved_type(resolved);
651 interfaces.push(interface);
652 }
653 }
654 }
655 }
656
657 interfaces.extend(self.generate_event_interfaces(&mut generated_types));
659
660 if let Some(idl_value) = &self.idl {
662 if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
663 for type_def in types_array {
664 if let (Some(type_name), Some(type_obj)) = (
665 type_def.get("name").and_then(|v| v.as_str()),
666 type_def.get("type").and_then(|v| v.as_object()),
667 ) {
668 if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
669 if generated_types.insert(type_name.to_string()) {
671 if let Some(variants) =
672 type_obj.get("variants").and_then(|v| v.as_array())
673 {
674 let variant_names: Vec<String> = variants
675 .iter()
676 .filter_map(|v| {
677 v.get("name")
678 .and_then(|n| n.as_str())
679 .map(|s| s.to_string())
680 })
681 .collect();
682
683 if !variant_names.is_empty() {
684 let interface_name = to_pascal_case(type_name);
685 let variant_strings: Vec<String> = variant_names
686 .iter()
687 .map(|v| format!("\"{}\"", to_pascal_case(v)))
688 .collect();
689
690 let enum_type = format!(
691 "export type {} = {};",
692 interface_name,
693 variant_strings.join(" | ")
694 );
695 interfaces.push(enum_type);
696 }
697 }
698 }
699 }
700 }
701 }
702 }
703 }
704
705 interfaces
706 }
707
708 fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
710 let mut interfaces = Vec::new();
711
712 let handlers = match &self.handlers_json {
714 Some(h) => h.as_array(),
715 None => return interfaces,
716 };
717
718 let handlers_array = match handlers {
719 Some(arr) => arr,
720 None => return interfaces,
721 };
722
723 for handler in handlers_array {
725 if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
727 for mapping in mappings {
728 if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
729 if target_path.contains(".events.") || target_path.starts_with("events.") {
731 if let Some(source) = mapping.get("source") {
733 if let Some(event_data) = self.extract_event_data(source) {
734 if let Some(handler_source) = handler.get("source") {
736 if let Some(instruction_name) =
737 self.extract_instruction_name(handler_source)
738 {
739 let event_field_name =
741 target_path.split('.').next_back().unwrap_or("");
742 let interface_name = format!(
743 "{}Event",
744 to_pascal_case(event_field_name)
745 );
746
747 if generated_types.insert(interface_name.clone()) {
749 if let Some(interface) = self
750 .generate_event_interface_from_idl(
751 &interface_name,
752 &instruction_name,
753 &event_data,
754 )
755 {
756 interfaces.push(interface);
757 }
758 }
759 }
760 }
761 }
762 }
763 }
764 }
765 }
766 }
767 }
768
769 interfaces
770 }
771
772 fn extract_event_data(
774 &self,
775 source: &serde_json::Value,
776 ) -> Option<Vec<(String, Option<String>)>> {
777 if let Some(as_event) = source.get("AsEvent") {
778 if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
779 let mut event_fields = Vec::new();
780 for field in fields {
781 if let Some(from_source) = field.get("FromSource") {
782 if let Some(path) = from_source
783 .get("path")
784 .and_then(|p| p.get("segments"))
785 .and_then(|s| s.as_array())
786 {
787 if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
789 let transform = from_source
790 .get("transform")
791 .and_then(|t| t.as_str())
792 .map(|s| s.to_string());
793 event_fields.push((field_name.to_string(), transform));
794 }
795 }
796 }
797 }
798 return Some(event_fields);
799 }
800 }
801 None
802 }
803
804 fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
806 if let Some(source_obj) = source.get("Source") {
807 if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
808 if let Some(instruction_part) = type_name.strip_suffix("IxState") {
810 return Some(instruction_part.to_string());
811 }
812 }
813 }
814 None
815 }
816
817 fn find_instruction_in_idl<'a>(
821 &self,
822 instructions: &'a [serde_json::Value],
823 rust_name: &str,
824 ) -> Option<&'a serde_json::Value> {
825 let normalized_search = normalize_for_comparison(rust_name);
826
827 for instruction in instructions {
828 if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
829 if normalize_for_comparison(idl_name) == normalized_search {
830 return Some(instruction);
831 }
832 }
833 }
834 None
835 }
836
837 fn generate_event_interface_from_idl(
839 &self,
840 interface_name: &str,
841 rust_instruction_name: &str,
842 captured_fields: &[(String, Option<String>)],
843 ) -> Option<String> {
844 if captured_fields.is_empty() {
845 return Some(format!("export interface {} {{}}", interface_name));
846 }
847
848 let idl_value = self.idl.as_ref()?;
849 let instructions = idl_value.get("instructions")?.as_array()?;
850
851 let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
852 let args = instruction.get("args")?.as_array()?;
853
854 let mut fields = Vec::new();
855 for (field_name, transform) in captured_fields {
856 for arg in args {
857 if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
858 if arg_name == field_name {
859 if let Some(arg_type) = arg.get("type") {
860 let ts_type =
861 self.idl_type_to_typescript(arg_type, transform.as_deref());
862 fields.push(format!(" {}: {};", field_name, ts_type));
863 }
864 break;
865 }
866 }
867 }
868 }
869
870 if !fields.is_empty() {
871 return Some(format!(
872 "export interface {} {{\n{}\n}}",
873 interface_name,
874 fields.join("\n")
875 ));
876 }
877
878 None
879 }
880
881 fn idl_type_to_typescript(
883 &self,
884 idl_type: &serde_json::Value,
885 transform: Option<&str>,
886 ) -> String {
887 #![allow(clippy::only_used_in_recursion)]
888 if transform == Some("HexEncode") {
890 return "string".to_string();
891 }
892
893 if let Some(type_str) = idl_type.as_str() {
895 return match type_str {
896 "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" => {
897 "number".to_string()
898 }
899 "f32" | "f64" => "number".to_string(),
900 "bool" => "boolean".to_string(),
901 "string" => "string".to_string(),
902 "pubkey" | "publicKey" => "string".to_string(),
903 "bytes" => "string".to_string(),
904 _ => "any".to_string(),
905 };
906 }
907
908 if let Some(type_obj) = idl_type.as_object() {
910 if let Some(option_type) = type_obj.get("option") {
911 let inner = self.idl_type_to_typescript(option_type, None);
912 return format!("{} | null", inner);
913 }
914 if let Some(vec_type) = type_obj.get("vec") {
915 let inner = self.idl_type_to_typescript(vec_type, None);
916 return format!("{}[]", inner);
917 }
918 }
919
920 "any".to_string()
921 }
922
923 fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
925 let interface_name = to_pascal_case(&resolved.type_name);
926
927 if resolved.is_enum {
929 let variants: Vec<String> = resolved
930 .enum_variants
931 .iter()
932 .map(|v| format!("\"{}\"", to_pascal_case(v)))
933 .collect();
934
935 return format!("export type {} = {};", interface_name, variants.join(" | "));
936 }
937
938 let fields: Vec<String> = resolved
941 .fields
942 .iter()
943 .map(|field| {
944 let base_ts_type = self.resolved_field_to_typescript(field);
945 let ts_type = if field.is_optional {
946 format!("{} | null", base_ts_type)
947 } else {
948 base_ts_type
949 };
950 format!(" {}?: {};", field.field_name, ts_type)
951 })
952 .collect();
953
954 format!(
955 "export interface {} {{\n{}\n}}",
956 interface_name,
957 fields.join("\n")
958 )
959 }
960
961 fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
963 let base_ts = self.base_type_to_typescript(&field.base_type, false);
964
965 if field.is_array {
966 format!("{}[]", base_ts)
967 } else {
968 base_ts
969 }
970 }
971
972 fn has_event_types(&self) -> bool {
974 for section in &self.spec.sections {
975 for field_info in §ion.fields {
976 if let Some(resolved) = &field_info.resolved_type {
977 if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
978 return true;
979 }
980 }
981 }
982 }
983 false
984 }
985
986 fn generate_event_wrapper_interface(&self) -> String {
988 r#"/**
989 * Wrapper for event data that includes context metadata.
990 * Events are automatically wrapped in this structure at runtime.
991 */
992export interface EventWrapper<T> {
993 /** Unix timestamp when the event was processed */
994 timestamp: number;
995 /** The event-specific data */
996 data: T;
997 /** Optional blockchain slot number */
998 slot?: number;
999 /** Optional transaction signature */
1000 signature?: string;
1001}"#
1002 .to_string()
1003 }
1004
1005 fn infer_type_from_field_name(&self, field_name: &str) -> String {
1006 let lower_name = field_name.to_lowercase();
1007
1008 if lower_name.contains("events.") {
1010 return "any".to_string();
1012 }
1013
1014 if lower_name.contains("id")
1016 || lower_name.contains("count")
1017 || lower_name.contains("number")
1018 || lower_name.contains("timestamp")
1019 || lower_name.contains("time")
1020 || lower_name.contains("at")
1021 || lower_name.contains("volume")
1022 || lower_name.contains("amount")
1023 || lower_name.contains("ev")
1024 || lower_name.contains("fee")
1025 || lower_name.contains("payout")
1026 || lower_name.contains("distributed")
1027 || lower_name.contains("claimable")
1028 || lower_name.contains("total")
1029 || lower_name.contains("rate")
1030 || lower_name.contains("ratio")
1031 || lower_name.contains("current")
1032 || lower_name.contains("state")
1033 {
1034 "number".to_string()
1035 } else if lower_name.contains("status")
1036 || lower_name.contains("hash")
1037 || lower_name.contains("address")
1038 || lower_name.contains("key")
1039 {
1040 "string".to_string()
1041 } else {
1042 "any".to_string()
1043 }
1044 }
1045
1046 fn is_field_optional(&self, mapping: &TypedFieldMapping<S>) -> bool {
1047 match &mapping.source {
1049 MappingSource::Constant(_) => false,
1051 MappingSource::AsEvent { .. } => true,
1053 MappingSource::FromSource { .. } => true,
1055 _ => true,
1057 }
1058 }
1059
1060 fn base_type_to_typescript(&self, base_type: &BaseType, is_array: bool) -> String {
1062 let base_ts_type = match base_type {
1063 BaseType::Integer => "number",
1064 BaseType::Float => "number",
1065 BaseType::String => "string",
1066 BaseType::Boolean => "boolean",
1067 BaseType::Timestamp => "number", BaseType::Binary => "string", BaseType::Pubkey => "string", BaseType::Array => "any[]", BaseType::Object => "Record<string, any>", BaseType::Any => "any",
1073 };
1074
1075 if is_array && !matches!(base_type, BaseType::Array) {
1076 format!("{}[]", base_ts_type)
1077 } else {
1078 base_ts_type.to_string()
1079 }
1080 }
1081}
1082
1083#[derive(Debug, Clone)]
1085struct TypeScriptField {
1086 name: String,
1087 ts_type: String,
1088 optional: bool,
1089 #[allow(dead_code)]
1090 description: Option<String>,
1091}
1092
1093fn value_to_typescript_type(value: &serde_json::Value) -> String {
1095 match value {
1096 serde_json::Value::Number(_) => "number".to_string(),
1097 serde_json::Value::String(_) => "string".to_string(),
1098 serde_json::Value::Bool(_) => "boolean".to_string(),
1099 serde_json::Value::Array(_) => "any[]".to_string(),
1100 serde_json::Value::Object(_) => "Record<string, any>".to_string(),
1101 serde_json::Value::Null => "null".to_string(),
1102 }
1103}
1104
1105fn to_pascal_case(s: &str) -> String {
1107 s.split(['_', '-', '.'])
1108 .map(|word| {
1109 let mut chars = word.chars();
1110 match chars.next() {
1111 None => String::new(),
1112 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1113 }
1114 })
1115 .collect()
1116}
1117
1118fn normalize_for_comparison(s: &str) -> String {
1121 s.chars()
1122 .filter(|c| *c != '_')
1123 .flat_map(|c| c.to_lowercase())
1124 .collect()
1125}
1126
1127fn is_root_section(name: &str) -> bool {
1129 name.eq_ignore_ascii_case("root")
1130}
1131
1132fn to_kebab_case(s: &str) -> String {
1134 let mut result = String::new();
1135
1136 for ch in s.chars() {
1137 if ch.is_uppercase() && !result.is_empty() {
1138 result.push('-');
1139 }
1140 result.push(ch.to_lowercase().next().unwrap());
1141 }
1142
1143 result
1144}
1145
1146pub fn generate_typescript_from_spec_fn<F, S>(
1149 spec_fn: F,
1150 entity_name: String,
1151 config: Option<TypeScriptConfig>,
1152) -> Result<TypeScriptOutput, String>
1153where
1154 F: Fn() -> TypedStreamSpec<S>,
1155{
1156 let spec = spec_fn();
1157 let compiler =
1158 TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
1159
1160 Ok(compiler.compile())
1161}
1162
1163pub fn write_typescript_to_file(
1165 output: &TypeScriptOutput,
1166 path: &std::path::Path,
1167) -> Result<(), std::io::Error> {
1168 std::fs::write(path, output.full_file())
1169}
1170
1171pub fn compile_serializable_spec(
1174 spec: SerializableStreamSpec,
1175 entity_name: String,
1176 config: Option<TypeScriptConfig>,
1177) -> Result<TypeScriptOutput, String> {
1178 let idl = spec
1179 .idl
1180 .as_ref()
1181 .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
1182
1183 let handlers = serde_json::to_value(&spec.handlers).ok();
1184 let views = spec.views.clone();
1185
1186 let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
1187
1188 let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
1189 .with_idl(idl)
1190 .with_handlers_json(handlers)
1191 .with_views(views)
1192 .with_config(config.unwrap_or_default());
1193
1194 Ok(compiler.compile())
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199 use super::*;
1200
1201 #[test]
1202 fn test_case_conversions() {
1203 assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
1204 assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
1205 }
1206
1207 #[test]
1208 fn test_normalize_for_comparison() {
1209 assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
1210 assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
1211 assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
1212 assert_eq!(
1213 normalize_for_comparison("admin_set_creator"),
1214 "adminsetcreator"
1215 );
1216 assert_eq!(
1217 normalize_for_comparison("AdminSetCreator"),
1218 "adminsetcreator"
1219 );
1220 }
1221
1222 #[test]
1223 fn test_value_to_typescript_type() {
1224 assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
1225 assert_eq!(
1226 value_to_typescript_type(&serde_json::json!("hello")),
1227 "string"
1228 );
1229 assert_eq!(
1230 value_to_typescript_type(&serde_json::json!(true)),
1231 "boolean"
1232 );
1233 assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
1234 }
1235
1236 #[test]
1237 fn test_derived_view_codegen() {
1238 let spec = SerializableStreamSpec {
1239 state_name: "OreRound".to_string(),
1240 program_id: None,
1241 idl: None,
1242 identity: IdentitySpec {
1243 primary_keys: vec!["id".to_string()],
1244 lookup_indexes: vec![],
1245 },
1246 handlers: vec![],
1247 sections: vec![],
1248 field_mappings: BTreeMap::new(),
1249 resolver_hooks: vec![],
1250 instruction_hooks: vec![],
1251 computed_fields: vec![],
1252 computed_field_specs: vec![],
1253 content_hash: None,
1254 views: vec![
1255 ViewDef {
1256 id: "OreRound/latest".to_string(),
1257 source: ViewSource::Entity {
1258 name: "OreRound".to_string(),
1259 },
1260 pipeline: vec![ViewTransform::Last],
1261 output: ViewOutput::Single,
1262 },
1263 ViewDef {
1264 id: "OreRound/top10".to_string(),
1265 source: ViewSource::Entity {
1266 name: "OreRound".to_string(),
1267 },
1268 pipeline: vec![ViewTransform::Take { count: 10 }],
1269 output: ViewOutput::Collection,
1270 },
1271 ],
1272 };
1273
1274 let output =
1275 compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
1276
1277 let stack_def = &output.stack_definition;
1278
1279 assert!(
1280 stack_def.contains("listView<OreRound>('OreRound/latest')"),
1281 "Expected 'latest' derived view using listView, got:\n{}",
1282 stack_def
1283 );
1284 assert!(
1285 stack_def.contains("listView<OreRound>('OreRound/top10')"),
1286 "Expected 'top10' derived view using listView, got:\n{}",
1287 stack_def
1288 );
1289 assert!(
1290 stack_def.contains("latest:"),
1291 "Expected 'latest' key, got:\n{}",
1292 stack_def
1293 );
1294 assert!(
1295 stack_def.contains("top10:"),
1296 "Expected 'top10' key, got:\n{}",
1297 stack_def
1298 );
1299 assert!(
1300 stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
1301 "Expected listView helper function, got:\n{}",
1302 stack_def
1303 );
1304 }
1305}