use crate::core::config::{Language, ResolvedCrateConfig};
use crate::core::ir::ApiSurface;
use std::collections::BTreeSet;
use std::path::Path;
pub fn collect_cfg_feature_names(cfg_str: &str, out: &mut BTreeSet<String>) {
let normalized = cfg_str.trim().replace(" (", "(");
let cfg_str = normalized.as_str();
if let Some(feature) = cfg_str.strip_prefix("feature = \"").and_then(|s| s.strip_suffix('"')) {
out.insert(feature.to_string());
return;
}
if let Some(inner) = cfg_str
.strip_prefix("any(")
.and_then(|s| s.strip_suffix(')'))
.or_else(|| cfg_str.strip_prefix("all(").and_then(|s| s.strip_suffix(')')))
{
for cond in parse_cfg_list(inner) {
collect_cfg_feature_names(&cond, out);
}
return;
}
if let Some(inner) = cfg_str.strip_prefix("not(").and_then(|s| s.strip_suffix(')')) {
collect_cfg_feature_names(inner.trim(), out);
}
}
pub fn collect_cfg_features(api: &ApiSurface) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let host_crate = api.crate_name.replace('-', "_");
let is_host = |rust_path: &str| -> bool {
if host_crate.is_empty() {
return true;
}
match rust_path.split("::").next() {
Some(first) if !first.is_empty() => first == host_crate,
_ => true,
}
};
for typ in &api.types {
if !is_host(&typ.rust_path) {
continue;
}
if let Some(cfg) = &typ.cfg {
collect_cfg_feature_names(cfg, &mut out);
}
for field in &typ.fields {
if let Some(cfg) = &field.cfg {
collect_cfg_feature_names(cfg, &mut out);
}
}
for method in &typ.methods {
if let Some(cfg) = &method.cfg {
collect_cfg_feature_names(cfg, &mut out);
}
}
}
for enum_def in &api.enums {
if !is_host(&enum_def.rust_path) {
continue;
}
if let Some(cfg) = &enum_def.cfg {
collect_cfg_feature_names(cfg, &mut out);
}
for variant in &enum_def.variants {
if let Some(cfg) = &variant.cfg {
collect_cfg_feature_names(cfg, &mut out);
}
}
for method in &enum_def.methods {
if let Some(cfg) = &method.cfg {
collect_cfg_feature_names(cfg, &mut out);
}
}
}
for func in &api.functions {
if let Some(cfg) = &func.cfg {
collect_cfg_feature_names(cfg, &mut out);
}
}
for service in &api.services {
if !is_host(&service.rust_path) {
continue;
}
if let Some(cfg) = &service.cfg {
collect_cfg_feature_names(cfg, &mut out);
}
if let Some(cfg) = &service.constructor.cfg {
collect_cfg_feature_names(cfg, &mut out);
}
for configurator in &service.configurators {
if let Some(cfg) = &configurator.cfg {
collect_cfg_feature_names(cfg, &mut out);
}
}
}
out
}
#[must_use]
pub fn undeclared_cfg_features(api: &ApiSurface, declared: &BTreeSet<String>) -> BTreeSet<String> {
collect_cfg_features(api).difference(declared).cloned().collect()
}
#[must_use]
pub fn read_declared_cargo_features(manifest_path: &Path) -> Option<BTreeSet<String>> {
let content = std::fs::read_to_string(manifest_path).ok()?;
let manifest = toml::from_str::<toml::Value>(&content).ok()?;
let features = manifest
.get("features")
.and_then(toml::Value::as_table)
.map(|table| table.keys().cloned().collect())
.unwrap_or_default();
Some(features)
}
#[must_use]
pub fn resolve_against_workspace_root(config: &ResolvedCrateConfig, relative: &Path) -> std::path::PathBuf {
let root = config
.workspace_root
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
root.join(relative)
}
pub fn warn_on_undeclared_binding_cfg_features(api: &ApiSurface, language: Language, manifest_path: &Path) {
let Some(declared) = read_declared_cargo_features(manifest_path) else {
return;
};
let missing = undeclared_cfg_features(api, &declared);
if missing.is_empty() {
return;
}
tracing::warn!(
language = %language,
manifest = %manifest_path.display(),
missing_features = ?missing,
"generated bindings reference #[cfg(feature = \"...\")] gates this crate's own Cargo.toml \
does not declare; the affected definitions (and their registrations) will silently \
compile out of this binding even though the core crate has the feature on -- re-run \
`alef scaffold` to add the missing features to this crate's [features] table"
);
}
pub fn warn_on_ffi_feature_drift(config: &ResolvedCrateConfig, lang: Language) {
if lang == Language::Ffi {
return;
}
let lang_features: BTreeSet<&str> = config.features_for_language(lang).iter().map(String::as_str).collect();
let ffi_features: BTreeSet<&str> = config
.features_for_language(Language::Ffi)
.iter()
.map(String::as_str)
.collect();
if lang_features != ffi_features {
tracing::warn!(
language = %lang,
lang_features = ?lang_features,
ffi_features = ?ffi_features,
"configured feature set for this binding differs from [crates.ffi]'s; cfg-gated FFI \
exports are included/omitted based on this binding's own feature list, but the \
linked native library is built once using the FFI crate's feature list — keep them \
in sync (or set them explicitly to the same value) or generated glue may reference \
symbols the shipped library doesn't export"
);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CfgPredicate {
Feature(String),
All(Vec<CfgPredicate>),
Any(Vec<CfgPredicate>),
Not(Box<CfgPredicate>),
Other,
}
pub fn parse_cfg_predicate(cfg_str: &str) -> CfgPredicate {
let normalized = cfg_str.trim().replace(" (", "(");
let cfg_str = normalized.as_str();
if let Some(feature) = cfg_str.strip_prefix("feature = \"").and_then(|s| s.strip_suffix('"')) {
return CfgPredicate::Feature(feature.to_string());
}
if let Some(inner) = cfg_str.strip_prefix("any(").and_then(|s| s.strip_suffix(')')) {
return CfgPredicate::Any(parse_cfg_list(inner).iter().map(|c| parse_cfg_predicate(c)).collect());
}
if let Some(inner) = cfg_str.strip_prefix("all(").and_then(|s| s.strip_suffix(')')) {
return CfgPredicate::All(parse_cfg_list(inner).iter().map(|c| parse_cfg_predicate(c)).collect());
}
if let Some(inner) = cfg_str.strip_prefix("not(").and_then(|s| s.strip_suffix(')')) {
return CfgPredicate::Not(Box::new(parse_cfg_predicate(inner.trim())));
}
CfgPredicate::Other
}
#[must_use]
pub fn combine_gates(owner_cfg: &str, member_cfg: &str) -> String {
let (owner, member) = (owner_cfg.trim(), member_cfg.trim());
if predicate_implies(&parse_cfg_predicate(member), &parse_cfg_predicate(owner)) {
return member.to_string();
}
format!("all({owner}, {member})")
}
fn predicate_implies(predicate: &CfgPredicate, required: &CfgPredicate) -> bool {
if matches!(required, CfgPredicate::Other) {
return false;
}
if predicate == required {
return true;
}
match predicate {
CfgPredicate::All(arms) => arms.iter().any(|arm| predicate_implies(arm, required)),
_ => false,
}
}
fn parse_cfg_list(s: &str) -> Vec<String> {
let mut result = Vec::new();
let mut depth = 0usize;
let mut current = String::new();
for ch in s.chars() {
match ch {
'(' => {
depth += 1;
current.push(ch);
}
')' => {
depth = depth.saturating_sub(1);
current.push(ch);
}
',' if depth == 0 => {
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
result.push(trimmed);
}
current.clear();
}
_ => current.push(ch),
}
}
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
result.push(trimmed);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ir::{ApiSurface, EnumDef, EnumVariant, TypeDef};
use tracing_test::traced_test;
#[test]
fn combine_gates_drops_an_owner_the_member_already_requires() {
assert_eq!(
combine_gates(
r#"feature = "client""#,
r#"all(feature = "client", feature = "streaming")"#
),
r#"all(feature = "client", feature = "streaming")"#
);
}
#[test]
fn combine_gates_keeps_both_operands_when_the_member_does_not_imply_the_owner() {
assert_eq!(
combine_gates(r#"feature = "client""#, r#"feature = "streaming""#),
r#"all(feature = "client", feature = "streaming")"#
);
}
#[test]
fn combine_gates_does_not_collapse_two_predicates_the_parser_cannot_read() {
assert_eq!(
combine_gates("target_os = \"macos\"", "target_os = \"linux\""),
"all(target_os = \"macos\", target_os = \"linux\")"
);
}
#[test]
fn combine_gates_does_not_treat_a_disjunct_as_implying_the_owner() {
assert_eq!(
combine_gates(
r#"feature = "client""#,
r#"any(feature = "client", feature = "server")"#
),
r#"all(feature = "client", any(feature = "client", feature = "server"))"#
);
}
#[test]
fn collect_cfg_feature_names_simple_feature() {
let mut out = BTreeSet::new();
collect_cfg_feature_names(r#"feature = "pdf""#, &mut out);
assert_eq!(out, BTreeSet::from(["pdf".to_string()]));
}
#[test]
fn collect_cfg_feature_names_any_compound() {
let mut out = BTreeSet::new();
collect_cfg_feature_names(r#"any(feature = "html", feature = "xml")"#, &mut out);
let want: BTreeSet<String> = ["html", "xml"].into_iter().map(String::from).collect();
assert_eq!(out, want);
}
#[test]
fn collect_cfg_feature_names_all_compound() {
let mut out = BTreeSet::new();
collect_cfg_feature_names(
r#"all(feature = "layout-types", not(feature = "wasm-target"))"#,
&mut out,
);
let want: BTreeSet<String> = ["layout-types", "wasm-target"].into_iter().map(String::from).collect();
assert_eq!(out, want);
}
#[test]
fn parse_cfg_predicate_simple_feature() {
assert_eq!(
parse_cfg_predicate(r#"feature = "tokenizer""#),
CfgPredicate::Feature("tokenizer".to_string())
);
}
#[test]
fn parse_cfg_predicate_any_preserves_arms() {
assert_eq!(
parse_cfg_predicate(r#"any(feature = "native-http", feature = "wasm-http")"#),
CfgPredicate::Any(vec![
CfgPredicate::Feature("native-http".to_string()),
CfgPredicate::Feature("wasm-http".to_string()),
])
);
}
#[test]
fn parse_cfg_predicate_all_preserves_arms() {
assert_eq!(
parse_cfg_predicate(r#"all(feature = "layout-types", not(feature = "wasm-target"))"#),
CfgPredicate::All(vec![
CfgPredicate::Feature("layout-types".to_string()),
CfgPredicate::Not(Box::new(CfgPredicate::Feature("wasm-target".to_string()))),
])
);
}
#[test]
fn parse_cfg_predicate_not() {
assert_eq!(
parse_cfg_predicate(r#"not(feature = "wasm-target")"#),
CfgPredicate::Not(Box::new(CfgPredicate::Feature("wasm-target".to_string())))
);
}
#[test]
fn parse_cfg_predicate_unrecognised_is_other() {
assert_eq!(parse_cfg_predicate(r#"target_arch = "wasm32""#), CfgPredicate::Other);
}
#[test]
fn collect_cfg_feature_names_ignores_non_feature_cfg() {
let mut out = BTreeSet::new();
collect_cfg_feature_names(r#"target_arch = "wasm32""#, &mut out);
assert!(out.is_empty());
}
#[test]
fn collect_cfg_feature_names_whitespace_normalisation() {
let mut out = BTreeSet::new();
collect_cfg_feature_names(r#"any (feature = "a" , feature = "b")"#, &mut out);
let want: BTreeSet<String> = ["a", "b"].into_iter().map(String::from).collect();
assert_eq!(out, want);
}
#[test]
fn collect_cfg_features_walks_types_enums_functions() {
let mut out = BTreeSet::new();
collect_cfg_feature_names(r#"feature = "pdf""#, &mut out);
collect_cfg_feature_names(r#"any(feature = "html", feature = "xml")"#, &mut out);
collect_cfg_feature_names(
r#"all(feature = "layout-types", not(feature = "wasm-target"))"#,
&mut out,
);
collect_cfg_feature_names(r#"target_arch = "wasm32""#, &mut out);
let want: BTreeSet<String> = ["html", "layout-types", "pdf", "wasm-target", "xml"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(out, want);
}
#[test]
fn collect_cfg_features_full_surface_walk() {
let api = ApiSurface {
types: vec![TypeDef {
name: "PdfDoc".to_string(),
rust_path: "mylib::PdfDoc".to_string(),
cfg: Some(r#"feature = "pdf""#.to_string()),
..Default::default()
}],
enums: vec![EnumDef {
name: "ImageOutputFormat".to_string(),
variants: vec![
EnumVariant {
name: "Native".to_string(),
cfg: None,
..Default::default()
},
EnumVariant {
name: "Heic".to_string(),
cfg: Some(r#"feature = "heic""#.to_string()),
..Default::default()
},
],
..Default::default()
}],
..Default::default()
};
let features = collect_cfg_features(&api);
let want: BTreeSet<String> = ["heic", "pdf"].into_iter().map(String::from).collect();
assert_eq!(features, want);
}
#[test]
fn collect_cfg_features_includes_method_gates() {
use crate::core::ir::MethodDef;
let api = ApiSurface {
crate_name: "mylib".to_string(),
types: vec![TypeDef {
name: "Client".to_string(),
rust_path: "mylib::Client".to_string(),
methods: vec![
MethodDef {
name: "ping".to_string(),
..Default::default()
},
MethodDef {
name: "stream".to_string(),
cfg: Some(r#"feature = "streaming""#.to_string()),
..Default::default()
},
],
..Default::default()
}],
enums: vec![EnumDef {
name: "Format".to_string(),
rust_path: "mylib::Format".to_string(),
methods: vec![MethodDef {
name: "from_mime".to_string(),
cfg: Some(r#"all(feature = "mime", feature = "sniff")"#.to_string()),
..Default::default()
}],
..Default::default()
}],
..Default::default()
};
let want: BTreeSet<String> = ["mime", "sniff", "streaming"].into_iter().map(String::from).collect();
assert_eq!(collect_cfg_features(&api), want);
}
#[test]
fn collect_cfg_features_includes_service_and_configurator_gates() {
use crate::core::ir::{MethodDef, ServiceDef};
let api = ApiSurface {
crate_name: "mylib".to_string(),
services: vec![ServiceDef {
name: "ClientConfig".to_string(),
rust_path: "mylib::client::ClientConfig".to_string(),
constructor: MethodDef {
name: "new".to_string(),
..Default::default()
},
configurators: vec![
MethodDef {
name: "with_timeout".to_string(),
..Default::default()
},
MethodDef {
name: "with_tower_layer".to_string(),
cfg: Some(r#"feature = "tower""#.to_string()),
..Default::default()
},
],
registrations: vec![],
entrypoints: vec![],
doc: String::new(),
cfg: None,
}],
..Default::default()
};
let want: BTreeSet<String> = ["tower".to_string()].into_iter().collect();
assert_eq!(collect_cfg_features(&api), want);
}
#[test]
fn collect_cfg_features_excludes_external_source_crate_service_cfgs() {
use crate::core::ir::{MethodDef, ServiceDef};
let api = ApiSurface {
crate_name: "hostlib".to_string(),
services: vec![ServiceDef {
name: "OtherService".to_string(),
rust_path: "otherlib::OtherService".to_string(),
constructor: MethodDef::default(),
configurators: vec![],
registrations: vec![],
entrypoints: vec![],
doc: String::new(),
cfg: Some(r#"feature = "foreign-only""#.to_string()),
}],
..Default::default()
};
assert!(
collect_cfg_features(&api).is_empty(),
"a foreign-owned service's cfg must not forward to the host crate"
);
}
#[test]
fn collect_cfg_features_excludes_external_source_crate_cfgs() {
let api = ApiSurface {
crate_name: "hostlib".to_string(),
types: vec![TypeDef {
name: "HostDoc".to_string(),
rust_path: "hostlib::HostDoc".to_string(),
cfg: Some(r#"feature = "pdf""#.to_string()),
..Default::default()
}],
enums: vec![EnumDef {
name: "Strategy".to_string(),
rust_path: "otherlib::Strategy".to_string(),
variants: vec![
EnumVariant {
name: "Auto".to_string(),
cfg: None,
..Default::default()
},
EnumVariant {
name: "Advanced".to_string(),
cfg: Some(r#"any(test, feature = "foreign-only")"#.to_string()),
..Default::default()
},
],
..Default::default()
}],
..Default::default()
};
let features = collect_cfg_features(&api);
assert_eq!(
features,
BTreeSet::from(["pdf".to_string()]),
"host `pdf` must forward; the foreign `foreign-only` feature must not leak into host passthrough"
);
}
fn api_with_gated_functions(names_and_cfgs: &[(&str, Option<&str>)]) -> ApiSurface {
use crate::core::ir::FunctionDef;
ApiSurface {
crate_name: "test_lib".to_string(),
functions: names_and_cfgs
.iter()
.map(|(name, cfg)| FunctionDef {
name: (*name).to_string(),
rust_path: format!("test_lib::{name}"),
cfg: cfg.map(|s| s.to_string()),
..Default::default()
})
.collect(),
..Default::default()
}
}
#[test]
fn undeclared_cfg_features_returns_only_missing_names() {
let api = api_with_gated_functions(&[
("count_tokens", Some(r#"feature = "tokenizer""#)),
("with_tower_layer", Some(r#"feature = "tower""#)),
("completion_cost", None),
]);
let declared: BTreeSet<String> = ["tower".to_string()].into_iter().collect();
assert_eq!(
undeclared_cfg_features(&api, &declared),
BTreeSet::from(["tokenizer".to_string()]),
"only the feature missing from `declared` should be reported"
);
}
#[test]
fn undeclared_cfg_features_empty_when_everything_declared() {
let api = api_with_gated_functions(&[("count_tokens", Some(r#"feature = "tokenizer""#))]);
let declared: BTreeSet<String> = ["tokenizer".to_string()].into_iter().collect();
assert!(undeclared_cfg_features(&api, &declared).is_empty());
}
#[test]
fn read_declared_cargo_features_none_when_file_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let manifest = dir.path().join("Cargo.toml");
assert!(
read_declared_cargo_features(&manifest).is_none(),
"an unscaffolded crate has nothing to verify against yet"
);
}
#[test]
fn read_declared_cargo_features_reads_features_table() {
let dir = tempfile::tempdir().expect("tempdir");
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"x\"\n\n[features]\ndefault = [\"native-http\"]\nnative-http = [\"core/native-http\"]\n",
)
.expect("write manifest");
assert_eq!(
read_declared_cargo_features(&manifest),
Some(BTreeSet::from(["default".to_string(), "native-http".to_string()]))
);
}
#[test]
fn read_declared_cargo_features_empty_set_when_no_features_table() {
let dir = tempfile::tempdir().expect("tempdir");
let manifest = dir.path().join("Cargo.toml");
std::fs::write(&manifest, "[package]\nname = \"x\"\n").expect("write manifest");
assert_eq!(
read_declared_cargo_features(&manifest),
Some(BTreeSet::new()),
"a manifest scaffolded before any cfg-gated symbol existed has no [features] table at \
all, which must still be distinguishable from a missing file"
);
}
#[traced_test]
#[test]
fn warn_on_undeclared_binding_cfg_features_warns_on_stale_manifest() {
let dir = tempfile::tempdir().expect("tempdir");
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"x\"\n\n[features]\ndefault = [\"native-http\"]\nnative-http = [\"core/native-http\"]\n",
)
.expect("write manifest");
let api = api_with_gated_functions(&[("count_tokens", Some(r#"feature = "tokenizer""#))]);
warn_on_undeclared_binding_cfg_features(&api, Language::Ruby, &manifest);
assert!(
logs_contain("does not declare"),
"a stale manifest missing a referenced feature must produce a warning"
);
}
#[traced_test]
#[test]
fn warn_on_undeclared_binding_cfg_features_silent_when_manifest_declares_everything() {
let dir = tempfile::tempdir().expect("tempdir");
let manifest = dir.path().join("Cargo.toml");
std::fs::write(
&manifest,
"[package]\nname = \"x\"\n\n[features]\ndefault = [\"tokenizer\"]\ntokenizer = [\"core/tokenizer\"]\n",
)
.expect("write manifest");
let api = api_with_gated_functions(&[("count_tokens", Some(r#"feature = "tokenizer""#))]);
warn_on_undeclared_binding_cfg_features(&api, Language::Ruby, &manifest);
assert!(
!logs_contain("does not declare"),
"a fully up-to-date manifest must not warn"
);
}
#[traced_test]
#[test]
fn warn_on_undeclared_binding_cfg_features_silent_when_manifest_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let manifest = dir.path().join("Cargo.toml");
let api = api_with_gated_functions(&[("count_tokens", Some(r#"feature = "tokenizer""#))]);
warn_on_undeclared_binding_cfg_features(&api, Language::Ruby, &manifest);
assert!(
!logs_contain("does not declare"),
"an unscaffolded crate (no manifest on disk yet) must not warn -- there is nothing to \
verify against"
);
}
}