use super::run_run_command_with_spawn;
use anyhow::Context as _;
use serde_json::Value;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
const FRB: &str = "flutter_rust_bridge_codegen";
struct RuntimeConfig {
temporary: tempfile::TempPath,
args: Vec<String>,
output: PathBuf,
dart_root: Option<PathBuf>,
}
pub(super) fn run(
args: &[&str],
base: &Path,
scope: &str,
timeout: Duration,
before_spawn: &dyn Fn(&mut Command) -> anyhow::Result<()>,
) -> anyhow::Result<bool> {
if std::env::var("ALEF_SKIP_COMMANDS").is_ok_and(|list| list.split(',').any(|name| name.trim() == FRB)) {
return run_run_command_with_spawn(FRB, args, base, scope, timeout, before_spawn);
}
let started = Instant::now();
let runtime = prepare(args, base)?;
let borrowed: Vec<&str> = runtime.args.iter().map(String::as_str).collect();
let ran = run_run_command_with_spawn(FRB, &borrowed, base, scope, remaining(started, timeout)?, before_spawn)?;
if ran {
complete(
&runtime.output,
runtime.dart_root.as_deref(),
scope,
started,
timeout,
before_spawn,
)?;
}
drop(runtime.temporary);
Ok(ran)
}
fn prepare(args: &[&str], base: &Path) -> anyhow::Result<RuntimeConfig> {
let index = args
.iter()
.position(|arg| *arg == "--config-file")
.context("FRB command has no --config-file")?
+ 1;
let original = base.join(args.get(index).context("FRB command has no config filename")?);
let parent = original
.parent()
.context("FRB configuration has no parent directory")?
.canonicalize()
.context("locating FRB configuration directory")?;
let text = std::fs::read_to_string(&original).context("reading FRB generation configuration")?;
let mut config: Value = serde_saphyr::from_str(&text).context("parsing FRB generation configuration")?;
let mapping = config
.as_object_mut()
.context("FRB generation configuration must be a mapping")?;
let output = parent.join(
mapping
.get("dart_output")
.and_then(Value::as_str)
.context("FRB generation configuration has no dart_output")?,
);
let dart_root = match mapping.get("dart_root") {
None | Some(Value::Null) => None,
Some(Value::String(path)) => Some(base.join(path)),
Some(_) => anyhow::bail!("FRB dart_root must be a string"),
};
mapping.insert("build_runner".to_owned(), Value::Bool(false));
let mut temporary = tempfile::Builder::new()
.prefix(".alef-frb-")
.suffix(".yaml")
.tempfile_in(&parent)
.context("creating private FRB configuration")?;
serde_json::to_writer(temporary.as_file_mut(), &config).context("writing private FRB configuration")?;
temporary.flush().context("flushing private FRB configuration")?;
let temporary = temporary.into_temp_path();
let mut actual: Vec<String> = args.iter().map(|arg| (*arg).to_owned()).collect();
actual[index] = temporary
.to_str()
.context("FRB configuration path is not UTF-8")?
.to_owned();
Ok(RuntimeConfig {
temporary,
args: actual,
output,
dart_root,
})
}
fn complete(
output: &Path,
dart_root: Option<&Path>,
scope: &str,
started: Instant,
timeout: Duration,
before_spawn: &dyn Fn(&mut Command) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
let output = output.canonicalize().context("locating generated Dart bridge output")?;
let bridge = output.join("lib.dart");
let text = std::fs::read_to_string(&bridge).context("reading generated Dart bridge for part completion")?;
let parts = generated_parts(&text)?;
if parts.is_empty() {
tracing::debug!("generated Dart bridge declares no parts requiring build_runner");
return Ok(());
}
let (root, args) = builder_command(&output, dart_root, &parts)?;
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
let ran = run_run_command_with_spawn(
"dart",
&borrowed,
&root,
scope,
remaining(started, timeout)?,
&|command| {
command.env("FRB_SIMPLE_BUILD_SKIP", "1");
before_spawn(command)
},
)
.context("required Dart build_runner failed")?;
anyhow::ensure!(ran, "required Dart build_runner was skipped or is not installed");
for part in parts {
anyhow::ensure!(
output.join(&part).is_file(),
"required generated Dart part {part} is missing after build_runner"
);
}
Ok(())
}
fn builder_command(
output: &Path,
dart_root: Option<&Path>,
parts: &[String],
) -> anyhow::Result<(PathBuf, Vec<String>)> {
let root = match dart_root {
Some(root) => root.canonicalize().context("locating explicit FRB dart_root")?,
None => output
.ancestors()
.find(|parent| parent.join("pubspec.yaml").is_file())
.context("generated Dart bridge has no package pubspec")?
.to_path_buf(),
};
let pubspec: Value = serde_saphyr::from_str(&std::fs::read_to_string(root.join("pubspec.yaml"))?)
.context("parsing generated Dart package pubspec")?;
let name = pubspec
.get("name")
.and_then(Value::as_str)
.context("generated Dart package has no name")?;
anyhow::ensure!(
name.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
&& !name.is_empty(),
"generated Dart package name is invalid"
);
let relative = output
.strip_prefix(root.join("lib"))
.context("generated Dart output must be inside package lib")?;
let prefix = escaped_filter_path(relative)?;
let mut args = vec![
"run".to_owned(),
"build_runner".to_owned(),
"build".to_owned(),
"--delete-conflicting-outputs".to_owned(),
format!("--build-filter=package:{name}/{prefix}**.freezed.dart"),
];
if parts.iter().any(|part| part == "lib.g.dart") {
args.push(format!("--build-filter=package:{name}/{prefix}**.g.dart"));
}
Ok((root.to_path_buf(), args))
}
fn generated_parts(text: &str) -> anyhow::Result<Vec<String>> {
let regex = regex::Regex::new(r#"(?m)^\s*part\s+['\"]([^'\"]+)['\"]\s*;"#)?;
let mut parts = Vec::new();
for capture in regex.captures_iter(text) {
let name = &capture[1];
if name.ends_with(".freezed.dart") || name.ends_with(".g.dart") {
anyhow::ensure!(
matches!(name, "lib.freezed.dart" | "lib.g.dart"),
"unsupported generated Dart part {name}"
);
if !parts.iter().any(|part| part == name) {
parts.push(name.to_owned());
}
}
}
anyhow::ensure!(
!parts.iter().any(|part| part == "lib.g.dart") || parts.iter().any(|part| part == "lib.freezed.dart"),
"JSON-only generated Dart bridge is unsupported by this FRB completion path"
);
Ok(parts)
}
pub(super) fn escaped_filter_path(path: &Path) -> anyhow::Result<String> {
let mut escaped = String::new();
for component in path.components() {
let std::path::Component::Normal(component) = component else {
anyhow::bail!("generated Dart output filter contains a nonliteral path component");
};
for character in component
.to_str()
.context("generated Dart output filter is not UTF-8")?
.chars()
{
if matches!(
character,
'*' | '{' | '[' | '?' | '\\' | '}' | ']' | ',' | '-' | '(' | ')'
) {
escaped.push('\\');
}
escaped.push(character);
}
escaped.push('/');
}
let mut encoded = String::new();
for byte in escaped.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') {
encoded.push(char::from(byte));
} else {
use std::fmt::Write as _;
write!(&mut encoded, "%{byte:02X}")?;
}
}
Ok(encoded)
}
fn remaining(started: Instant, timeout: Duration) -> anyhow::Result<Duration> {
let remaining = timeout.saturating_sub(started.elapsed());
anyhow::ensure!(
!remaining.is_zero(),
"FRB generation and Dart completion exceeded their shared timeout"
);
Ok(remaining)
}