Skip to content

Commit 8efe6d9

Browse files
committed
fix(alef-backend-swift): call .intoRust() on first-class DTO args at call sites
1 parent ac81264 commit 8efe6d9

2 files changed

Lines changed: 231 additions & 4 deletions

File tree

crates/alef-backend-swift/src/gen_bindings.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -854,10 +854,27 @@ fn emit_client_class(
854854
let params_str = params.join(", ");
855855

856856
// Build argument list for forwarding to the bridge function.
857+
// When a parameter type is a first-class Swift DTO (i.e. has `intoRust()`
858+
// emitted), the bridge function expects the raw `RustBridge.T` type, not
859+
// the Swift wrapper `T`. Apply `.intoRust()` at the call site.
860+
// `intoRust()` is `throws`, so track whether any conversion is needed so we
861+
// can add `throws` to the method signature even when `error_type` is `None`.
862+
let has_dto_param = method
863+
.params
864+
.iter()
865+
.any(|p| matches!(&p.ty, TypeRef::Named(n) if first_class_types.contains(n)));
857866
let args: Vec<String> = method
858867
.params
859868
.iter()
860-
.map(|p| swift_ident(&p.name.to_lower_camel_case()))
869+
.map(|p| {
870+
let swift_name = swift_ident(&p.name.to_lower_camel_case());
871+
match &p.ty {
872+
TypeRef::Named(n) if first_class_types.contains(n) => {
873+
format!("try {swift_name}.intoRust()")
874+
}
875+
_ => swift_name,
876+
}
877+
})
861878
.collect();
862879
let args_str = if args.is_empty() {
863880
String::new()
@@ -866,7 +883,8 @@ fn emit_client_class(
866883
};
867884

868885
let return_ty = mapper.map_type(&method.return_type);
869-
let throws_clause = if method.error_type.is_some() { " throws" } else { "" };
886+
let needs_throws = method.error_type.is_some() || has_dto_param;
887+
let throws_clause = if needs_throws { " throws" } else { "" };
870888
let async_clause = if method.is_async { " async" } else { "" };
871889
let return_clause = if matches!(method.return_type, TypeRef::Unit) {
872890
String::new()
@@ -883,11 +901,11 @@ fn emit_client_class(
883901
if matches!(method.return_type, TypeRef::Unit) {
884902
out.push_str(&format!(
885903
" {throws_kw}RustBridge.{bridge_fn_camel}(self.inner{args_str})\n",
886-
throws_kw = if method.error_type.is_some() { "try " } else { "" }
904+
throws_kw = if needs_throws { "try " } else { "" }
887905
));
888906
} else {
889907
let await_kw = if method.is_async { "await " } else { "" };
890-
let try_kw = if method.error_type.is_some() { "try " } else { "" };
908+
let try_kw = if needs_throws { "try " } else { "" };
891909
// swift-bridge bridges `Vec<u8>` as `RustVec<UInt8>` on the Swift side.
892910
// The host wrapper exposes `Data` (per `SwiftMapper::bytes()`) — convert
893911
// by iterating the RustVec into a Swift array, then wrapping in `Data`.

crates/alef-backend-swift/tests/gen_bindings_test.rs

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,6 +1497,215 @@ client_constructor_body.Client = "Self { inner: ::demo::Client::new(api_key, bas
14971497
);
14981498
}
14991499

1500+
// ── first-class DTO call-site conversion tests ───────────────────────────────
1501+
1502+
/// When a method on an opaque client class takes a first-class Swift DTO as a
1503+
/// parameter, the generated call site must apply `.intoRust()` to convert the
1504+
/// Swift wrapper into the `RustBridge.T` raw type that the bridge function
1505+
/// expects. Without this conversion the Swift compiler rejects the call with
1506+
/// "cannot convert value of type 'LiterLlm.T' to expected argument type
1507+
/// 'RustBridge.T'".
1508+
///
1509+
/// The method signature must also carry `throws` because `intoRust()` is itself
1510+
/// a throwing function.
1511+
#[test]
1512+
fn method_with_first_class_dto_param_calls_into_rust_at_call_site() {
1513+
use alef_core::ir::{MethodDef, ReceiverKind};
1514+
1515+
// CreateImageRequest: first-class DTO with has_serde + has_default + string fields.
1516+
let mut request_type = make_type(
1517+
"CreateImageRequest",
1518+
vec![
1519+
make_field("prompt", TypeRef::String, false),
1520+
make_field("model", TypeRef::Optional(Box::new(TypeRef::String)), true),
1521+
],
1522+
);
1523+
request_type.has_serde = true;
1524+
request_type.has_default = true;
1525+
1526+
// ImageClient: opaque type with `generateImage(_ request: CreateImageRequest)`.
1527+
let client_type = TypeDef {
1528+
name: "ImageClient".to_string(),
1529+
rust_path: "demo::ImageClient".to_string(),
1530+
original_rust_path: String::new(),
1531+
fields: vec![],
1532+
methods: vec![MethodDef {
1533+
name: "generate_image".to_string(),
1534+
params: vec![make_param("request", TypeRef::Named("CreateImageRequest".into()))],
1535+
return_type: TypeRef::String,
1536+
is_async: true,
1537+
is_static: false,
1538+
error_type: Some("DemoError".to_string()),
1539+
doc: String::new(),
1540+
sanitized: false,
1541+
returns_ref: false,
1542+
returns_cow: false,
1543+
return_newtype_wrapper: None,
1544+
receiver: Some(ReceiverKind::Ref),
1545+
trait_source: None,
1546+
has_default_impl: false,
1547+
binding_excluded: false,
1548+
binding_exclusion_reason: None,
1549+
}],
1550+
is_opaque: true,
1551+
is_clone: false,
1552+
is_copy: false,
1553+
is_trait: false,
1554+
has_default: false,
1555+
has_stripped_cfg_fields: false,
1556+
is_return_type: false,
1557+
serde_rename_all: None,
1558+
has_serde: false,
1559+
super_traits: vec![],
1560+
doc: String::new(),
1561+
cfg: None,
1562+
binding_excluded: false,
1563+
binding_exclusion_reason: None,
1564+
};
1565+
1566+
let api = ApiSurface {
1567+
crate_name: "demo".into(),
1568+
version: "0.1.0".into(),
1569+
types: vec![client_type, request_type],
1570+
functions: vec![],
1571+
enums: vec![],
1572+
errors: vec![],
1573+
excluded_type_paths: ::std::collections::HashMap::new(),
1574+
};
1575+
1576+
let toml = r#"
1577+
[workspace]
1578+
languages = ["swift"]
1579+
1580+
[[crates]]
1581+
name = "demo"
1582+
sources = ["src/lib.rs"]
1583+
1584+
[crates.swift]
1585+
client_constructor_body.ImageClient = "Self { inner: ::demo::ImageClient::new(api_key, base_url) }"
1586+
"#;
1587+
let cfg: alef_core::config::new_config::NewAlefConfig = toml::from_str(toml).expect("test config must parse");
1588+
let config = cfg.resolve().expect("test config must resolve").remove(0);
1589+
let files = SwiftBackend.generate_bindings(&api, &config).unwrap();
1590+
let swift = files
1591+
.iter()
1592+
.find(|f| f.path.to_string_lossy().ends_with(".swift"))
1593+
.unwrap();
1594+
1595+
// The call site must apply `.intoRust()` to convert the Swift DTO wrapper
1596+
// into the RustBridge raw type.
1597+
assert!(
1598+
swift.content.contains("try request.intoRust()"),
1599+
"call site must apply try request.intoRust() for first-class DTO params; got:\n{}",
1600+
swift.content
1601+
);
1602+
// The method signature must carry `throws` (both from error_type and intoRust).
1603+
assert!(
1604+
swift.content.contains("public func generateImage(_ request: CreateImageRequest) async throws -> String"),
1605+
"method signature must include throws when param is a first-class DTO; got:\n{}",
1606+
swift.content
1607+
);
1608+
// Must NOT pass the raw Swift wrapper directly to the bridge.
1609+
assert!(
1610+
!swift.content.contains(", request)"),
1611+
"must not forward the Swift wrapper directly to the bridge without .intoRust(); got:\n{}",
1612+
swift.content
1613+
);
1614+
}
1615+
1616+
/// When only DTO params are present (no `error_type`), the method must still
1617+
/// emit `throws` on its signature because `intoRust()` is throwing.
1618+
#[test]
1619+
fn method_with_dto_param_only_adds_throws_even_without_error_type() {
1620+
use alef_core::ir::{MethodDef, ReceiverKind};
1621+
1622+
let mut req_type = make_type(
1623+
"SpeechRequest",
1624+
vec![make_field("text", TypeRef::String, false)],
1625+
);
1626+
req_type.has_serde = true;
1627+
req_type.has_default = true;
1628+
1629+
let client_type = TypeDef {
1630+
name: "SpeechClient".to_string(),
1631+
rust_path: "demo::SpeechClient".to_string(),
1632+
original_rust_path: String::new(),
1633+
fields: vec![],
1634+
methods: vec![MethodDef {
1635+
name: "create_speech".to_string(),
1636+
params: vec![make_param("req", TypeRef::Named("SpeechRequest".into()))],
1637+
return_type: TypeRef::Bytes,
1638+
is_async: false,
1639+
is_static: false,
1640+
error_type: None, // no error_type — throws must still come from intoRust()
1641+
doc: String::new(),
1642+
sanitized: false,
1643+
returns_ref: false,
1644+
returns_cow: false,
1645+
return_newtype_wrapper: None,
1646+
receiver: Some(ReceiverKind::Ref),
1647+
trait_source: None,
1648+
has_default_impl: false,
1649+
binding_excluded: false,
1650+
binding_exclusion_reason: None,
1651+
}],
1652+
is_opaque: true,
1653+
is_clone: false,
1654+
is_copy: false,
1655+
is_trait: false,
1656+
has_default: false,
1657+
has_stripped_cfg_fields: false,
1658+
is_return_type: false,
1659+
serde_rename_all: None,
1660+
has_serde: false,
1661+
super_traits: vec![],
1662+
doc: String::new(),
1663+
cfg: None,
1664+
binding_excluded: false,
1665+
binding_exclusion_reason: None,
1666+
};
1667+
1668+
let api = ApiSurface {
1669+
crate_name: "demo".into(),
1670+
version: "0.1.0".into(),
1671+
types: vec![client_type, req_type],
1672+
functions: vec![],
1673+
enums: vec![],
1674+
errors: vec![],
1675+
excluded_type_paths: ::std::collections::HashMap::new(),
1676+
};
1677+
1678+
let toml = r#"
1679+
[workspace]
1680+
languages = ["swift"]
1681+
1682+
[[crates]]
1683+
name = "demo"
1684+
sources = ["src/lib.rs"]
1685+
1686+
[crates.swift]
1687+
client_constructor_body.SpeechClient = "Self { inner: ::demo::SpeechClient::new(api_key, base_url) }"
1688+
"#;
1689+
let cfg: alef_core::config::new_config::NewAlefConfig = toml::from_str(toml).expect("test config must parse");
1690+
let config = cfg.resolve().expect("test config must resolve").remove(0);
1691+
let files = SwiftBackend.generate_bindings(&api, &config).unwrap();
1692+
let swift = files
1693+
.iter()
1694+
.find(|f| f.path.to_string_lossy().ends_with(".swift"))
1695+
.unwrap();
1696+
1697+
assert!(
1698+
swift.content.contains("public func createSpeech(_ req: SpeechRequest) throws"),
1699+
"method with only DTO params must still emit throws; got:\n{}",
1700+
swift.content
1701+
);
1702+
assert!(
1703+
swift.content.contains("try req.intoRust()"),
1704+
"call site must apply try req.intoRust(); got:\n{}",
1705+
swift.content
1706+
);
1707+
}
1708+
15001709
/// First-class struct fields must emit their rustdoc as `///` lines immediately
15011710
/// above the `public let` declaration, mirroring how type/enum docs are surfaced.
15021711
#[test]

0 commit comments

Comments
 (0)