use crate::core::backend::GeneratedFile;
use crate::core::config::ResolvedCrateConfig;
use crate::core::ir::{ApiSurface, TypeRef};
use std::path::PathBuf;
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 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 workspace_deps_block = if workspace_dep_lines.is_empty() {
String::new()
} else {
format!("{}\n", workspace_dep_lines.join("\n"))
};
let needs_serde_json = api_has_json_or_enum_field(api);
let serde_json_dep = if needs_serde_json { "serde_json = \"1\"\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 tokio_dep = if has_streaming || has_trait_bridges {
"tokio = { version = \"1\", features = [\"rt-multi-thread\"] }\n"
} else {
""
};
let extra_deps =
format!("{ahash_dep}{serde_json_dep}{futures_util_dep}{tokio_dep}{trait_bridge_deps}{workspace_deps_block}");
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 target_overrides = config
.dart
.as_ref()
.map(|c| c.target_dep_overrides.as_slice())
.unwrap_or(&[]);
let (core_dep_line, target_override_blocks) = if target_overrides.is_empty() {
(
format!("{core_dep_key} = {{ path = \"{core_path}\"{package_rename_block}{features_block} }}\n"),
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 = format!(
"[target.'cfg(not({neg_cfg}))'.dependencies]\n{core_dep_key} = {{ path = \"{core_path}\"{package_rename_block}{features_block} }}\n\n"
);
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(&format!(
"[target.'cfg({cfg})'.dependencies]\n{core_dep_key} = {{ path = \"{core_path}\"{package_rename_block}{default_block}{feats_block} }}\n\n",
cfg = override_entry.cfg,
));
}
(String::new(), blocks)
};
let content = format!(
r#"[package]
name = "{crate_name}-dart"
version = "{version}"
edition = "2024"
license = "{license}"
[package.metadata.cargo-machete]
# Umbrella + sibling crates are pulled in so flutter_rust_bridge can resolve
# every referenced type, but the generated Rust wrapper only `use`s a subset.
ignored = [{machete_ignored_list}]
[lib]
crate-type = ["cdylib", "staticlib"]
[dependencies]
{core_dep_line}flutter_rust_bridge = "={frb_version}"
{extra_deps}
{target_override_blocks}[lints.rust]
# flutter_rust_bridge uses #[cfg(frb_expand)] internally during macro expansion.
# Declare it as a known cfg so rustc does not emit unexpected_cfgs warnings.
unexpected_cfgs = {{ level = "warn", check-cfg = ['cfg(frb_expand)'] }}"#
);
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 = format!(
r#"use std::path::Path;
fn main() {{
// Re-run whenever any Rust source changes.
println!("cargo:rerun-if-changed=src");
// Optional FRB codegen: regenerate flutter_rust_bridge artifacts when the
// tool is on PATH. Missing tool is not fatal — committed generated sources
// are checked in, and CI environments without FRB still build cleanly.
match std::process::Command::new("flutter_rust_bridge_codegen")
.args(["generate", "--config-file", "flutter_rust_bridge.yaml"])
.status()
{{
Ok(status) if status.success() => {{
// FRB v2.12+ emits `use` lists in an order rustfmt 2024 edition rewrites
// (e.g. `{{transform_result_dco, Lifetimeable, Lockable}}` →
// `{{Lifetimeable, Lockable, transform_result_dco}}`). Run rustfmt against
// the generated file so committed output is fmt-clean and `cargo fmt --check`
// stays green in CI.
match std::process::Command::new("rustfmt")
.args(["--edition", "2024", "src/frb_generated.rs"])
.status()
{{
Ok(s) if s.success() => {{}}
Ok(s) => println!("cargo:warning=rustfmt on src/frb_generated.rs exited {{s}}"),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {{
println!(
"cargo:warning=rustfmt not on PATH — skipping post-FRB format. Install rustfmt via rustup to keep generated bridge sources fmt-clean."
);
}}
Err(err) => println!("cargo:warning=failed to spawn rustfmt: {{err}}"),
}}
// Patch the generated Dart entrypoint so the published package resolves
// its native library from its own installed location.
patch_published_loader();
}}
Ok(status) => panic!("flutter_rust_bridge_codegen generate failed (exit code: {{status}})"),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {{
println!(
"cargo:warning=flutter_rust_bridge_codegen not on PATH — skipping codegen. Install via `dart pub global activate flutter_rust_bridge_codegen` to regenerate FRB artifacts at build time."
);
}}
Err(err) => panic!("failed to spawn flutter_rust_bridge_codegen: {{err}}"),
}}
}}
{loader_patch}
"#
);
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);
format!(
r##"const FRB_GENERATED_DART: &str = "../lib/src/{module_name}_bridge_generated/frb_generated.dart";
const LOADER_MARKER: &str = "_alefResolveExternalLibrary";
const FRB_INIT_PROLOGUE: &str = " /// Initialize flutter_rust_bridge\n static Future<void> init({{\n RustLibApi? api,\n BaseHandler? handler,\n ExternalLibrary? externalLibrary,\n bool forceSameCodegenVersion = true,\n }}) async {{\n";
const FRB_INIT_REPLACEMENT: &str = r#"{dart_replacement}"#;
/// Inject the published-package native-library loader into `frb_generated.dart`.
/// Idempotent: a no-op when the marker is already present or the FRB entrypoint
/// signature is absent.
fn patch_published_loader() {{
let path = Path::new(FRB_GENERATED_DART);
let Ok(source) = std::fs::read_to_string(path) else {{
println!("cargo:warning=published-loader patch skipped: {{}} not found", FRB_GENERATED_DART);
return;
}};
if source.contains(LOADER_MARKER) {{
return;
}}
if !source.contains(FRB_INIT_PROLOGUE) {{
println!("cargo:warning=published-loader patch skipped: FRB init prologue not found");
return;
}}
let mut patched = source.replacen(FRB_INIT_PROLOGUE, FRB_INIT_REPLACEMENT, 1);
// Ensure the helper's `File`/`Isolate` dependencies are imported.
for (probe, line) in [
("import 'dart:io';", "import 'dart:io';\n"),
("import 'dart:isolate';", "import 'dart:isolate';\n"),
] {{
if patched.contains(probe) {{
continue;
}}
if let Some(pos) = patched.find("\nimport ") {{
patched.insert_str(pos + 1, line);
}} else {{
patched.insert_str(0, line);
}}
}}
if patched != source {{
if let Err(err) = std::fs::write(path, &patched) {{
println!("cargo:warning=failed to write published-loader patch: {{err}}");
return;
}}
match std::process::Command::new("dart").args(["format", FRB_GENERATED_DART]).status() {{
Ok(s) if s.success() => {{}}
Ok(s) => println!("cargo:warning=dart format on {{}} exited {{}}", FRB_GENERATED_DART, s),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {{
println!("cargo:warning=dart not on PATH — skipping post-patch format. Install Dart SDK to keep generated FRB Dart sources fmt-clean.");
}}
Err(err) => println!("cargo:warning=failed to spawn dart format: {{err}}"),
}}
}}
}}
"##
)
}
fn dart_init_prologue_replacement(package_name: &str, module_name: &str, stem: &str) -> String {
format!(
r#" /// Resolve the prebuilt native library from this package's own installed
/// location so the load works from any working directory and under hardened
/// runtimes. Returns `null` to defer to flutter_rust_bridge's default loader.
static Future<ExternalLibrary?> _alefResolveExternalLibrary() async {{
try {{
final packageRoot =
await Isolate.resolvePackageUri(Uri.parse('package:{package_name}/{package_name}.dart'));
if (packageRoot != null) {{
final libDir = packageRoot.resolve('src/{module_name}_bridge_generated/');
const candidates = <String>[
'lib{stem}.dylib',
'lib{stem}.so',
'{stem}.dll',
];
for (final candidate in candidates) {{
final libPath = libDir.resolve(candidate).toFilePath();
if (File(libPath).existsSync()) {{
return ExternalLibrary.open(libPath);
}}
}}
}}
}} catch (_) {{
// Fall through to the default loader on any resolution failure.
}}
return null;
}}
/// Initialize flutter_rust_bridge
static Future<void> init({{
RustLibApi? api,
BaseHandler? handler,
ExternalLibrary? externalLibrary,
bool forceSameCodegenVersion = true,
}}) async {{
externalLibrary ??= await _alefResolveExternalLibrary();
"#
)
}
pub(crate) fn emit_frb_yaml(rust_dir: &str, module_name: &str) -> GeneratedFile {
let content = format!(
"rust_root: .\nrust_input: crate\ndart_output: ../lib/src/{module_name}_bridge_generated\nadd_mod_to_lib: false\n"
);
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 build_rs_tests {
use super::*;
#[test]
fn emitted_build_rs_is_valid_rust() {
let file = emit_build_rs("packages/dart/rust", "spikard", "spikard", "spikard_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", "spikard", "spikard", "spikard_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/spikard_bridge_generated/frb_generated.dart"#),
"build.rs must target the generated frb dart file"
);
assert!(
file.content
.contains("Isolate.resolvePackageUri(Uri.parse('package:spikard/spikard.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", "spikard", "spikard", "spikard_dart");
assert!(
file.content
.contains(r#"Command::new("dart").args(["format", FRB_GENERATED_DART])"#),
"build.rs must run `dart format` on the patched frb_generated.dart"
);
}
}