use crate::core::backend::GeneratedFile;
use crate::core::config::ResolvedCrateConfig;
use crate::core::hash::{self, CommentStyle};
use crate::e2e::config::{CallConfig, E2eConfig};
use crate::e2e::escape::{escape_c, sanitize_filename};
use crate::e2e::field_access::FieldResolver;
use crate::e2e::fixture::{Fixture, FixtureGroup};
use anyhow::Result;
use heck::{ToPascalCase, ToSnakeCase};
use std::collections::{HashMap, HashSet};
use std::fmt::Write as FmtWrite;
use std::path::PathBuf;
use super::E2eCodegen;
pub struct CCodegen;
fn is_primitive_c_type(t: &str) -> bool {
matches!(
t,
"uint8_t"
| "uint16_t"
| "uint32_t"
| "uint64_t"
| "int8_t"
| "int16_t"
| "int32_t"
| "int64_t"
| "uintptr_t"
| "intptr_t"
| "size_t"
| "ssize_t"
| "double"
| "float"
| "bool"
| "int"
)
}
fn is_skipped_c_field(fields_c_types: &HashMap<String, String>, parent_snake: &str, field_snake: &str) -> bool {
let key = format!("{parent_snake}.{field_snake}");
fields_c_types.get(&key).is_some_and(|t| t == "skip")
}
fn infer_opaque_handle_type(
fields_c_types: &HashMap<String, String>,
parent_snake_type: &str,
field_snake: &str,
) -> Option<String> {
let lookup_key = format!("{parent_snake_type}.{field_snake}");
if let Some(t) = fields_c_types.get(&lookup_key) {
if !is_primitive_c_type(t) && t != "char*" {
return Some(t.clone());
}
return None;
}
let nested_prefix = format!("{field_snake}.");
if fields_c_types.keys().any(|k| k.starts_with(&nested_prefix)) {
return Some(field_snake.to_pascal_case());
}
None
}
#[allow(clippy::too_many_arguments)]
fn try_emit_enum_accessor(
out: &mut String,
prefix: &str,
prefix_upper: &str,
raw_field: &str,
resolved_field: &str,
parent_snake_type: &str,
accessor_fn: &str,
parent_handle: &str,
local_var: &str,
fields_c_types: &HashMap<String, String>,
fields_enum: &HashSet<String>,
intermediate_handles: &mut Vec<(String, String)>,
) -> bool {
if !(fields_enum.contains(raw_field) || fields_enum.contains(resolved_field)) {
return false;
}
let lookup_key = format!("{parent_snake_type}.{resolved_field}");
let Some(enum_pascal) = fields_c_types.get(&lookup_key) else {
return false;
};
if is_primitive_c_type(enum_pascal) || enum_pascal == "char*" {
return false;
}
let enum_snake = enum_pascal.to_snake_case();
let handle_var = format!("{local_var}_handle");
let _ = writeln!(
out,
" {prefix_upper}{enum_pascal}* {handle_var} = {accessor_fn}({parent_handle});"
);
let _ = writeln!(out, " assert({handle_var} != NULL);");
let _ = writeln!(
out,
" char* {local_var} = {prefix}_{enum_snake}_to_string({handle_var});"
);
intermediate_handles.push((handle_var, enum_snake));
true
}
impl E2eCodegen for CCodegen {
fn generate(
&self,
groups: &[FixtureGroup],
e2e_config: &E2eConfig,
config: &ResolvedCrateConfig,
type_defs: &[crate::core::ir::TypeDef],
_enums: &[crate::core::ir::EnumDef],
) -> Result<Vec<GeneratedFile>> {
let lang = self.language_name();
let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
let mut files = Vec::new();
let call = &e2e_config.call;
let overrides = call.overrides.get(lang);
let result_var = &call.result_var;
let prefix = overrides
.and_then(|o| o.prefix.as_ref())
.cloned()
.or_else(|| config.ffi.as_ref().and_then(|ffi| ffi.prefix.as_ref()).cloned())
.unwrap_or_default();
let header = overrides
.and_then(|o| o.header.as_ref())
.cloned()
.unwrap_or_else(|| config.ffi_header_name());
let c_pkg = e2e_config.resolve_package("c");
let lib_name = config.ffi_lib_name();
let ffi_pkg_name = c_pkg
.as_ref()
.and_then(|p| p.name.as_ref())
.cloned()
.unwrap_or_else(|| {
format!("{}-ffi", config.name)
});
let active_groups: Vec<(&FixtureGroup, Vec<&Fixture>)> = groups
.iter()
.filter_map(|group| {
let active: Vec<&Fixture> = group
.fixtures
.iter()
.filter(|f| super::should_include_fixture(f, lang, e2e_config))
.filter(|f| f.visitor.is_none())
.collect();
if active.is_empty() { None } else { Some((group, active)) }
})
.collect();
let visitor_fixtures: Vec<&Fixture> = groups
.iter()
.flat_map(|group| group.fixtures.iter())
.filter(|f| super::should_include_fixture(f, lang, e2e_config))
.filter(|f| f.visitor.is_some())
.filter(|f| c_visitor_fixture_has_typed_call(f, e2e_config))
.collect();
let ffi_crate_path = c_pkg
.as_ref()
.and_then(|p| p.path.as_ref())
.cloned()
.unwrap_or_else(|| config.ffi_crate_path());
let mut category_names: Vec<String> = active_groups
.iter()
.map(|(g, _)| sanitize_filename(&g.category))
.collect();
if !visitor_fixtures.is_empty() {
category_names.push("visitor".to_string());
}
let needs_mock_server = active_groups
.iter()
.flat_map(|(_, fixtures)| fixtures.iter())
.any(|f| f.needs_mock_server());
files.push(GeneratedFile {
path: output_base.join("Makefile"),
content: render_makefile(&category_names, &header, &ffi_crate_path, &lib_name, needs_mock_server),
generated_header: true,
});
let github_repo = config.github_repo();
let version = config.resolved_version().unwrap_or_else(|| "0.0.0".to_string());
files.push(GeneratedFile {
path: output_base.join("download_ffi.sh"),
content: render_download_script(&github_repo, &version, &ffi_pkg_name),
generated_header: true,
});
files.push(GeneratedFile {
path: output_base.join("test_runner.h"),
content: render_test_runner_header(&active_groups, &visitor_fixtures),
generated_header: true,
});
files.push(GeneratedFile {
path: output_base.join("main.c"),
content: render_main_c(&active_groups, &visitor_fixtures, &e2e_config.env),
generated_header: true,
});
files.push(GeneratedFile {
path: output_base.join(".gitignore"),
content: render_gitignore(),
generated_header: false,
});
let field_resolver = FieldResolver::new(
&e2e_config.fields,
&e2e_config.fields_optional,
&e2e_config.result_fields,
&e2e_config.fields_array,
&std::collections::HashSet::new(),
);
for (group, active) in &active_groups {
let filename = format!("test_{}.c", sanitize_filename(&group.category));
let content = render_test_file(
&group.category,
active,
&header,
&prefix,
result_var,
e2e_config,
lang,
&field_resolver,
config,
type_defs,
);
files.push(GeneratedFile {
path: output_base.join(filename),
content,
generated_header: true,
});
}
if !visitor_fixtures.is_empty() {
files.push(GeneratedFile {
path: output_base.join("test_visitor.c"),
content: render_visitor_test_file(&visitor_fixtures, &header, &prefix, e2e_config, config),
generated_header: true,
});
}
Ok(files)
}
fn language_name(&self) -> &'static str {
"c"
}
}
struct ResolvedCallInfo {
function_name: String,
result_type_name: String,
options_type_name: String,
client_factory: Option<String>,
args: Vec<crate::e2e::config::ArgMapping>,
raw_c_result_type: Option<String>,
c_free_fn: Option<String>,
c_engine_factory: Option<String>,
result_is_option: bool,
result_is_bytes: bool,
streaming: Option<bool>,
extra_args: Vec<String>,
}
fn resolve_call_info(call: &CallConfig, lang: &str) -> ResolvedCallInfo {
let overrides = call.overrides.get(lang);
let function_name = overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call.function.clone());
let result_type_name = overrides
.and_then(|o| o.result_type.as_ref())
.cloned()
.unwrap_or_else(|| call.function.to_pascal_case());
let options_type_name = overrides
.and_then(|o| o.options_type.as_deref())
.or(call.options_type.as_deref())
.unwrap_or_default()
.to_string();
let client_factory = overrides.and_then(|o| o.client_factory.as_ref()).cloned();
let raw_c_result_type = overrides.and_then(|o| o.raw_c_result_type.clone());
let c_free_fn = overrides.and_then(|o| o.c_free_fn.clone());
let c_engine_factory = overrides.and_then(|o| o.c_engine_factory.clone());
let result_is_option = overrides
.and_then(|o| if o.result_is_option { Some(true) } else { None })
.unwrap_or(call.result_is_option);
let result_is_bytes = call.result_is_bytes || overrides.is_some_and(|o| o.result_is_bytes);
let extra_args = overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
ResolvedCallInfo {
function_name,
result_type_name,
options_type_name,
client_factory,
args: call.args.clone(),
raw_c_result_type,
c_free_fn,
c_engine_factory,
result_is_option,
result_is_bytes,
streaming: call.streaming_enabled(),
extra_args,
}
}
fn resolve_fixture_call_info(fixture: &Fixture, e2e_config: &E2eConfig, lang: &str) -> ResolvedCallInfo {
let call = e2e_config.resolve_call_for_fixture(
fixture.call.as_deref(),
&fixture.id,
&fixture.resolved_category(),
&fixture.tags,
&fixture.input,
);
let mut info = resolve_call_info(call, lang);
let default_overrides = e2e_config.call.overrides.get(lang);
if info.client_factory.is_none() {
if let Some(factory) = default_overrides.and_then(|o| o.client_factory.as_ref()) {
info.client_factory = Some(factory.clone());
}
}
if info.c_engine_factory.is_none() {
if let Some(factory) = default_overrides.and_then(|o| o.c_engine_factory.as_ref()) {
info.c_engine_factory = Some(factory.clone());
}
}
info
}
fn c_visitor_fixture_has_typed_call(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
let call = e2e_config.resolve_call_for_fixture(
fixture.call.as_deref(),
&fixture.id,
&fixture.resolved_category(),
&fixture.tags,
&fixture.input,
);
let info = resolve_call_info(call, "c");
let has_function = call
.overrides
.get("c")
.and_then(|override_config| override_config.function.as_deref())
.is_some_and(|function| !function.is_empty());
has_function && !info.options_type_name.is_empty()
}
mod assertions;
mod call_patterns;
mod project;
mod runner;
mod streaming;
mod test_function;
mod visitor;
use assertions::{build_args_string_c, emit_nested_accessor, render_assertion};
use call_patterns::{render_bytes_test_function, render_engine_factory_test_function};
use project::{render_download_script, render_gitignore, render_makefile};
use runner::{render_main_c, render_test_runner_header};
use streaming::{
render_c_diagnostic_skip, render_streaming_test_function, resolve_c_client_owner_type, resolve_c_streaming_adapter,
};
use test_function::render_test_function;
use visitor::render_visitor_test_file;
#[allow(clippy::too_many_arguments)]
fn render_test_file(
category: &str,
fixtures: &[&Fixture],
header: &str,
prefix: &str,
result_var: &str,
e2e_config: &E2eConfig,
lang: &str,
field_resolver: &FieldResolver,
config: &ResolvedCrateConfig,
type_defs: &[crate::core::ir::TypeDef],
) -> String {
let mut out = String::new();
out.push_str(&hash::header(CommentStyle::Block));
let _ = writeln!(out, "/* E2e tests for category: {category} */");
let _ = writeln!(out);
let _ = writeln!(out, "#include <assert.h>");
let _ = writeln!(out, "#include <stdint.h>");
let _ = writeln!(out, "#include <string.h>");
let _ = writeln!(out, "#include <stdio.h>");
let _ = writeln!(out, "#include <stdlib.h>");
let _ = writeln!(out, "#include \"{header}\"");
let _ = writeln!(out, "#include \"test_runner.h\"");
let _ = writeln!(out);
for (i, fixture) in fixtures.iter().enumerate() {
if fixture.visitor.is_some() {
panic!(
"C e2e generator: visitor pattern not supported for fixture: {}",
fixture.id
);
}
let call_info = resolve_fixture_call_info(fixture, e2e_config, lang);
let mut effective_fields_enum = e2e_config.fields_enum.clone();
let fixture_call = e2e_config.resolve_call_for_fixture(
fixture.call.as_deref(),
&fixture.id,
&fixture.resolved_category(),
&fixture.tags,
&fixture.input,
);
if let Some(co) = fixture_call.overrides.get(lang) {
for k in co.enum_fields.keys() {
effective_fields_enum.insert(k.clone());
}
}
let per_call_field_resolver = FieldResolver::new(
e2e_config.effective_fields(fixture_call),
e2e_config.effective_fields_optional(fixture_call),
e2e_config.effective_result_fields(fixture_call),
e2e_config.effective_fields_array(fixture_call),
&std::collections::HashSet::new(),
);
let _ = field_resolver; let field_resolver = &per_call_field_resolver;
render_test_function(
&mut out,
fixture,
prefix,
&call_info.function_name,
result_var,
&call_info.args,
field_resolver,
&e2e_config.fields_c_types,
&effective_fields_enum,
&call_info.result_type_name,
&call_info.options_type_name,
call_info.client_factory.as_deref(),
call_info.raw_c_result_type.as_deref(),
call_info.c_free_fn.as_deref(),
call_info.c_engine_factory.as_deref(),
call_info.result_is_option,
call_info.result_is_bytes,
call_info.streaming,
&call_info.extra_args,
config,
type_defs,
);
if i + 1 < fixtures.len() {
let _ = writeln!(out);
}
}
out
}
#[allow(clippy::too_many_arguments)]
fn json_to_c(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => format!("\"{}\"", escape_c(s)),
serde_json::Value::Bool(true) => "1".to_string(),
serde_json::Value::Bool(false) => "0".to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Null => "NULL".to_string(),
other => format!("\"{}\"", escape_c(&other.to_string())),
}
}
pub fn emit_test_backend(
_trait_bridge: &crate::core::config::TraitBridgeConfig,
_methods: &[&crate::core::ir::MethodDef],
_fixture: &crate::e2e::fixture::Fixture,
) -> super::TestBackendEmission {
super::TestBackendEmission::unimplemented("c")
}