1use crate::Emitter;
38use crate::ir;
39
40use super::case::to_pascal_case;
41
42#[derive(Debug, Default, Clone, Copy)]
44pub struct TypeScriptEmitter;
45
46impl TypeScriptEmitter {
47 pub fn new() -> Self {
49 Self
50 }
51}
52
53impl Emitter for TypeScriptEmitter {
54 fn emit(&self, schema: &ir::Schema) -> String {
55 let mut code = String::new();
56 code.push_str(HEADER);
57 code.push('\n');
58
59 for ty in &schema.types {
61 match ty {
62 ir::TypeDef::Struct {
63 name,
64 description,
65 fields,
66 } => {
67 code.push_str(&render_interface(name, description.as_deref(), fields));
68 }
69 ir::TypeDef::Enum {
70 name,
71 description,
72 variants,
73 } => {
74 code.push_str(&render_enum(name, description.as_deref(), variants));
75 }
76 }
77 code.push_str("\n\n");
78 }
79
80 for record in &schema.records {
83 code.push_str(&render_interface(
84 &to_pascal_case(&record.name),
85 record.description.as_deref(),
86 &record_members(record),
87 ));
88 code.push_str("\n\n");
89 }
90 for relation in &schema.relations {
91 code.push_str(&render_interface(
92 &to_pascal_case(&relation.name),
93 relation.description.as_deref(),
94 &relation_members(relation),
95 ));
96 code.push_str("\n\n");
97 }
98
99 if let Some(protocol) = &schema.protocol {
101 if let Some(namespace) = &protocol.namespace {
102 code.push_str(&format!("// Namespace: {namespace}\n"));
103 code.push_str(&format!("// Version: {}\n\n", protocol.version));
104 }
105 for channel in &protocol.channels {
106 code.push_str(&render_channel(channel));
107 code.push_str("\n\n");
108 }
109 }
110
111 code
112 }
113}
114
115const HEADER: &str = "\
117// Auto-generated TypeScript definitions
118// DO NOT EDIT MANUALLY
119
120export type Timestamp = string; // ISO-8601 format
121export type UUID = string;
122export type LanguageCode = string; // ISO 639-1 format
123";
124
125fn render_doc(description: Option<&str>, indent: &str) -> String {
129 match description {
130 None => String::new(),
131 Some(text) => {
132 let mut lines = text.lines();
133 match (lines.next(), text.contains('\n')) {
134 (Some(first), false) => format!("{indent}/** {first} */\n"),
135 (Some(first), true) => {
136 let mut out = format!("{indent}/**\n{indent} * {first}\n");
137 for line in lines {
138 out.push_str(&format!("{indent} * {line}\n"));
139 }
140 out.push_str(&format!("{indent} */\n"));
141 out
142 }
143 (None, _) => String::new(),
144 }
145 }
146 }
147}
148
149fn render_interface(name: &str, description: Option<&str>, fields: &[ir::Field]) -> String {
151 let doc = render_doc(description, "");
152 let body: Vec<String> = fields.iter().map(render_field).collect();
153 format!("{doc}export interface {name} {{\n{}\n}}", body.join("\n"))
154}
155
156fn render_enum(name: &str, description: Option<&str>, variants: &[String]) -> String {
158 let doc = render_doc(description, "");
159 let body: Vec<String> = variants
160 .iter()
161 .map(|v| format!(" {} = '{}',", to_pascal_case(v), v))
162 .collect();
163 format!("{doc}export enum {name} {{\n{}\n}}", body.join("\n"))
164}
165
166fn render_field(field: &ir::Field) -> String {
169 let optional = if field.required { "" } else { "?" };
170 let doc = render_doc(field.description.as_deref(), " ");
171 format!(
172 "{doc} {}{}: {};",
173 field.name,
174 optional,
175 ty_to_ts(&field.ty)
176 )
177}
178
179fn id_member() -> ir::Field {
181 ir::Field {
182 name: "id".to_string(),
183 ty: ir::Ty::Primitive(ir::Prim::String),
184 required: true,
185 flexible: false,
186 default: None,
187 description: None,
188 constraints: ir::Constraints::default(),
189 }
190}
191
192fn record_members(record: &ir::Record) -> Vec<ir::Field> {
194 let mut members = Vec::with_capacity(record.fields.len() + 1);
195 members.push(id_member());
196 members.extend(record.fields.iter().cloned());
197 members
198}
199
200fn relation_members(relation: &ir::Relation) -> Vec<ir::Field> {
203 let endpoint = |name: &str| ir::Field {
204 name: name.to_string(),
205 ty: ir::Ty::Primitive(ir::Prim::String),
206 required: true,
207 flexible: false,
208 default: None,
209 description: None,
210 constraints: ir::Constraints::default(),
211 };
212 let mut members = Vec::with_capacity(relation.fields.len() + 3);
213 members.push(id_member());
214 members.push(endpoint("in"));
215 members.push(endpoint("out"));
216 members.extend(relation.fields.iter().cloned());
217 members
218}
219
220fn ty_to_ts(ty: &ir::Ty) -> String {
222 match ty {
223 ir::Ty::Primitive(p) => prim_to_ts(*p).to_string(),
224 ir::Ty::Array(inner) => format!("{}[]", ty_to_ts(inner)),
225 ir::Ty::Named(name) => named_to_ts(name),
226 ir::Ty::Link(_) => "string".to_string(),
228 ir::Ty::Literal(value) => format!("'{value}'"),
230 ir::Ty::Union(members) => {
233 let mut parts: Vec<String> = Vec::new();
234 for m in members {
235 let t = ty_to_ts(m);
236 if !parts.contains(&t) {
237 parts.push(t);
238 }
239 }
240 parts.join(" | ")
241 }
242 }
243}
244
245fn prim_to_ts(p: ir::Prim) -> &'static str {
247 match p {
248 ir::Prim::String => "string",
249 ir::Prim::Int | ir::Prim::Float => "number",
250 ir::Prim::Bool => "boolean",
251 ir::Prim::Datetime => "Timestamp",
252 ir::Prim::Json => "any",
253 }
254}
255
256fn named_to_ts(name: &str) -> String {
258 match name {
259 "timestamp" => "Timestamp".to_string(),
260 "uuid" => "UUID".to_string(),
261 "language_code" => "LanguageCode".to_string(),
262 _ => to_pascal_case(name),
263 }
264}
265
266fn render_payload_interface(kind: &str, name: &str, fields: &[ir::Field]) -> String {
268 if fields.is_empty() {
269 format!("/** {kind} \"{name}\" — empty payload */\nexport interface {name} {{}}")
270 } else {
271 let body: Vec<String> = fields.iter().map(render_field).collect();
272 format!(
273 "/** {kind} \"{name}\" */\nexport interface {name} {{\n{}\n}}",
274 body.join("\n")
275 )
276 }
277}
278
279fn render_channel(channel: &ir::Channel) -> String {
283 let mut code = String::new();
284
285 let backend_str = match channel.backend {
286 ir::ChannelBackend::Stream => "stream",
287 ir::ChannelBackend::Datagram => "datagram",
288 };
289
290 let channel_id_note = match channel.channel_id {
292 Some(id) => format!(", channel_id={id}"),
293 None => String::new(),
294 };
295 code.push_str(&format!(
296 "// ════════════════════════════════════════════════\n\
297 // Channel: {name} (backend={backend_str}{channel_id_note})\n\
298 // ════════════════════════════════════════════════\n\n",
299 name = channel.name,
300 ));
301
302 let mut event_names: Vec<String> = Vec::new();
304 for evt in &channel.events {
305 code.push_str(&render_payload_interface("Event", &evt.name, &evt.fields));
306 code.push_str("\n\n");
307 event_names.push(evt.name.clone());
308 }
309
310 let mut request_mappings: Vec<(String, String)> = Vec::new();
312 for req in &channel.requests {
313 code.push_str(&render_payload_interface("Request", &req.name, &req.fields));
314 code.push_str("\n\n");
315
316 let response_name = match &req.returns {
317 Some(returns) => {
318 code.push_str(&render_payload_interface(
319 "Response",
320 &returns.name,
321 &returns.fields,
322 ));
323 code.push_str("\n\n");
324 returns.name.clone()
325 }
326 None => "void".to_string(),
327 };
328 request_mappings.push((req.name.clone(), response_name));
329 }
330
331 let pascal = to_pascal_case(&channel.name);
332
333 let event_types_name = format!("{pascal}ChannelEventTypes");
335 code.push_str(&format!(
336 "/** Event name → 生成 interface の map for \"{}\" (= type-narrowing 用) */\n",
337 channel.name
338 ));
339 if event_names.is_empty() {
340 code.push_str(&format!(
341 "export type {event_types_name} = Record<string, never>;\n\n"
342 ));
343 } else {
344 code.push_str(&format!("export interface {event_types_name} {{\n"));
345 for n in &event_names {
346 code.push_str(&format!(" {n}: {n};\n"));
347 }
348 code.push_str("}\n\n");
349 }
350
351 let request_types_name = format!("{pascal}ChannelRequestTypes");
353 code.push_str(&format!(
354 "/** Request name → {{ request, response }} 生成 interface の map for \"{}\" */\n",
355 channel.name
356 ));
357 if request_mappings.is_empty() {
358 code.push_str(&format!(
359 "export type {request_types_name} = Record<string, never>;\n\n"
360 ));
361 } else {
362 code.push_str(&format!("export interface {request_types_name} {{\n"));
363 for (req_name, resp_type) in &request_mappings {
364 code.push_str(&format!(
365 " {req_name}: {{ request: {req_name}; response: {resp_type} }};\n"
366 ));
367 }
368 code.push_str("}\n\n");
369 }
370
371 let meta_name = format!("{pascal}ChannelMeta");
373 code.push_str(&format!(
374 "/** Channel metadata for \"{}\" (= Phase 2 runtime SDK 用 type-narrowing 入力) */\n",
375 channel.name
376 ));
377 code.push_str(&format!("export const {meta_name} = {{\n"));
378 code.push_str(&format!(" name: {:?} as const,\n", channel.name));
379 code.push_str(&format!(" backend: {backend_str:?} as const,\n"));
380 if let Some(cid) = channel.channel_id {
381 code.push_str(&format!(" channelId: {cid} as const,\n"));
382 }
383 let from_str = match channel.from {
384 ir::ChannelFrom::Client => "client",
385 ir::ChannelFrom::Server => "server",
386 ir::ChannelFrom::Either => "either",
387 };
388 code.push_str(&format!(" from: {from_str:?} as const,\n"));
389 let lifetime_str = match channel.lifetime {
390 ir::ChannelLifetime::Transient => "transient",
391 ir::ChannelLifetime::Persistent => "persistent",
392 };
393 code.push_str(&format!(" lifetime: {lifetime_str:?} as const,\n"));
394
395 if event_names.is_empty() {
397 code.push_str(" events: [] as const,\n");
398 } else {
399 code.push_str(" events: [");
400 for (i, n) in event_names.iter().enumerate() {
401 if i > 0 {
402 code.push_str(", ");
403 }
404 code.push_str(&format!("{n:?}"));
405 }
406 code.push_str("] as const,\n");
407 }
408
409 if request_mappings.is_empty() {
411 code.push_str(" requests: {} as const,\n");
412 } else {
413 code.push_str(" requests: {\n");
414 for (req_name, resp_type) in &request_mappings {
415 code.push_str(&format!(
416 " {req_name}: {{ request: {req_name:?} as const, response: {resp_type:?} as const }},\n"
417 ));
418 }
419 code.push_str(" } as const,\n");
420 }
421
422 code.push_str(&format!(
424 " __types: undefined as unknown as {{ events: {event_types_name}; requests: {request_types_name} }},\n"
425 ));
426 code.push_str("} as const;\n");
427
428 code
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
436 ir::Field {
437 name: name.to_string(),
438 ty,
439 required,
440 flexible: false,
441 default: None,
442 description: None,
443 constraints: ir::Constraints::default(),
444 }
445 }
446
447 #[test]
448 fn emits_header() {
449 let out = TypeScriptEmitter::new().emit(&ir::Schema::default());
450 assert!(out.contains("// DO NOT EDIT MANUALLY"));
451 assert!(out.contains("export type Timestamp = string;"));
452 assert!(out.contains("export type UUID = string;"));
453 }
454
455 #[test]
456 fn emits_interface_with_optional_field() {
457 let schema = ir::Schema {
458 types: vec![ir::TypeDef::Struct {
459 name: "User".to_string(),
460 description: None,
461 fields: vec![
462 field("name", ir::Ty::Primitive(ir::Prim::String), true),
463 field("nick", ir::Ty::Primitive(ir::Prim::String), false),
464 ],
465 }],
466 protocol: None,
467 ..Default::default()
468 };
469 let out = TypeScriptEmitter::new().emit(&schema);
470 assert!(out.contains("export interface User {"));
471 assert!(out.contains(" name: string;"));
472 assert!(out.contains(" nick?: string;"));
473 }
474
475 #[test]
476 fn emits_enum() {
477 let schema = ir::Schema {
478 types: vec![ir::TypeDef::Enum {
479 name: "Role".to_string(),
480 description: None,
481 variants: vec!["admin".to_string(), "guest_user".to_string()],
482 }],
483 protocol: None,
484 ..Default::default()
485 };
486 let out = TypeScriptEmitter::new().emit(&schema);
487 assert!(out.contains("export enum Role {"));
488 assert!(out.contains(" Admin = 'admin',"));
489 assert!(out.contains(" GuestUser = 'guest_user',"));
490 }
491
492 #[test]
493 fn maps_types() {
494 let schema = ir::Schema {
495 types: vec![ir::TypeDef::Struct {
496 name: "T".to_string(),
497 description: None,
498 fields: vec![
499 field("n", ir::Ty::Primitive(ir::Prim::Int), true),
500 field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
501 field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
502 field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
503 field(
504 "tags",
505 ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
506 true,
507 ),
508 field("owner", ir::Ty::Named("user_account".to_string()), true),
509 ],
510 }],
511 protocol: None,
512 ..Default::default()
513 };
514 let out = TypeScriptEmitter::new().emit(&schema);
515 assert!(out.contains(" n: number;"));
516 assert!(out.contains(" b: boolean;"));
517 assert!(out.contains(" at: Timestamp;"));
518 assert!(out.contains(" blob: any;"));
519 assert!(out.contains(" tags: string[];"));
520 assert!(out.contains(" owner: UserAccount;"));
521 }
522
523 #[test]
524 fn emits_channel_interfaces_and_meta() {
525 let schema = ir::Schema {
526 types: vec![],
527 records: vec![],
528 relations: vec![],
529 protocol: Some(ir::Protocol {
530 name: "ping-pong".to_string(),
531 version: "2.0.0".to_string(),
532 namespace: Some("demo".to_string()),
533 description: None,
534 channels: vec![ir::Channel {
535 name: "ping-pong".to_string(),
536 from: ir::ChannelFrom::Client,
537 lifetime: ir::ChannelLifetime::Persistent,
538 backend: ir::ChannelBackend::Stream,
539 channel_id: None,
540 requests: vec![ir::Request {
541 name: "Ping".to_string(),
542 fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
543 returns: Some(ir::Message {
544 name: "Pong".to_string(),
545 fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
546 }),
547 }],
548 events: vec![ir::Event {
549 name: "Tick".to_string(),
550 fields: vec![],
551 }],
552 }],
553 }),
554 };
555 let out = TypeScriptEmitter::new().emit(&schema);
556 assert!(out.contains("// Namespace: demo"));
557 assert!(out.contains("// Channel: ping-pong (backend=stream)"));
558 assert!(out.contains("/** Request \"Ping\" */"));
559 assert!(out.contains("export interface Ping {"));
560 assert!(out.contains("/** Response \"Pong\" */"));
561 assert!(out.contains("/** Event \"Tick\" — empty payload */"));
562 assert!(out.contains("export interface Tick {}"));
563 assert!(out.contains("export interface PingPongChannelEventTypes {"));
564 assert!(out.contains("export interface PingPongChannelRequestTypes {"));
565 assert!(out.contains(" Ping: { request: Ping; response: Pong };"));
566 assert!(out.contains("export const PingPongChannelMeta = {"));
567 assert!(out.contains(" name: \"ping-pong\" as const,"));
568 assert!(out.contains(" backend: \"stream\" as const,"));
569 assert!(out.contains(" from: \"client\" as const,"));
570 assert!(out.contains(" lifetime: \"persistent\" as const,"));
571 assert!(out.contains(" events: [\"Tick\"] as const,"));
572 }
573
574 #[test]
575 fn datagram_channel_meta_carries_channel_id() {
576 let schema = ir::Schema {
577 types: vec![],
578 records: vec![],
579 relations: vec![],
580 protocol: Some(ir::Protocol {
581 name: "telemetry".to_string(),
582 version: "1.0.0".to_string(),
583 namespace: None,
584 description: None,
585 channels: vec![ir::Channel {
586 name: "metrics".to_string(),
587 from: ir::ChannelFrom::Server,
588 lifetime: ir::ChannelLifetime::Persistent,
589 backend: ir::ChannelBackend::Datagram,
590 channel_id: Some(7),
591 requests: vec![],
592 events: vec![ir::Event {
593 name: "Sample".to_string(),
594 fields: vec![field("v", ir::Ty::Primitive(ir::Prim::Float), true)],
595 }],
596 }],
597 }),
598 };
599 let out = TypeScriptEmitter::new().emit(&schema);
600 assert!(out.contains("// Channel: metrics (backend=datagram, channel_id=7)"));
601 assert!(out.contains(" channelId: 7 as const,"));
602 assert!(out.contains(" requests: {} as const,"));
603 assert!(out.contains("export type MetricsChannelRequestTypes = Record<string, never>;"));
604 }
605
606 #[test]
611 fn record_becomes_interface_with_id() {
612 let schema = ir::Schema {
613 records: vec![ir::Record {
614 name: "Atlas".to_string(),
615 description: None,
616 id_strategy: ir::IdStrategy::Uuidv7,
617 fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
618 }],
619 ..Default::default()
620 };
621 let out = TypeScriptEmitter::new().emit(&schema);
622 assert!(out.contains("export interface Atlas {"));
623 assert!(out.contains(" id: string;"));
624 assert!(out.contains(" name: string;"));
625 }
626
627 #[test]
628 fn relation_interface_is_pascal_cased_with_in_out() {
629 let schema = ir::Schema {
630 relations: vec![ir::Relation {
631 name: "derivedFrom".to_string(),
632 description: None,
633 from: "Memory".to_string(),
634 to: "Memory".to_string(),
635 unique: true,
636 fields: vec![field("reason", ir::Ty::Primitive(ir::Prim::String), false)],
637 }],
638 ..Default::default()
639 };
640 let out = TypeScriptEmitter::new().emit(&schema);
641 assert!(out.contains("export interface DerivedFrom {"));
642 assert!(out.contains(" id: string;"));
643 assert!(out.contains(" in: string;"));
644 assert!(out.contains(" out: string;"));
645 assert!(out.contains(" reason?: string;"));
646 }
647
648 #[test]
649 fn link_literal_and_union_map_to_ts_types() {
650 let schema = ir::Schema {
651 records: vec![ir::Record {
652 name: "Doc".to_string(),
653 description: None,
654 id_strategy: ir::IdStrategy::Uuidv7,
655 fields: vec![
656 field("parent", ir::Ty::Link("Doc".to_string()), false),
657 field(
658 "visibility",
659 ir::Ty::Union(vec![
660 ir::Ty::Literal("public".to_string()),
661 ir::Ty::Literal("private".to_string()),
662 ]),
663 true,
664 ),
665 ],
666 }],
667 ..Default::default()
668 };
669 let out = TypeScriptEmitter::new().emit(&schema);
670 assert!(out.contains(" parent?: string;"), "link → string");
671 assert!(
672 out.contains(" visibility: 'public' | 'private';"),
673 "literal union → TS union of literals"
674 );
675 }
676
677 #[test]
682 fn interface_and_field_descriptions_become_jsdoc() {
683 let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
684 content.description = Some("Memory content text".to_string());
685 let schema = ir::Schema {
686 types: vec![ir::TypeDef::Struct {
687 name: "Memory".to_string(),
688 description: Some("User memory".to_string()),
689 fields: vec![content],
690 }],
691 ..Default::default()
692 };
693 let out = TypeScriptEmitter::new().emit(&schema);
694 assert!(out.contains("/** User memory */\n"), "interface JSDoc");
695 assert!(
696 out.contains(" /** Memory content text */\n"),
697 "field JSDoc"
698 );
699 }
700
701 #[test]
702 fn enum_description_becomes_jsdoc() {
703 let schema = ir::Schema {
704 types: vec![ir::TypeDef::Enum {
705 name: "Role".to_string(),
706 description: Some("An access role".to_string()),
707 variants: vec!["admin".to_string()],
708 }],
709 ..Default::default()
710 };
711 let out = TypeScriptEmitter::new().emit(&schema);
712 assert!(out.contains("/** An access role */\n"));
713 }
714
715 #[test]
716 fn constraints_do_not_appear_in_typescript_output() {
717 let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
719 f.constraints = ir::Constraints {
720 min: Some(0),
721 max: Some(1),
722 ..Default::default()
723 };
724 let schema = ir::Schema {
725 types: vec![ir::TypeDef::Struct {
726 name: "T".to_string(),
727 description: None,
728 fields: vec![f],
729 }],
730 ..Default::default()
731 };
732 let out = TypeScriptEmitter::new().emit(&schema);
733 assert!(out.contains(" confidence: number;"));
734 assert!(!out.contains("@minimum"), "no constraint metadata leaks");
735 }
736}