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