1pub mod phon_client;
11pub mod phon_descriptor;
12pub mod phon_server;
13pub mod phon_service;
14pub mod types;
15
16use facet_core::Shape;
17use vox_types::ServiceDescriptor;
18
19pub use types::{collect_named_types, generate_named_types};
20
21pub struct WireType {
24 pub swift_name: String,
26 pub shape: &'static Shape,
28}
29
30pub fn generate_service(service: &ServiceDescriptor) -> String {
36 validate_channel_rules(service);
37 super::validate_no_fd_shapes(service, "Swift");
39
40 let mut out = String::new();
41 out.push_str("// @generated by vox-codegen\n");
42 out.push_str("// DO NOT EDIT - regenerate with `cargo xtask codegen --swift`\n\n");
43 out.push_str("import Foundation\nimport PhonEngine\nimport PhonIR\nimport PhonSchema\nimport VoxRuntime\n\n");
44 let named = collect_named_types(service);
45 let typedefs = generate_named_types(&named)
48 .replace(": Codable, Sendable, Error", ": Sendable, Error")
49 .replace(": Codable, Sendable", ": Sendable");
50 out.push_str(&typedefs);
51 out.push('\n');
52 out.push_str(&phon_service::generate_phon_service(service));
53 out.push_str(&phon_client::generate_phon_client(service));
54 out.push_str(&phon_server::generate_phon_server(service));
55 out
56}
57
58fn validate_channel_rules(service: &ServiceDescriptor) {
59 for method in service.methods {
60 assert!(
62 !vox_types::shape_contains_channel(method.return_shape),
63 "channels are not allowed in return types: {}.{}",
64 method.service_name,
65 method.method_name
66 );
67 assert!(
69 !vox_types::shape_contains_channel_in_collection(method.args_shape),
70 "channels are not allowed inside collections: {}.{}",
71 method.service_name,
72 method.method_name
73 );
74 assert!(
76 !vox_types::shape_contains_indirect_channel_arg(method.args_shape),
77 "channels are only allowed as direct method arguments: {}.{}",
78 method.service_name,
79 method.method_name
80 );
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use std::collections::BTreeSet;
87
88 use base64::{Engine, engine::general_purpose::STANDARD};
89 use facet::{Facet, Shape};
90 use vox_types::{
91 MethodDescriptor, MethodDescriptorOptions, MethodId, Rx, ServiceDescriptor, Tx,
92 method_descriptor,
93 };
94
95 use crate::render::hex_u64;
96
97 use super::generate_service;
98
99 fn schema_closure_expr(bytes: &[u8]) -> String {
100 let encoded = STANDARD.encode(bytes);
101 let mut out = String::from("voxSchemaClosure(\"\"\"\n");
102 for chunk in encoded.as_bytes().chunks(100) {
103 out.push_str(" ");
104 out.push_str(std::str::from_utf8(chunk).expect("base64 is utf-8"));
105 out.push('\n');
106 }
107 out.push_str(" \"\"\")");
108 out
109 }
110
111 fn parse_self_describing_unique_bundle(
112 bytes: &[u8],
113 expected_root: u64,
114 ) -> vox_phon::SchemaBundle {
115 let bundle = vox_phon::parse_schema_bytes(bytes).expect("schema closure parses");
116 assert_eq!(bundle.root.as_u64(), expected_root);
117 let mut seen = BTreeSet::new();
118 for schema in &bundle.schemas {
119 assert!(
120 seen.insert(schema.id.as_u64()),
121 "schema closure must not repeat type ID {:#x}",
122 schema.id.as_u64()
123 );
124 }
125 bundle
126 }
127
128 #[derive(Facet)]
129 struct NestedInner {
130 value: u32,
131 }
132
133 #[derive(Facet)]
134 struct NestedOuter {
135 inner: NestedInner,
136 }
137
138 #[derive(Facet)]
139 struct NestedChannels {
140 stream: Tx<u32>,
141 }
142
143 #[derive(Facet)]
144 struct OuterChannels {
145 inner: NestedChannels,
146 }
147
148 #[cfg(unix)]
149 #[derive(Facet)]
150 struct FdBundle {
151 descriptors: Vec<vox_types::Fd>,
152 }
153
154 fn service_with_shapes(
155 args_shape: &'static Shape,
156 return_shape: &'static Shape,
157 ) -> ServiceDescriptor {
158 let method = Box::leak(Box::new(MethodDescriptor {
159 id: MethodId(1),
160 service_name: "BadSvc",
161 method_name: "bad",
162 args_shape,
163 args: &[],
164 return_shape,
165 response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
166 args_have_channels: vox_types::shape_contains_channel(args_shape),
167 doc: None,
168 }));
169 let methods = Box::leak(vec![method as &'static MethodDescriptor].into_boxed_slice());
170 ServiceDescriptor {
171 service_name: "BadSvc",
172 methods,
173 doc: None,
174 }
175 }
176
177 fn panic_message(panic: Box<dyn std::any::Any + Send>) -> String {
178 panic
179 .downcast_ref::<String>()
180 .cloned()
181 .or_else(|| panic.downcast_ref::<&'static str>().map(|s| s.to_string()))
182 .expect("panic payload should be a string")
183 }
184
185 #[test]
186 fn generated_swift_uses_phon_schema_closures_and_canonical_method_ids() {
196 let echo = method_descriptor::<(NestedOuter,), NestedOuter>(
197 "SchemaSvc",
198 "EchoNested",
199 &["value"],
200 &[Some(<NestedOuter as Facet>::SHAPE)],
201 MethodDescriptorOptions {
202 response_wire_shape: <Result<NestedOuter, vox_types::VoxError> as Facet>::SHAPE,
203 doc: None,
204 },
205 );
206 let methods = Box::leak(vec![echo].into_boxed_slice());
207 let service = ServiceDescriptor {
208 service_name: "SchemaSvc",
209 methods,
210 doc: None,
211 };
212
213 let generated = generate_service(&service);
214 let args_root = vox_phon::schema_id_for_shape(echo.args_shape).expect("args schema id");
215 let response_root =
216 vox_phon::schema_id_for_shape(echo.response_wire_shape).expect("response schema id");
217 let args_closure =
218 vox_phon::schema_bytes_for_shape(echo.args_shape).expect("args schema closure");
219 let response_closure = vox_phon::schema_bytes_for_shape(echo.response_wire_shape)
220 .expect("response schema closure");
221 let args_bundle = parse_self_describing_unique_bundle(&args_closure, args_root.as_u64());
222 let _response_bundle =
223 parse_self_describing_unique_bundle(&response_closure, response_root.as_u64());
224 let nested_inner_id =
225 vox_phon::schema_id_for_shape(<NestedInner as Facet>::SHAPE).expect("inner schema id");
226
227 assert_eq!(args_bundle.root, args_root);
228 assert!(
229 args_bundle
230 .schemas
231 .iter()
232 .any(|schema| schema.id == nested_inner_id),
233 "args closure must carry transitively referenced NestedInner schema"
234 );
235 assert!(
236 generated.contains(&format!(
237 " {}: PhonMethodSchemas(",
238 hex_u64(crate::method_id(echo))
239 )),
240 "generated Swift method table must use the canonical Vox method ID:\n{generated}"
241 );
242 assert!(
243 generated.contains(&format!(
244 "argsRoot: SchemaId({})",
245 hex_u64(args_root.as_u64())
246 )),
247 "generated Swift must use the phon args schema ID:\n{generated}"
248 );
249 assert!(
250 generated.contains(&format!(
251 "responseRoot: SchemaId({})",
252 hex_u64(response_root.as_u64())
253 )),
254 "generated Swift must use the phon response schema ID:\n{generated}"
255 );
256 assert!(
257 generated.contains(&format!(
258 "argsSchemaClosure: {}",
259 schema_closure_expr(&args_closure)
260 )),
261 "generated Swift must embed the self-contained phon args closure:\n{generated}"
262 );
263 assert!(
264 generated.contains(&format!(
265 "responseSchemaClosure: {}",
266 schema_closure_expr(&response_closure)
267 )),
268 "generated Swift must embed the self-contained phon response closure:\n{generated}"
269 );
270 }
271
272 #[test]
273 fn generated_swift_method_ids_include_service_name() {
275 let alpha = method_descriptor::<(), ()>(
276 "AlphaSvc",
277 "echo",
278 &[],
279 &[],
280 MethodDescriptorOptions {
281 response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
282 doc: None,
283 },
284 );
285 let beta = method_descriptor::<(), ()>(
286 "BetaSvc",
287 "echo",
288 &[],
289 &[],
290 MethodDescriptorOptions {
291 response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
292 doc: None,
293 },
294 );
295
296 assert_ne!(crate::method_id(alpha), crate::method_id(beta));
297
298 let alpha_methods = Box::leak(vec![alpha].into_boxed_slice());
299 let beta_methods = Box::leak(vec![beta].into_boxed_slice());
300 let alpha_service = ServiceDescriptor {
301 service_name: "AlphaSvc",
302 methods: alpha_methods,
303 doc: None,
304 };
305 let beta_service = ServiceDescriptor {
306 service_name: "BetaSvc",
307 methods: beta_methods,
308 doc: None,
309 };
310
311 let alpha_generated = generate_service(&alpha_service);
312 let beta_generated = generate_service(&beta_service);
313 let alpha_entry = format!(
314 " {}: PhonMethodSchemas(",
315 hex_u64(crate::method_id(alpha))
316 );
317 let beta_entry = format!(
318 " {}: PhonMethodSchemas(",
319 hex_u64(crate::method_id(beta))
320 );
321
322 assert!(
323 alpha_generated.contains(&alpha_entry),
324 "Alpha service must emit its service-qualified method ID:\n{alpha_generated}"
325 );
326 assert!(
327 !alpha_generated.contains(&beta_entry),
328 "Alpha service must not reuse Beta service's same-method-name ID:\n{alpha_generated}"
329 );
330 assert!(
331 beta_generated.contains(&beta_entry),
332 "Beta service must emit its service-qualified method ID:\n{beta_generated}"
333 );
334 }
335
336 #[test]
337 fn generated_swift_emits_rpc_caller_handler_and_dispatch_shapes() {
347 let echo = method_descriptor::<(String,), String>(
348 "TestSvc",
349 "echo",
350 &["message"],
351 &[None],
352 MethodDescriptorOptions {
353 response_wire_shape: <Result<String, vox_types::VoxError> as Facet>::SHAPE,
354 doc: None,
355 },
356 );
357 let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
358 "TestSvc",
359 "divide",
360 &["lhs", "rhs"],
361 &[None, None],
362 MethodDescriptorOptions {
363 response_wire_shape: <Result<u64, vox_types::VoxError<String>> as Facet>::SHAPE,
364 doc: None,
365 },
366 );
367 let methods = Box::leak(vec![echo, divide].into_boxed_slice());
368 let service = ServiceDescriptor {
369 service_name: "TestSvc",
370 methods,
371 doc: None,
372 };
373
374 let generated = generate_service(&service);
375 let echo_id = hex_u64(crate::method_id(echo));
376 let divide_id = hex_u64(crate::method_id(divide));
377
378 assert!(
379 generated.contains("public protocol TestSvcCaller: Sendable {")
380 && generated.contains(" func echo(message: String) async throws -> String")
381 && generated.contains(
382 " func divide(lhs: UInt64, rhs: UInt64) async throws -> Result<UInt64, String>",
383 ),
384 "generated Swift must expose async caller methods matching the source service:\n{generated}"
385 );
386 assert!(
387 generated.contains("public final class TestSvcClient: TestSvcCaller, Sendable {")
388 && generated.contains(" private let connection: VoxLane")
389 && generated
390 .contains(" public init(connection: VoxLane, timeout: TimeInterval? = 30.0)")
391 && generated.contains("let payload = encodeVoxTyped(message, testSvc_echo_ArgsEncoder)")
392 && generated.contains(&format!(
393 "methodId: {echo_id}, metadata: .null, payload: payload, timeout: timeout,"
394 ))
395 && generated.contains(&format!(
396 "schemaInfo: ClientSchemaInfo(methodSchemas: testSvcMethods[{echo_id}]!, registry: testSvcRegistry)"
397 ))
398 && generated.contains("let result: Result<String, VoxError<Infallible>> = try decodeVoxTyped(respDecoder, response)"),
399 "generated Swift client must delegate request serialization and response handling to VoxLane:\n{generated}"
400 );
401 assert!(
402 generated.contains(" case connectionShutdown")
403 && !generated.contains(" case sessionShutdown"),
404 "generated Swift wire errors must use connection-era vocabulary:\n{generated}"
405 );
406 assert!(
407 generated.contains("public protocol TestSvcHandler: Sendable {")
408 && generated.contains(" func echo(message: String) async throws -> String")
409 && generated.contains(
410 " func divide(lhs: UInt64, rhs: UInt64) async throws -> Result<UInt64, String>",
411 ),
412 "generated Swift handler must expose service methods with server-side argument types:\n{generated}"
413 );
414 assert!(
415 generated.contains("public final class TestSvcDispatcher: ServiceDispatcher {")
416 && generated.contains(" private let handler: TestSvcHandler")
417 && generated.contains(" public init(handler: TestSvcHandler) { self.handler = handler }")
418 && generated.contains("public func dispatch(methodId: UInt64, payload: [UInt8], requestId: UInt64, channels: [UInt64], registry: ChannelRegistry, schemaSendTracker: SchemaSendTracker, schemaReceiveTracker: SchemaTracker, context: RequestContext, taskTx: @escaping @Sendable (TaskMessage) -> Void) async")
419 && generated.contains(&format!(
420 "case {echo_id}: await dispatch_echo(payload: payload, requestId: requestId, schemaSendTracker: schemaSendTracker, schemaReceiveTracker: schemaReceiveTracker, taskTx: taskTx)"
421 ))
422 && generated.contains(&format!(
423 "case {divide_id}: await dispatch_divide(payload: payload, requestId: requestId, schemaSendTracker: schemaSendTracker, schemaReceiveTracker: schemaReceiveTracker, taskTx: taskTx)"
424 ))
425 && generated.contains("let voxValue = try await handler.echo(message: args)")
426 && generated.contains("let voxValue = try await handler.divide(lhs: args.0, rhs: args.1)")
427 && generated.contains("case .failure(let e): voxResult = .failure(.user(e))"),
428 "generated Swift dispatcher must route decoded args to the handler and reply through TaskMessage:\n{generated}"
429 );
430 assert!(
431 generated.contains(
432 "default: taskTx(.response(requestId: requestId, payload: encodeVoxError(.unknownMethod), methodId: methodId))"
433 ),
434 "generated Swift dispatcher must return a call-level UnknownMethod response for unrecognized method ids:\n{generated}"
435 );
436 }
437
438 #[test]
439 fn generated_swift_has_no_trailing_line_whitespace() {
440 let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
441 "WhitespaceSvc",
442 "divide",
443 &["lhs", "rhs"],
444 &[None, None],
445 MethodDescriptorOptions {
446 response_wire_shape: <Result<u64, vox_types::VoxError<String>> as Facet>::SHAPE,
447 doc: None,
448 },
449 );
450 let methods = Box::leak(vec![divide].into_boxed_slice());
451 let service = ServiceDescriptor {
452 service_name: "WhitespaceSvc",
453 methods,
454 doc: None,
455 };
456
457 let generated = generate_service(&service);
458
459 for (line_idx, line) in generated.lines().enumerate() {
460 assert_eq!(
461 line.trim_end_matches([' ', '\t']),
462 line,
463 "generated Swift line {} has trailing whitespace: {line:?}",
464 line_idx + 1
465 );
466 }
467 }
468
469 #[test]
470 fn generated_swift_maps_schema_decode_failures_to_call_errors() {
474 let echo = method_descriptor::<(NestedOuter,), NestedOuter>(
475 "SchemaSvc",
476 "EchoNested",
477 &["value"],
478 &[Some(<NestedOuter as Facet>::SHAPE)],
479 MethodDescriptorOptions {
480 response_wire_shape: <Result<NestedOuter, vox_types::VoxError> as Facet>::SHAPE,
481 doc: None,
482 },
483 );
484 let methods = Box::leak(vec![echo].into_boxed_slice());
485 let service = ServiceDescriptor {
486 service_name: "SchemaSvc",
487 methods,
488 doc: None,
489 };
490
491 let generated = generate_service(&service);
492
493 assert!(
494 generated.contains("guard let argsDecoder = schemaReceiveTracker.buildDecodeFn"),
495 "generated Swift server must build a caller->callee args decode plan:\n{generated}"
496 );
497 assert!(
498 generated.contains("encodeVoxError(.invalidPayload(\"no args schema advertised\"))"),
499 "generated Swift server must reject missing args decode plans as call errors:\n{generated}"
500 );
501 assert!(
502 generated.contains("do { args = try decodeVoxTyped(argsDecoder, payload) } catch {")
503 && generated.contains("encodeVoxError(.invalidPayload(\"decode args\"))"),
504 "generated Swift server must turn args decode failures into InvalidPayload responses:\n{generated}"
505 );
506 assert!(
507 generated
508 .contains("guard let respDecoder = connection.schemaReceiveTracker.buildDecodeFn"),
509 "generated Swift client must build a callee->caller response decode plan:\n{generated}"
510 );
511 assert!(
512 generated.contains(
513 "throw VoxError<Infallible>.invalidPayload(\"no response schema advertised\")",
514 ),
515 "generated Swift client must surface response decode-plan failure on the call:\n{generated}"
516 );
517 assert_eq!(
518 generated.matches("connection.call(").count(),
519 1,
520 "generated Swift client must issue one request attempt and not replay after local decode failure:\n{generated}"
521 );
522 }
523
524 #[test]
525 fn generated_swift_emits_channel_schemas() {
528 let subscribe = method_descriptor::<(Tx<NestedInner>, Rx<NestedInner>), ()>(
529 "StreamSvc",
530 "subscribe",
531 &["output", "input"],
532 &[
533 Some(<NestedInner as facet::Facet>::SHAPE),
534 Some(<NestedInner as facet::Facet>::SHAPE),
535 ],
536 MethodDescriptorOptions {
537 response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
538 doc: None,
539 },
540 );
541 let methods = Box::leak(vec![subscribe].into_boxed_slice());
542 let service = ServiceDescriptor {
543 service_name: "StreamSvc",
544 methods,
545 doc: None,
546 };
547
548 let generated = generate_service(&service);
549 let args_closure = vox_phon::schema_bytes_for_shape_with_auxiliary_roots(
550 subscribe.args_shape,
551 &[
552 (
553 "channel.arg.0.tx.element",
554 <NestedInner as facet::Facet>::SHAPE,
555 ),
556 (
557 "channel.arg.1.rx.element",
558 <NestedInner as facet::Facet>::SHAPE,
559 ),
560 ],
561 )
562 .expect("args schema closure with channel auxiliary roots");
563 assert!(
564 generated.contains("channel.arg.0.tx.element"),
565 "Tx<T> channel receive decode must use the named auxiliary root:\n{generated}"
566 );
567 assert!(
568 generated.contains("channel.arg.1.rx.element"),
569 "Rx<T> channel receive decode must use the named auxiliary root:\n{generated}"
570 );
571 assert!(
572 generated.contains(&format!(
573 "argsSchemaClosure: {}",
574 schema_closure_expr(&args_closure)
575 )),
576 "channel element roots must be carried in argsSchemaClosure auxiliary roots:\n{generated}"
577 );
578 assert!(
579 generated.contains("buildAuxiliaryDecodeFn"),
580 "generated Swift must build channel item decode programs from advertised schemas:\n{generated}"
581 );
582 assert!(
583 !generated.contains("ElementDecodeProgram"),
584 "generated Swift must not emit local same-schema channel item decode programs:\n{generated}"
585 );
586 assert!(
587 generated.contains("public struct NestedInner"),
588 "named channel element types must be emitted as normal generated types:\n{generated}"
589 );
590 assert!(
591 generated.contains("var channelIds: [UInt64] = []")
592 && generated.contains("let outputWireIndex = channelIds.count")
593 && generated.contains("channelIds.append(await connection.bindClientTxArg(output")
594 && generated.contains("let inputWireIndex = channelIds.count")
595 && generated.contains("channelIds.append(await connection.bindClientRxArg(input")
596 && generated.contains("channels: channelIds,"),
597 "generated Swift caller must discover direct channel args left-to-right and send that channel id list:\n{generated}"
598 );
599 assert!(
600 generated.contains("let outputWireIndex = channelWireIndex(args.0)")
601 && generated.contains("channels[outputWireIndex]")
602 && generated.contains("let inputWireIndex = channelWireIndex(args.1)")
603 && generated.contains("channels[inputWireIndex]"),
604 "generated Swift dispatcher must resolve decoded channel wire indexes through Request.channels:\n{generated}"
605 );
606 }
607
608 #[test]
609 fn generated_swift_rejects_channels_in_return_shapes() {
611 let service = service_with_shapes(<() as Facet>::SHAPE, <Tx<u32> as Facet>::SHAPE);
612
613 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
614 generate_service(&service);
615 }));
616 let message = panic_message(result.expect_err("generator should reject channel returns"));
617
618 assert!(
619 message.contains("channels are not allowed in return types: BadSvc.bad"),
620 "unexpected panic message: {message}"
621 );
622 }
623
624 #[test]
626 fn generated_swift_rejects_nested_channel_args() {
627 let service = service_with_shapes(<(OuterChannels,) as Facet>::SHAPE, <() as Facet>::SHAPE);
628
629 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
630 generate_service(&service);
631 }));
632 let message = panic_message(result.expect_err("generator should reject nested channels"));
633
634 assert!(
635 message.contains("channels are only allowed as direct method arguments: BadSvc.bad"),
636 "unexpected panic message: {message}"
637 );
638 }
639
640 #[test]
641 fn generated_swift_rejects_channels_inside_collections() {
643 let service = service_with_shapes(<(Vec<Tx<u32>>,) as Facet>::SHAPE, <() as Facet>::SHAPE);
644
645 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
646 generate_service(&service);
647 }));
648 let message =
649 panic_message(result.expect_err("generator should reject collection channels"));
650
651 assert!(
652 message.contains("channels are not allowed inside collections: BadSvc.bad"),
653 "unexpected panic message: {message}"
654 );
655 }
656
657 #[cfg(unix)]
658 #[test]
659 fn generated_swift_rejects_fd_capability_surfaces() {
661 let service = service_with_shapes(<() as Facet>::SHAPE, <FdBundle as Facet>::SHAPE);
662
663 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
664 generate_service(&service);
665 }));
666 let message =
667 panic_message(result.expect_err("generator should reject fd capability surfaces"));
668
669 assert!(
670 message.contains(
671 "Swift codegen does not support vox::Fd capabilities: BadSvc.bad return type"
672 ),
673 "unexpected panic message: {message}"
674 );
675 }
676}