Files
improvise/crates/zset-ffi/tests/header.rs
T

279 lines
8.7 KiB
Rust

//! The checked-in C header matches the exported functions, their parameter and return
//! types, and the constants. Evidence: `context/test-evidence/zset-ffi.md`.
use std::collections::BTreeMap;
use zset_ffi::ZS_ABI_VERSION;
use zset_ffi::engine::Code;
const SRC: &str = include_str!("../src/lib.rs");
const HEADER: &str = include_str!("../include/zset_ffi.h");
fn squash(s: &str) -> String {
s.split_whitespace().collect()
}
/// Splits at the commas that are not inside brackets.
fn split_params(params: &str) -> Vec<String> {
let (mut depth, mut cur, mut out) = (0i32, String::new(), Vec::new());
for c in params.chars() {
match c {
'(' | '<' => depth += 1,
')' | '>' => depth -= 1,
',' if depth == 0 => {
out.push(std::mem::take(&mut cur));
continue;
}
_ => {}
}
cur.push(c);
}
if !cur.trim().is_empty() {
out.push(cur);
}
out
}
/// The C type a Rust ABI type must be declared as. Anything unlisted fails the test, so a
/// new type needs a line here and a conscious choice.
fn c_type(rust: &str) -> String {
let t = squash(rust);
let c = match t.as_str() {
"*mut*mutZsZSet" => "ZsZSet **",
"*mutZsZSet" => "ZsZSet *",
"*constZsZSet" => "const ZsZSet *",
"*mutZsCircuit" => "ZsCircuit *",
"*mut*mutZsCircuit" => "ZsCircuit **",
"*mutZsRebuild" => "ZsRebuild *",
"*mut*mutZsRebuild" => "ZsRebuild **",
"*constu8" => "const uint8_t *",
"*mutu8" => "uint8_t *",
"*mutc_void" => "void *",
"*mutu32" => "uint32_t *",
"*muti64" => "int64_t *",
"*mutusize" => "size_t *",
"*muti32" => "int32_t *",
"*constc_char" => "const char *",
"usize" => "size_t",
"i64" => "int64_t",
"u32" => "uint32_t",
"i32" => "int32_t",
"Option<KeepFn>" => "ZsKeepFn",
"Option<FlatMapFn>" => "ZsFlatMapFn",
"Option<ExpiredFn>" => "ZsExpiredFn",
"Emit" => "ZsEmit",
other => panic!("no C type is declared for the Rust type `{other}`"),
};
squash(c)
}
/// `name -> (return type, parameter types)` from the Rust source.
fn exported() -> BTreeMap<String, (String, Vec<String>)> {
let mut out = BTreeMap::new();
let fns = SRC
.split("extern \"C\" fn ")
.skip(1)
.filter(|p| !p.starts_with('$'));
for part in fns {
let name = part.split('(').next().unwrap().to_string();
let rest = &part[name.len() + 1..];
let mut depth = 1;
let end = rest
.char_indices()
.find(|(_, c)| {
depth += i32::from(*c == '(') - i32::from(*c == ')');
depth == 0
})
.unwrap()
.0;
let params: Vec<String> = split_params(&rest[..end])
.iter()
.map(|p| c_type(p.split_once(':').unwrap().1))
.collect();
let ret = rest[end + 1..].split('{').next().unwrap().trim();
let ret = match ret.strip_prefix("->") {
Some(r) => c_type(r),
None => "void".to_string(),
};
out.insert(name, (ret, params));
}
// The `unary_op!` expansions share the macro body's signature.
let body = &SRC[SRC.find("macro_rules! unary_op").unwrap()..];
let sig = body.split("extern \"C\" fn $name(").nth(1).unwrap();
let (params, tail) = sig.split_once(')').unwrap();
let unary_params: Vec<String> = split_params(params)
.iter()
.map(|p| c_type(p.split_once(':').unwrap().1))
.collect();
let unary_ret = c_type(
tail.split('{')
.next()
.unwrap()
.trim()
.trim_start_matches("->"),
);
for line in SRC.lines().filter(|l| l.starts_with("unary_op!(")) {
let name = line["unary_op!(".len()..].split(',').next().unwrap();
out.insert(name.to_string(), (unary_ret.clone(), unary_params.clone()));
}
out
}
fn strip_comments(text: &str) -> String {
let mut flat = text.replace('\n', " ");
while let Some(open) = flat.find("/*") {
let close = flat[open..].find("*/").expect("an unterminated comment") + open + 2;
flat.replace_range(open..close, " ");
}
flat
}
/// A header parameter without its name: `const ZsZSet *z` is `const ZsZSet *`.
fn param_type(p: &str) -> String {
let p = p.trim();
let name_start = p
.rfind(|c: char| !(c.is_alphanumeric() || c == '_'))
.map_or(0, |i| i + 1);
if name_start == 0 {
return squash(p);
}
squash(&p[..name_start])
}
fn declared() -> BTreeMap<String, (String, Vec<String>)> {
let mut out = BTreeMap::new();
for decl in strip_comments(HEADER).split(';') {
let Some(open) = decl.find('(') else { continue };
let before = &decl[..open];
let name_at = before
.rfind(|c: char| !(c.is_alphanumeric() || c == '_'))
.map_or(0, |i| i + 1);
let name = &before[name_at..];
if !name.starts_with("zs_") || before.contains("typedef") || before.contains("(*") {
continue;
}
let ret = squash(&before[..name_at]);
let params = decl[open + 1..].rsplit_once(')').unwrap().0;
let params = if params.trim() == "void" {
Vec::new()
} else {
split_params(params).iter().map(|p| param_type(p)).collect()
};
out.insert(name.to_string(), (ret, params));
}
out
}
fn defines() -> BTreeMap<String, i64> {
HEADER
.lines()
.filter_map(|l| l.strip_prefix("#define "))
.filter_map(|l| {
let (k, v) = l.split_once(' ')?;
Some((k.to_string(), v.trim().parse().ok()?))
})
.collect()
}
#[test]
fn the_header_declares_exactly_the_exported_functions_with_their_types() {
let (src, hdr) = (exported(), declared());
assert!(
src.len() > 30,
"the scanner saw {} functions, it is not reading the source",
src.len()
);
assert_eq!(src, hdr);
}
#[test]
fn the_header_constants_are_the_rust_constants() {
let d = defines();
for code in Code::ALL {
assert_eq!(
d[code.header_name()],
*code as i64,
"{}",
code.header_name()
);
}
let err_lines = d.keys().filter(|k| k.starts_with("ZS_ERR_")).count();
assert_eq!(
err_lines,
Code::ALL.len(),
"a header code without a Rust variant"
);
assert_eq!(d["ZS_ABI_VERSION"], i64::from(ZS_ABI_VERSION));
assert_eq!(d["ZS_OK"], 0);
assert_eq!(
[
d["ZS_AGG_COUNT"],
d["ZS_AGG_SUM"],
d["ZS_AGG_MIN"],
d["ZS_AGG_MAX"]
],
[0, 1, 2, 3]
);
}
/// `typedef R (*Name)(params)` from the header, and `pub type X = extern "C" fn(..) -> R` from
/// `engine.rs`, as `name -> (return, params)`.
type Sigs = BTreeMap<String, (String, Vec<String>)>;
fn callback_types() -> (Sigs, Sigs) {
let mut hdr = BTreeMap::new();
for decl in strip_comments(HEADER).split(';') {
let Some(at) = decl.find("(*") else { continue };
if !decl.contains("typedef") {
continue;
}
let ret = squash(
decl.split("typedef")
.nth(1)
.unwrap()
.split('(')
.next()
.unwrap(),
);
let rest = &decl[at + 2..];
let (name, after) = rest.split_once(')').unwrap();
let params = after
.trim()
.trim_start_matches('(')
.rsplit_once(')')
.unwrap()
.0;
let params = split_params(params).iter().map(|p| param_type(p)).collect();
hdr.insert(name.trim().to_string(), (ret, params));
}
let engine = include_str!("../src/engine.rs");
let mut rust = BTreeMap::new();
for line in engine.lines().filter(|l| l.contains("= extern \"C\" fn(")) {
let name = line
.trim_start_matches("pub type ")
.split(' ')
.next()
.unwrap();
let sig = line.split("fn(").nth(1).unwrap();
let (params, tail) = sig.rsplit_once(')').unwrap();
let ret = match tail.trim().trim_end_matches(';').trim().strip_prefix("->") {
Some(r) => c_type(r),
None => "void".to_string(),
};
let params = split_params(params).iter().map(|p| c_type(p)).collect();
rust.insert(format!("Zs{name}"), (ret, params));
}
(hdr, rust)
}
#[test]
fn the_header_callback_typedefs_match_the_rust_callback_types() {
let (hdr, rust) = callback_types();
assert_eq!(
rust.len(),
4,
"the scanner saw {} callback types",
rust.len()
);
assert_eq!(rust, hdr);
}