use crate::backends::dart::template_env;
use crate::core::backend::GeneratedFile;
use crate::core::config::ResolvedCrateConfig;
use crate::core::ir::{ApiSurface, TypeRef};
use std::path::PathBuf;
fn extract_feature_names_from_cfg(condition: &str) -> Vec<String> {
let mut features = Vec::new();
let condition = condition.trim();
if let Some(rest) = condition.strip_prefix("feature = \"") {
if let Some(name) = rest.strip_suffix('"') {
features.push(name.to_string());
return features;
}
}
let inner = if let Some(s) = condition.strip_prefix("any(") {
s.strip_suffix(')').unwrap_or(s)
} else {
return features;
};
for part in inner.split(',') {
let part = part.trim();
if let Some(rest) = part.strip_prefix("feature = \"") {
if let Some(name) = rest.strip_suffix('"') {
features.push(name.to_string());
}
}
}
features
}
fn collect_referenced_features(api: &ApiSurface) -> Vec<String> {
let mut features: Vec<String> = api
.enums
.iter()
.flat_map(|en| en.variants.iter())
.filter_map(|v| v.cfg.as_deref())
.flat_map(extract_feature_names_from_cfg)
.collect();
features.sort();
features.dedup();
features
}
fn quote_check_cfg_feature_value(feature: &str) -> String {
let escaped = feature.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn format_check_cfg_feature_values(features: &[String]) -> String {
features
.iter()
.map(|feature| quote_check_cfg_feature_value(feature))
.collect::<Vec<_>>()
.join(", ")
}
fn api_has_ahash_param(api: &ApiSurface) -> bool {
api.functions.iter().any(|f| f.params.iter().any(|p| p.map_is_ahash))
}
fn type_has_json(t: &TypeRef) -> bool {
match t {
TypeRef::Json => true,
TypeRef::Optional(inner) | TypeRef::Vec(inner) => type_has_json(inner),
TypeRef::Map(k, v) => type_has_json(k) || type_has_json(v),
_ => false,
}
}
fn type_references_excluded_named(
t: &TypeRef,
excluded_type_paths: &std::collections::HashMap<String, String>,
) -> bool {
match t {
TypeRef::Named(name) => excluded_type_paths.contains_key(name),
TypeRef::Optional(inner) | TypeRef::Vec(inner) => type_references_excluded_named(inner, excluded_type_paths),
TypeRef::Map(k, v) => {
type_references_excluded_named(k, excluded_type_paths)
|| type_references_excluded_named(v, excluded_type_paths)
}
_ => false,
}
}
fn api_has_trait_bridge_excluded_carrier(api: &ApiSurface, config: &ResolvedCrateConfig) -> bool {
config
.trait_bridges
.iter()
.filter(|cfg| !cfg.exclude_languages.iter().any(|l| l == "dart"))
.filter_map(|cfg| api.types.iter().find(|t| t.name == cfg.trait_name && t.is_trait))
.flat_map(|trait_def| trait_def.methods.iter())
.filter(|m| m.trait_source.is_none())
.any(|m| {
type_references_excluded_named(&m.return_type, &api.excluded_type_paths)
|| m.params
.iter()
.any(|p| type_references_excluded_named(&p.ty, &api.excluded_type_paths))
})
}
fn api_has_json_or_enum_field(api: &ApiSurface) -> bool {
if api
.types
.iter()
.flat_map(|t| t.fields.iter())
.any(|f| type_has_json(&f.ty))
|| api
.functions
.iter()
.any(|f| f.params.iter().any(|p| type_has_json(&p.ty)) || type_has_json(&f.return_type))
{
return true;
}
let enum_names: std::collections::HashSet<&str> = api.enums.iter().map(|e| e.name.as_str()).collect();
fn type_ref_contains_enum(t: &TypeRef, enum_names: &std::collections::HashSet<&str>) -> bool {
match t {
TypeRef::Named(name) => enum_names.contains(name.as_str()),
TypeRef::Optional(inner) | TypeRef::Vec(inner) => type_ref_contains_enum(inner, enum_names),
TypeRef::Map(k, v) => type_ref_contains_enum(k, enum_names) || type_ref_contains_enum(v, enum_names),
_ => false,
}
}
api.types
.iter()
.filter(|t| !t.is_trait && !t.is_opaque)
.flat_map(|t| t.fields.iter())
.any(|f| type_ref_contains_enum(&f.ty, &enum_names))
|| api.functions.iter().any(|f| {
f.params.iter().any(|p| type_ref_contains_enum(&p.ty, &enum_names))
|| type_ref_contains_enum(&f.return_type, &enum_names)
})
}
#[allow(dead_code)]
fn api_has_json_field(api: &ApiSurface) -> bool {
api.types
.iter()
.flat_map(|t| t.fields.iter())
.any(|f| type_has_json(&f.ty))
|| api
.functions
.iter()
.any(|f| f.params.iter().any(|p| type_has_json(&p.ty)) || type_has_json(&f.return_type))
}
pub(crate) fn emit_cargo_toml(
rust_dir: &str,
api: &ApiSurface,
config: &ResolvedCrateConfig,
source_crate_name: &str,
) -> GeneratedFile {
let crate_name = config.name.as_str();
let version = &api_version(config);
let frb_version = crate::backends::dart::naming::dart_frb_version(config);
let core_crate_dir = config.core_crate_for_language(crate::core::config::extras::Language::Dart);
let dart_override = config.dart.as_ref().and_then(|c| c.core_crate_override.as_deref());
let core_dep_key: String = match dart_override {
Some(name) => name.to_string(),
None => source_crate_name.to_string(),
};
let same_as_workspace = dart_override.is_none() && core_crate_dir == *crate_name && config.workspace_root.is_none();
let core_path = if same_as_workspace {
"../../..".to_string()
} else {
format!("../../../crates/{core_crate_dir}")
};
let features = config.features_for_language(crate::core::config::extras::Language::Dart);
let features_block = if features.is_empty() {
String::new()
} else {
let list = features
.iter()
.map(|f| format!("\"{f}\""))
.collect::<Vec<_>>()
.join(", ");
format!(", features = [{list}]")
};
let package_rename_block = if dart_override.is_none() && core_dep_key != crate_name {
format!(", package = \"{crate_name}\"")
} else {
String::new()
};
let has_trait_bridges = config.trait_bridges.iter().any(|b| {
!b.exclude_languages.iter().any(|l| l == "dart")
&& api.types.iter().any(|t| t.name == b.trait_name && t.is_trait)
});
let trait_bridge_deps = if has_trait_bridges {
"async-trait = \"0.1\"\n"
} else {
""
};
let workspace_extra = config.extra_deps_for_language(crate::core::config::extras::Language::Dart);
let mut workspace_dep_lines: Vec<String> = workspace_extra
.iter()
.map(|(name, value)| {
if let Some(s) = value.as_str() {
format!("{name} = \"{s}\"")
} else {
format!("{name} = {value}")
}
})
.collect();
workspace_dep_lines.sort();
let has_trait_bridge_excluded_carrier = api_has_trait_bridge_excluded_carrier(api, config);
let needs_serde_json = api_has_json_or_enum_field(api) || has_trait_bridge_excluded_carrier;
let serde_json_dep = if needs_serde_json { "serde_json = \"1\"\n" } else { "" };
let needs_serde_derive = has_trait_bridge_excluded_carrier;
let serde_dep = if needs_serde_derive {
"serde = { version = \"1\", features = [\"derive\"] }\n"
} else {
""
};
let needs_ahash = api_has_ahash_param(api);
let ahash_dep = if needs_ahash { "ahash = \"0.8\"\n" } else { "" };
let has_streaming = config
.adapters
.iter()
.any(|a| matches!(a.pattern, crate::core::config::extras::AdapterPattern::Streaming));
let futures_util_dep = if has_streaming { "futures-util = \"0.3\"\n" } else { "" };
let has_services = !api.services.is_empty();
let tokio_dep = if has_streaming || has_trait_bridges || has_services {
"tokio = { version = \"1\", features = [\"rt-multi-thread\", \"sync\"] }\n"
} else {
""
};
let target_overrides = config
.dart
.as_ref()
.map(|c| c.target_dep_overrides.as_slice())
.unwrap_or(&[]);
let frb_line = format!("flutter_rust_bridge = \"={frb_version}\"");
let mut dep_lines: Vec<String> = Vec::new();
if target_overrides.is_empty() {
dep_lines.push(format!(
"{core_dep_key} = {{ path = \"{core_path}\"{package_rename_block}{features_block} }}"
));
}
dep_lines.push(frb_line);
for dep in [
ahash_dep,
serde_dep,
serde_json_dep,
futures_util_dep,
tokio_dep,
trait_bridge_deps,
] {
let trimmed = dep.trim_end_matches('\n');
if !trimmed.is_empty() {
dep_lines.push(trimmed.to_string());
}
}
dep_lines.extend(workspace_dep_lines);
dep_lines.sort_by(|a, b| {
let key = |line: &str| line.split('=').next().unwrap_or("").trim().to_string();
key(a).cmp(&key(b))
});
let extra_deps = if dep_lines.is_empty() {
String::new()
} else {
format!("{}\n", dep_lines.join("\n"))
};
let license = config
.scaffold
.as_ref()
.and_then(|s| s.license.as_deref())
.unwrap_or("MIT");
let mut machete_ignored: Vec<String> = std::iter::once(core_dep_key.clone())
.chain(workspace_extra.keys().cloned())
.collect();
if api_has_ahash_param(api) {
machete_ignored.push("ahash".to_string());
}
machete_ignored.sort();
machete_ignored.dedup();
let machete_ignored_list = machete_ignored
.iter()
.map(|n| format!("\"{n}\""))
.collect::<Vec<_>>()
.join(", ");
let (core_dep_line, target_override_blocks) = if target_overrides.is_empty() {
(String::new(), String::new())
} else {
let neg_cfg = if target_overrides.len() == 1 {
target_overrides[0].cfg.clone()
} else {
let any = target_overrides
.iter()
.map(|o| o.cfg.as_str())
.collect::<Vec<_>>()
.join(", ");
format!("any({any})")
};
let mut blocks = template_env::render(
"rust_cargo_target_dependency.rs.jinja",
minijinja::context! {
cfg => format!("not({neg_cfg})"),
core_dep_key => core_dep_key.as_str(),
core_path => core_path.as_str(),
package_rename_block => package_rename_block.as_str(),
default_block => "",
features_block => features_block.as_str(),
},
);
for override_entry in target_overrides {
let feat_list = override_entry
.features
.iter()
.map(|f| format!("\"{f}\""))
.collect::<Vec<_>>()
.join(", ");
let feats_block = if feat_list.is_empty() {
String::new()
} else {
format!(", features = [{feat_list}]")
};
let default_block = if override_entry.default_features {
String::new()
} else {
", default-features = false".to_string()
};
blocks.push_str(&template_env::render(
"rust_cargo_target_dependency.rs.jinja",
minijinja::context! {
cfg => override_entry.cfg.as_str(),
core_dep_key => core_dep_key.as_str(),
core_path => core_path.as_str(),
package_rename_block => package_rename_block.as_str(),
default_block => default_block.as_str(),
features_block => feats_block.as_str(),
},
));
}
(String::new(), blocks)
};
let referenced_feature_values: String = {
let features = collect_referenced_features(api);
format_check_cfg_feature_values(&features)
};
let content = template_env::render(
"rust_cargo_toml.rs.jinja",
minijinja::context! {
crate_name => crate_name,
version => version.as_str(),
license => license,
machete_ignored_list => machete_ignored_list.as_str(),
core_dep_line => core_dep_line.as_str(),
frb_version => frb_version.as_str(),
extra_deps => extra_deps.as_str(),
target_override_blocks => target_override_blocks.as_str(),
referenced_feature_values => referenced_feature_values.as_str(),
},
);
GeneratedFile {
path: PathBuf::from(format!("{rust_dir}/Cargo.toml")),
content,
generated_header: false,
}
}
pub(crate) fn emit_build_rs(rust_dir: &str, package_name: &str, module_name: &str, stem: &str) -> GeneratedFile {
let loader_patch = render_loader_patch_fn(package_name, module_name, stem);
let content = template_env::render(
"rust_build_rs.rs.jinja",
minijinja::context! {
loader_patch => loader_patch.as_str(),
},
);
GeneratedFile {
path: PathBuf::from(format!("{rust_dir}/build.rs")),
content,
generated_header: false,
}
}
fn render_loader_patch_fn(package_name: &str, module_name: &str, stem: &str) -> String {
let dart_replacement = dart_init_prologue_replacement(package_name, module_name, stem);
template_env::render(
"rust_loader_patch_fn.rs.jinja",
minijinja::context! {
module_name => module_name,
dart_replacement => dart_replacement.as_str(),
},
)
}
fn dart_init_prologue_replacement(package_name: &str, module_name: &str, stem: &str) -> String {
template_env::render(
"dart_init_prologue_replacement.jinja",
minijinja::context! {
package_name => package_name,
module_name => module_name,
stem => stem,
},
)
}
pub(crate) fn emit_frb_yaml(rust_dir: &str, module_name: &str) -> GeneratedFile {
let content = template_env::render(
"flutter_rust_bridge_yaml.jinja",
minijinja::context! {
module_name => module_name,
},
);
GeneratedFile {
path: PathBuf::from(format!("{rust_dir}/flutter_rust_bridge.yaml")),
content,
generated_header: false,
}
}
fn api_version(config: &ResolvedCrateConfig) -> String {
config.resolved_version().unwrap_or_else(|| "0.1.0".to_string())
}
#[cfg(test)]
mod feature_cfg_tests {
use super::*;
use crate::core::ir::{EnumDef, EnumVariant};
fn make_unit_variant(name: &str, cfg: Option<&str>) -> EnumVariant {
EnumVariant {
name: name.to_string(),
cfg: cfg.map(str::to_string),
..Default::default()
}
}
#[test]
fn extract_feature_names_from_simple_cfg() {
let names = extract_feature_names_from_cfg("feature = \"heic\"");
assert_eq!(names, vec!["heic"]);
}
#[test]
fn extract_feature_names_from_any_cfg() {
let names = extract_feature_names_from_cfg("any(feature = \"heic\", feature = \"svg\")");
assert_eq!(names, vec!["heic", "svg"]);
}
#[test]
fn extract_feature_names_ignores_non_feature_cfg() {
let names = extract_feature_names_from_cfg("target_os = \"linux\"");
assert!(names.is_empty());
}
#[test]
fn collect_referenced_features_sorted_deduped() {
use crate::core::ir::ApiSurface;
let api = ApiSurface {
enums: vec![
EnumDef {
name: "ImageOutputFormat".to_string(),
variants: vec![
make_unit_variant("Native", None),
make_unit_variant("Heic", Some("feature = \"heic\"")),
make_unit_variant("Svg", Some("feature = \"svg\"")),
make_unit_variant("Svg2", Some("feature = \"svg\"")),
],
..Default::default()
},
EnumDef {
name: "OtherEnum".to_string(),
variants: vec![make_unit_variant("A", Some("feature = \"heic\""))],
..Default::default()
},
],
..Default::default()
};
let features = collect_referenced_features(&api);
assert_eq!(features, vec!["heic", "svg"]);
}
#[test]
fn format_check_cfg_feature_values_quotes_and_escapes() {
let features = vec![
"plain".to_string(),
"quote\"feature".to_string(),
"slash\\feature".to_string(),
];
assert_eq!(
format_check_cfg_feature_values(&features),
r#""plain", "quote\"feature", "slash\\feature""#
);
}
#[test]
fn cargo_toml_check_cfg_includes_referenced_features() {
use crate::core::config::ResolvedCrateConfig;
use crate::core::ir::ApiSurface;
let api = ApiSurface {
enums: vec![EnumDef {
name: "ImageOutputFormat".to_string(),
variants: vec![
make_unit_variant("Native", None),
make_unit_variant("Heic", Some("feature = \"heic\"")),
make_unit_variant("Svg", Some("feature = \"svg\"")),
],
..Default::default()
}],
..Default::default()
};
let config = ResolvedCrateConfig {
name: "sample-lib".to_string(),
..Default::default()
};
let file = emit_cargo_toml("packages/dart/rust", &api, &config, "sample_lib");
assert!(
file.content.contains("values(\"heic\", \"svg\")"),
"Cargo.toml must declare referenced features in check-cfg values; got:\n{}",
file.content
);
assert!(
file.content.contains("'cfg(frb_expand)'"),
"Cargo.toml must still include cfg(frb_expand); got:\n{}",
file.content
);
}
#[test]
fn cargo_toml_check_cfg_fallback_when_no_cfg_variants() {
use crate::core::config::ResolvedCrateConfig;
use crate::core::ir::ApiSurface;
let api = ApiSurface {
enums: vec![EnumDef {
name: "SimpleEnum".to_string(),
variants: vec![make_unit_variant("A", None), make_unit_variant("B", None)],
..Default::default()
}],
..Default::default()
};
let config = ResolvedCrateConfig {
name: "sample-lib".to_string(),
..Default::default()
};
let file = emit_cargo_toml("packages/dart/rust", &api, &config, "sample_lib");
assert!(
file.content
.contains("unexpected_cfgs = { level = \"warn\", check-cfg = ['cfg(frb_expand)'] }"),
"Cargo.toml must use single-entry form when no cfg variants; got:\n{}",
file.content
);
assert!(
!file.content.contains("values("),
"Cargo.toml must not contain feature values when no cfg variants; got:\n{}",
file.content
);
}
}
#[cfg(test)]
mod build_rs_tests {
use super::*;
#[test]
fn emitted_build_rs_is_valid_rust() {
let file = emit_build_rs(
"packages/dart/rust",
"sample_router",
"sample_router",
"sample_router_dart",
);
syn::parse_file(&file.content).expect("generated build.rs must be valid Rust");
}
#[test]
fn emitted_build_rs_patches_published_loader_after_codegen() {
let file = emit_build_rs(
"packages/dart/rust",
"sample_router",
"sample_router",
"sample_router_dart",
);
assert!(
file.content.contains("patch_published_loader();"),
"build.rs must invoke the loader patch after codegen"
);
assert!(
file.content.contains("fn patch_published_loader()"),
"build.rs must define the loader patch"
);
assert!(
file.content
.contains(r#"../lib/src/sample_router_bridge_generated/frb_generated.dart"#),
"build.rs must target the generated frb dart file"
);
assert!(
file.content
.contains("Isolate.resolvePackageUri(Uri.parse('package:sample_router/sample_router.dart'))"),
"build.rs replacement must resolve the package URI"
);
assert!(
file.content
.contains("externalLibrary ??= await _alefResolveExternalLibrary();"),
"build.rs replacement must prefer the package-relative library"
);
}
#[test]
fn emitted_build_rs_runs_dart_format_after_patch() {
let file = emit_build_rs(
"packages/dart/rust",
"sample_router",
"sample_router",
"sample_router_dart",
);
assert!(
file.content.contains("Command::new(\"dart\")")
&& file.content.contains("\"format\"")
&& file.content.contains("FRB_GENERATED_DART"),
"build.rs must run `dart format` on the patched frb_generated.dart"
);
}
#[test]
fn emitted_build_rs_handles_loader_patch_write_error() {
let file = emit_build_rs(
"packages/dart/rust",
"sample_router",
"sample_router",
"sample_router_dart",
);
assert!(
file.content
.contains("if let Err(err) = std::fs::write(path, &patched)")
&& file
.content
.contains("cargo:warning=failed to write published-loader patch: {err}")
&& file.content.contains("return;"),
"emitted build.rs must handle loader patch write errors"
);
}
}