use super::tests::*;
use super::*;
use crate::core::ir::{ParamDef, PrimitiveType, TypeRef};
#[test]
fn gen_convert_with_visitor_wrapper_uses_scalar_handle_not_opaque_pointer() {
let func = FunctionDef {
name: "convert".to_string(),
params: vec![
make_param("html", TypeRef::String),
make_param("options", TypeRef::Named("ConversionOptions".to_string())),
],
return_type: TypeRef::Named("ConversionResult".to_string()),
error_type: Some("ConversionError".to_string()),
..Default::default()
};
let opaque_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
let value_only_types: std::collections::HashSet<String> = std::collections::HashSet::new();
let bridge_cfg = crate::core::config::TraitBridgeConfig {
trait_name: "HtmlVisitor".to_string(),
type_alias: Some("VisitorHandle".to_string()),
param_name: Some("visitor".to_string()),
options_type: Some("ConversionOptions".to_string()),
..Default::default()
};
let reserved_type_names: HashSet<String> = HashSet::new();
let out = gen_convert_with_visitor_wrapper(
&func,
"htm",
&opaque_names,
&value_only_types,
&bridge_cfg,
&reserved_type_names,
);
assert!(
out.contains("func Convert(") && out.contains("C.htm_convert("),
"expected a full Convert wrapper body with an htm_convert FFI call, got:\n{out}"
);
assert!(
out.contains("var cOptions C.HTMAlefHandle"),
"cOptions must be declared as the scalar AlefHandle type, got:\n{out}"
);
assert!(
!out.contains("*C.HTMConversionOptions"),
"cOptions must not be declared as an opaque pointer to the options struct, got:\n{out}"
);
assert!(
!out.contains("cOptions == nil"),
"handle comparisons must use == 0, not nil, got:\n{out}"
);
assert!(
out.contains("cOptions == 0"),
"expected a scalar handle nil-check for cOptions, got:\n{out}"
);
assert!(
!out.contains("ptr == nil"),
"the convert result handle must not be compared to nil, got:\n{out}"
);
assert!(
out.contains("ptr == 0"),
"expected a scalar handle nil-check for the convert result, got:\n{out}"
);
}
#[test]
fn optional_bytes_return_reads_the_three_out_params_and_maps_null_to_nil() {
let mut func = make_bytes_result_func("thumbnail", true);
func.return_type = TypeRef::Optional(Box::new(TypeRef::Bytes));
assert!(
is_bytes_result_func(&func),
"Optional<Bytes> must use the same owned-byte-buffer ABI as bare Bytes"
);
let empty_refs = HashSet::new();
let empty_strings = HashSet::new();
let out = gen_function_wrapper(
&func,
"krz",
&empty_refs,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
);
assert!(
out.contains("func Thumbnail(") && out.contains("C.krz_thumbnail("),
"fixture must emit a real wrapper calling the C symbol, got:\n{out}"
);
assert!(out.contains("([]byte, error)"), "missing bytes return type in:\n{out}");
assert!(out.contains("var outPtr"), "missing outPtr declaration in:\n{out}");
assert!(out.contains("var outLen, outCap"), "missing outLen/outCap in:\n{out}");
assert!(
out.contains("&outPtr, &outLen, &outCap"),
"the C call must pass all three out-params in:\n{out}"
);
assert!(
out.contains("if outPtr == nil"),
"absence is carried by a NULL out_ptr and must map to Go's nil slice, got:\n{out}"
);
assert!(out.contains("C.GoBytes"), "missing C.GoBytes in:\n{out}");
assert!(out.contains("krz_free_bytes"), "missing krz_free_bytes in:\n{out}");
assert!(
!out.contains("ptr :="),
"Optional<Bytes> must not fall through to the direct-pointer return path in:\n{out}"
);
}
#[test]
fn optional_string_return_keeps_the_direct_pointer_shape() {
let mut func = make_bytes_result_func("caption", false);
func.return_type = TypeRef::Optional(Box::new(TypeRef::String));
assert!(
!is_bytes_result_func(&func),
"only Bytes / Optional<Bytes> use the owned-byte-buffer ABI"
);
let empty_refs = HashSet::new();
let empty_strings = HashSet::new();
let out = gen_function_wrapper(
&func,
"krz",
&empty_refs,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
);
assert!(
out.contains("func Caption(") && out.contains("C.krz_caption("),
"fixture must emit a real wrapper calling the C symbol, got:\n{out}"
);
assert!(
!out.contains("outPtr"),
"no byte out-params for Optional<String> in:\n{out}"
);
assert!(
!out.contains("outCap"),
"no byte out-params for Optional<String> in:\n{out}"
);
assert!(
!out.contains("([]byte, error)"),
"Optional<String> must not be typed as []byte in:\n{out}"
);
}
#[test]
fn async_free_function_returning_vec_of_named_over_config_struct_emits_wrapper() {
let func = FunctionDef {
name: "embed_sparse_async".to_string(),
params: vec![
make_param("texts", TypeRef::Vec(Box::new(TypeRef::String))),
make_param("config", TypeRef::Named("SparseEmbeddingConfig".to_string())),
],
return_type: TypeRef::Vec(Box::new(TypeRef::Named("SparseEmbedding".to_string()))),
is_async: true,
error_type: Some("SampleCrateError".to_string()),
..Default::default()
};
let opaque: std::collections::HashSet<&str> = std::collections::HashSet::new();
let empty_strings = HashSet::new();
let out = gen_function_wrapper(
&func,
"sample",
&opaque,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
);
assert!(
out.contains(
"func EmbedSparseAsync(texts []string, config SparseEmbeddingConfig) ([]SparseEmbedding, error) {"
),
"expected the exact async Vec<Named>-over-config-struct signature, got:\n{out}"
);
assert!(
out.contains("return EmbedSparseAsyncWithContext(context.Background(), texts, config)"),
"the plain wrapper must forward to its WithContext sibling, got:\n{out}"
);
assert!(
out.contains(
"func EmbedSparseAsyncWithContext(ctx context.Context, texts []string, config SparseEmbeddingConfig) ([]SparseEmbedding, error) {"
),
"the context variant must take ctx first and keep the rest of the signature, got:\n{out}"
);
assert!(
out.contains("C.sample_embed_sparse_async_cancellable(") && !out.contains("C.sample_embed_sparse_async("),
"the work must call the cancellable FFI sibling, never the uncancellable symbol, got:\n{out}"
);
assert!(
out.contains("json.Unmarshal") && out.contains("[]SparseEmbedding"),
"Vec<Named> return must unmarshal into a Go slice, got:\n{out}"
);
}
fn make_mut_param(name: &str, type_name: &str) -> ParamDef {
ParamDef {
is_ref: true,
is_mut: true,
..make_param(name, TypeRef::Named(type_name.to_string()))
}
}
#[test]
fn test_gen_function_wrapper_mut_dto_param_writes_back_the_mutated_value() {
let func = FunctionDef {
name: "tag_record".to_string(),
params: vec![make_mut_param("record", "Record")],
return_type: TypeRef::Unit,
error_type: None,
..Default::default()
};
let opaque: std::collections::HashSet<&str> = std::collections::HashSet::new();
let empty_strings = HashSet::new();
let out = gen_function_wrapper(
&func,
"krz",
&opaque,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
);
assert!(
out.contains("func TagRecord(record Record) (*Record, error) {"),
"expected the write-back signature (record by value in, *Record + error out), got:\n{out}"
);
assert!(
out.contains("cRecord := C.krz_record_from_json(tmpStrcRecord)"),
"must still build the temporary handle from the caller's value, got:\n{out}"
);
assert!(
out.contains("defer C.krz_record_free(cRecord)"),
"must still free the temporary handle, got:\n{out}"
);
assert!(
out.contains("C.krz_tag_record(cRecord)"),
"must still call the FFI mutator, got:\n{out}"
);
assert!(
out.contains("jsonPtr := C.krz_record_to_json(cRecord)"),
"must read the mutated handle back out via the FFI's _to_json helper, got:\n{out}"
);
assert!(
out.contains("var result Record")
&& out.contains("json.Unmarshal([]byte(C.GoString(jsonPtr)), &result)")
&& out.contains("return &result, nil"),
"must decode the read-back JSON into a fresh Record and return it, got:\n{out}"
);
let call_pos = out.find("C.krz_tag_record(cRecord)").expect("call site");
let readback_pos = out
.find("jsonPtr := C.krz_record_to_json(cRecord)")
.expect("read-back site");
assert!(
call_pos < readback_pos,
"the mutating call must run before the read-back, got:\n{out}"
);
assert!(
!out.contains("defer C.krz_record_to_json") && !out.contains("defer jsonPtr"),
"the read-back must run as a plain statement, not be deferred, got:\n{out}"
);
assert!(
!out.contains("C.krz_tag_record(cRecord)\n\treturn nil\n}"),
"must not regress to the lossy call-then-return-nil shape, got:\n{out}"
);
}
#[test]
fn test_gen_function_wrapper_immutable_dto_param_gets_no_writeback() {
let func = FunctionDef {
name: "read_record".to_string(),
params: vec![ParamDef {
is_ref: true,
is_mut: false,
..make_param("record", TypeRef::Named("Record".to_string()))
}],
return_type: TypeRef::Unit,
error_type: None,
..Default::default()
};
let opaque: std::collections::HashSet<&str> = std::collections::HashSet::new();
let empty_strings = HashSet::new();
let out = gen_function_wrapper(
&func,
"krz",
&opaque,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
);
assert!(
out.contains("func ReadRecord(record Record) error {"),
"an immutable DTO param must keep the plain error-only signature, got:\n{out}"
);
assert!(
!out.contains("_to_json"),
"an immutable DTO param must not read anything back, got:\n{out}"
);
assert!(
out.contains("C.krz_read_record(cRecord)\n\treturn nil\n}"),
"must keep the original call-then-return-nil shape, got:\n{out}"
);
}
#[test]
fn test_gen_function_wrapper_owned_dto_param_is_unchanged() {
let func = FunctionDef {
name: "consume_and_tag_record".to_string(),
params: vec![make_param("record", TypeRef::Named("Record".to_string()))],
return_type: TypeRef::Unit,
error_type: None,
..Default::default()
};
let opaque: std::collections::HashSet<&str> = std::collections::HashSet::new();
let empty_strings = HashSet::new();
let out = gen_function_wrapper(
&func,
"krz",
&opaque,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
);
assert!(
out.contains("func ConsumeAndTagRecord(record Record) error {"),
"an owned DTO param must keep the plain error-only signature, got:\n{out}"
);
assert!(
!out.contains("_to_json"),
"an owned DTO param must not read anything back, got:\n{out}"
);
assert_eq!(
out,
concat!(
"// ConsumeAndTagRecord calls the FFI function.\n",
"func ConsumeAndTagRecord(record Record) error {\n",
"\truntime.LockOSThread()\n",
"\tdefer runtime.UnlockOSThread()\n",
"\tjsonBytescRecord, err := json.Marshal(record)\n",
"\tif err != nil {\n",
"\t\treturn fmt.Errorf(\"failed to marshal: %w\", err)\n",
"\t}\n",
"\t// When the parameter is a nil pointer (Option<&T> on the Rust side), json.Marshal\n",
"\t// emits \"null\" which the FFI's _from_json rejects. Substitute \"{}\" so a default\n",
"\t// instance is constructed instead \u{2014} semantically equivalent to None for query types\n",
"\t// whose fields are all optional with serde(default).\n",
"\tif string(jsonBytescRecord) == \"null\" {\n",
"\t\tjsonBytescRecord = []byte(\"{}\")\n",
"\t}\n",
"\ttmpStrcRecord := C.CString(string(jsonBytescRecord))\n",
"\tcRecord := C.krz_record_from_json(tmpStrcRecord)\n",
"\tC.free(unsafe.Pointer(tmpStrcRecord))\n",
"\tif cRecord == 0 {\n",
"\t\treturn wrapLastError(\"failed to create record\")\n",
"\t}\n",
"\tdefer C.krz_record_free(cRecord)\n",
"\n",
"\n",
"\tC.krz_consume_and_tag_record(cRecord)\n",
"\treturn nil\n",
"}\n",
),
"owned DTO param rendering must be byte-for-byte unchanged by the write-back fix, got:\n{out}"
);
}
#[test]
fn test_reject_unsupported_writeback_fires_for_mut_param_with_non_unit_return() {
let func = FunctionDef {
name: "tag_and_count".to_string(),
params: vec![make_mut_param("record", "Record")],
return_type: TypeRef::Primitive(PrimitiveType::U32),
error_type: None,
..Default::default()
};
let opaque: ahash::AHashSet<String> = ahash::AHashSet::default();
let result = crate::codegen::mut_writeback::reject_unsupported_writeback(
&func.name,
&func.params,
&func.return_type,
&opaque,
);
let err = result.expect_err("a &mut DTO param with a non-unit return must be rejected");
let message = err.to_string();
assert!(
message.contains("tag_and_count"),
"the diagnostic must name the offending function, got: {message}"
);
}
#[test]
fn test_gen_function_wrapper_locks_os_thread_across_call_read_and_convert() {
let func = FunctionDef {
name: "get_widget".to_string(),
return_type: TypeRef::Named("Widget".to_string()),
error_type: Some("SampleCrateError".to_string()),
..Default::default()
};
let opaque: std::collections::HashSet<&str> = std::collections::HashSet::new();
let empty_strings = HashSet::new();
let out = gen_function_wrapper(
&func,
"krz",
&opaque,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
&empty_strings,
);
let signature_pos = out.find("func GetWidget(").expect("signature must be emitted");
let lock_pos = out.find("runtime.LockOSThread()").expect("must lock the OS thread");
let call_pos = out.find("C.krz_get_widget(").expect("must call the FFI symbol");
let last_error_pos = out.find("if err := lastError()").expect("must read lastError()");
let unmarshal_pos = out.find("json.Unmarshal").expect("must convert the JSON result");
assert!(
signature_pos < lock_pos && lock_pos < call_pos && call_pos < last_error_pos && last_error_pos < unmarshal_pos,
"expected lock, then call, then lastError() read, then conversion, in that order, got:\n{out}"
);
assert!(
out.contains("defer runtime.UnlockOSThread()"),
"the unlock must be deferred so every return path in the body releases it, got:\n{out}"
);
assert_eq!(
out.matches("runtime.LockOSThread()").count(),
1,
"a synchronous wrapper must lock exactly once, got:\n{out}"
);
}
#[test]
fn test_capsule_wrapper_locks_os_thread_when_fallible() {
let func = make_capsule_func("get_language", true);
let empty: std::collections::HashSet<&str> = std::collections::HashSet::new();
let empty_s: std::collections::HashSet<String> = std::collections::HashSet::new();
let out = gen_capsule_function_wrapper(&func, "krz", &empty, &empty_s, &capsule_cfg(), &empty_s);
let lock_pos = out.find("runtime.LockOSThread()").expect("must lock the OS thread");
let call_pos = out
.find("cLang := C.krz_get_language(")
.expect("must call the FFI symbol");
let last_error_pos = out.find("if err := lastError()").expect("must read lastError()");
assert!(
lock_pos < call_pos && call_pos < last_error_pos,
"expected lock, then call, then lastError() read, got:\n{out}"
);
assert!(
out.contains("defer runtime.UnlockOSThread()"),
"the unlock must be deferred, got:\n{out}"
);
}
#[test]
fn test_convert_with_visitor_wrapper_locks_os_thread_once_around_every_ffi_call() {
let func = FunctionDef {
name: "convert".to_string(),
params: vec![
make_param("html", TypeRef::String),
make_param("options", TypeRef::Named("ConversionOptions".to_string())),
],
return_type: TypeRef::Named("ConversionResult".to_string()),
error_type: Some("ConversionError".to_string()),
..Default::default()
};
let opaque_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
let value_only_types: std::collections::HashSet<String> = std::collections::HashSet::new();
let bridge_cfg = crate::core::config::TraitBridgeConfig {
trait_name: "HtmlVisitor".to_string(),
type_alias: Some("VisitorHandle".to_string()),
param_name: Some("visitor".to_string()),
options_type: Some("ConversionOptions".to_string()),
..Default::default()
};
let reserved_type_names: HashSet<String> = HashSet::new();
let out = gen_convert_with_visitor_wrapper(
&func,
"htm",
&opaque_names,
&value_only_types,
&bridge_cfg,
&reserved_type_names,
);
let signature_pos = out.find("func Convert(").expect("signature must be emitted");
let lock_pos = out.find("runtime.LockOSThread()").expect("must lock the OS thread");
let call_pos = out.find("C.htm_convert(").expect("must call the FFI conversion symbol");
assert!(
signature_pos < lock_pos && lock_pos < call_pos,
"the lock must be established before any of the wrapper's FFI calls, got:\n{out}"
);
assert_eq!(
out.matches("runtime.LockOSThread()").count(),
1,
"one lock must cover every FFI call this wrapper makes, not one per call, got:\n{out}"
);
assert!(
out.contains("defer runtime.UnlockOSThread()"),
"the unlock must be deferred so it covers the final JSON conversion too, got:\n{out}"
);
}