mod manifest;
use std::{fs, path::Path};
use featurecomb_schema as schema;
use proc_macro::TokenStream;
use quote::quote;
use crate::manifest::Manifest;
const MANIFEST_FILE_NAME: &str = "Cargo.toml";
#[expect(clippy::missing_panics_doc)]
#[proc_macro_attribute]
pub fn comb(_attr: TokenStream, _item: TokenStream) -> TokenStream {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let manifest_path = Path::new(&manifest_dir).join(MANIFEST_FILE_NAME);
let manifest =
fs::read_to_string(manifest_path).expect("the crate manifest should not disappear");
let checks = generate_checks_from_manifest(&manifest);
TokenStream::from(quote! { #(#checks)* })
}
fn generate_checks_from_manifest(manifest: &str) -> Vec<proc_macro2::TokenStream> {
let manifest = match Manifest::<schema::Metadata>::from_str(manifest) {
Ok(manifest) => manifest,
Err(manifest::Error::Parse(err)) => {
panic!("the crate manifest `metadata.features-group` subtable should conform to the expected schema\n{err}");
}
};
let feature_groups = get_feature_groups(&manifest);
let Some(feature_groups) = feature_groups else {
return Vec::new();
};
check_feature_existence(manifest.features.as_ref(), feature_groups);
let mut checks = Vec::new();
if let Some(feature_table) = &feature_groups.features {
for (feature_name, feature_relations) in feature_table.iter() {
checks.extend(generate_feature_relation_checks(
feature_groups,
feature_name,
feature_relations,
));
}
}
for (feature_group_name, feature_group) in &feature_groups.groups {
checks.extend(generate_group_checks(
feature_groups,
feature_group_name,
feature_group,
));
}
checks
}
fn check_feature_existence(
feature_set: Option<&manifest::FeatureSet>,
feature_groups: &schema::FeatureGroups,
) {
use schema::FeatureRelations;
let Some(feature_set) = feature_set else {
return;
};
for (feature_group_name, feature_group) in &feature_groups.groups {
if let Some(features) = feature_group.features() {
let undefined_feature = features
.iter()
.find(|f| !is_feature_defined(feature_set, f));
if let Some(undefined_feature) = undefined_feature {
panic!(
"feature `{}` referenced in feature group `{}` is not defined",
undefined_feature.name(),
feature_group_name.name(),
);
}
}
}
let Some(feature_table) = &feature_groups.features else {
return;
};
for (feature_name, feature_relations) in feature_table.iter() {
assert!(
is_feature_defined(feature_set, feature_name),
"feature `{}` referenced in `feature-groups.features` is not defined",
feature_name.name(),
);
match feature_relations {
FeatureRelations::Requires {
features: Some(features),
..
} => {
for f in features {
assert!(
is_feature_defined(feature_set, f),
"feature `{}` referenced in `feature-groups.features.{}.requires` is not defined",
f.name(),
feature_name.name(),
);
}
}
FeatureRelations::Requires { features: None, .. } => {}
}
}
}
fn is_feature_defined(
feature_set: &manifest::FeatureSet,
feature_name: &schema::FeatureName,
) -> bool {
feature_set.contains_key(feature_name.name())
}
fn generate_feature_relation_checks(
feature_groups: &schema::FeatureGroups,
feature_name: &schema::FeatureName,
feature_relations: &schema::FeatureRelations,
) -> Vec<proc_macro2::TokenStream> {
use schema::FeatureRelations;
let mut checks = Vec::new();
if let FeatureRelations::Requires {
groups: Some(required_groups),
..
} = feature_relations
{
for required_group in required_groups {
let required_features = feature_groups
.features_in_group(required_group)
.collect::<Vec<_>>();
let required_group = required_group.name();
let candidates = format_candidate_features(required_features.iter().copied());
let error_message = format!(
"feature `{}` requires that some features from feature group `{required_group}` be enabled.
Candidate features: {candidates}",
feature_name.name(),
);
let feature = feature_name.name();
let required_features = required_features.into_iter().map(schema::FeatureName::name);
let check = quote! {
#[cfg(all(feature = #feature, not(any(#(feature = #required_features),*))))]
compile_error!(#error_message);
};
checks.push(check);
}
}
if let FeatureRelations::Requires {
features: Some(required_features),
..
} = feature_relations
{
for required_feature in required_features {
let error_message = format!(
"feature `{}` requires that feature `{}` be enabled as well",
feature_name.name(),
required_feature.name(),
);
let feature = feature_name.name();
let required_feature = required_feature.name();
let check = quote! {
#[cfg(all(feature = #feature, not(feature = #required_feature)))]
compile_error!(#error_message);
};
checks.push(check);
}
}
checks
}
fn format_candidate_features<'a>(
features: impl Iterator<Item = &'a schema::FeatureName>,
) -> String {
let candidates = features
.map(|f| format!("`{}`", f.name()))
.collect::<Vec<_>>();
candidates.join(", ")
}
fn generate_group_checks(
feature_groups: &schema::FeatureGroups,
feature_group_name: &schema::FeatureGroupName,
feature_group: &schema::FeatureGroup,
) -> Vec<proc_macro2::TokenStream> {
use schema::FeatureGroup;
let features = feature_groups
.features_in_group(feature_group_name)
.collect::<Vec<_>>();
if features.len() <= 1 {
return Vec::new();
}
let mut checks = Vec::new();
match feature_group {
FeatureGroup::ExactlyOne { .. } | FeatureGroup::Xor { .. } => {
let mut i = 0;
for feature_a in &features {
for feature_b in features.iter().skip(i) {
if feature_a == feature_b {
continue;
}
let feature_a = feature_a.name();
let feature_b = feature_b.name();
let error_message = format!(
"features `{feature_a}` and `{feature_b}` are part of the `{}` feature group and cannot be enabled at the same time",
feature_group_name.name(),
);
let check = quote! {
#[cfg(all(feature = #feature_a, feature = #feature_b))]
compile_error!(#error_message);
};
checks.push(check);
i += 1;
}
}
}
FeatureGroup::Or { .. } => {
}
}
if matches!(feature_group, FeatureGroup::ExactlyOne { .. }) {
let candidates = format_candidate_features(features.iter().copied());
let features = features.into_iter().map(schema::FeatureName::name);
let error_message = format!(
"feature group `{}` requires that one of its features be enabled.
Candidates: {candidates}",
feature_group_name.name(),
);
let check = quote! {
#[cfg(not(any(#(feature = #features),*)))]
compile_error!(#error_message);
};
checks.push(check);
}
checks
}
fn get_feature_groups(manifest: &Manifest<schema::Metadata>) -> Option<&schema::FeatureGroups> {
manifest
.package
.as_ref()?
.metadata
.as_ref()?
.feature_groups
.as_ref()
}
#[cfg(test)]
mod tests {
use dir_test::{dir_test, Fixture};
use insta::assert_snapshot;
use super::*;
#[dir_test(
dir: "$CARGO_MANIFEST_DIR/tests/fixtures",
glob: "**/*.toml",
)]
#[expect(clippy::needless_pass_by_value)]
fn manifest(fixture: Fixture<&str>) {
let fixture_toml = fixture.content();
let fixture_name = Path::new(fixture.path())
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let checks = generate_checks_from_manifest(fixture_toml);
let rust = checks
.iter()
.map(|s| prettyplease::unparse(&syn::parse_file(&s.to_string()).unwrap()))
.collect::<Vec<_>>();
let rust = rust.join("\n");
assert_snapshot!(fixture_name, rust);
}
}