use std::collections::BTreeMap;
use std::env::consts::EXE_EXTENSION;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use anyhow::{Context, Result};
use prek_consts::env_vars::EnvVars;
use prek_consts::prepend_paths;
use serde::{Deserialize, Serialize};
use tracing::debug;
use crate::cli::reporter::HookInstallReporter;
use crate::cli::run::HookRunReporter;
use crate::hook::{Hook, InstallInfo, InstalledHook};
use crate::languages::LanguageImpl;
use crate::process::Cmd;
use crate::run::run_by_batch;
use crate::store::Store;
#[derive(Debug, Copy, Clone)]
pub(crate) struct Dart;
const PUBSPEC_YAML: &str = "pubspec.yaml";
#[derive(Debug, Deserialize, Serialize)]
struct Pubspec {
name: String,
#[serde(
default,
skip_deserializing,
skip_serializing_if = "BTreeMap::is_empty"
)]
environment: BTreeMap<String, String>,
#[serde(
default,
skip_deserializing,
skip_serializing_if = "BTreeMap::is_empty"
)]
dependencies: BTreeMap<String, PubspecDependency>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
executables: BTreeMap<String, PubspecExecutable>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(untagged)]
enum PubspecExecutable {
Entrypoint(String),
Default,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(untagged)]
enum PubspecDependency {
Version(String),
Path { path: PathBuf },
}
impl PubspecExecutable {
fn into_entrypoint(self, output_name: &str) -> String {
match self {
Self::Entrypoint(entrypoint) if !entrypoint.is_empty() => entrypoint,
Self::Entrypoint(_) | Self::Default => output_name.to_string(),
}
}
}
fn find_dart_binary() -> Result<PathBuf> {
let dart = which::which("dart")
.context("Failed to locate dart executable. Is Dart installed and available in PATH?")?;
Ok(dart)
}
impl LanguageImpl for Dart {
async fn install(
&self,
hook: Arc<Hook>,
store: &Store,
reporter: &HookInstallReporter,
) -> Result<InstalledHook> {
let progress = reporter.on_install_start(&hook);
let mut info = InstallInfo::new(
hook.language,
hook.env_key_dependencies().clone(),
&store.hooks_dir(),
)?;
debug!(%hook, target = %info.env_path.display(), "Installing Dart environment");
let dart = find_dart_binary()?;
let source_path = hook.repo_path().unwrap_or_else(|| hook.work_dir());
if source_path.join(PUBSPEC_YAML).exists() {
install_from_pubspec(
&dart,
&info.env_path,
source_path,
&hook.additional_dependencies,
)
.await?;
} else if !hook.additional_dependencies.is_empty() {
install_package_config(
&dart,
&info.env_path,
None,
None,
&hook.additional_dependencies,
)
.await
.context("Failed to install Dart additional dependencies")?;
}
info.with_toolchain(dart);
info.persist_env_path();
reporter.on_install_complete(progress);
Ok(InstalledHook::Installed {
hook,
info: Arc::new(info),
})
}
async fn check_health(&self, info: &InstallInfo) -> Result<()> {
let dart = find_dart_binary()?;
if dart != info.toolchain {
anyhow::bail!(
"Dart executable mismatch: expected `{}`, found `{}`",
info.toolchain.display(),
dart.display()
);
}
Ok(())
}
async fn run(
&self,
hook: &InstalledHook,
filenames: &[&Path],
store: &Store,
reporter: &HookRunReporter,
) -> Result<(i32, Vec<u8>)> {
let progress = reporter.on_run_start(hook, filenames.len());
let env_dir = hook.env_path().expect("Dart must have env path");
let bin_path = bin_path(env_dir);
let new_path = prepend_paths(&[&bin_path]).context("Failed to join PATH")?;
let packages_path = package_config_path(env_dir);
let mut entry = hook.entry.resolve(Some(&new_path), store)?;
if packages_path.exists()
&& let Some(index) = packages_arg_insert_position(entry.argv(), &hook.args)
{
entry
.argv_mut()
.insert(index, format!("--packages={}", packages_path.display()));
}
let run = async |batch: &[&Path]| {
let mut output = Cmd::new(&entry[0])
.current_dir(hook.work_dir())
.args(&entry[1..])
.env(EnvVars::PATH, &new_path)
.env(EnvVars::PUB_CACHE, env_dir)
.envs(&hook.env)
.args(&hook.args)
.file_args(batch)
.check(false)
.stdin(Stdio::null())
.pty_output_with_sink(reporter.output_sink(progress))
.await?;
reporter.on_run_progress(progress, batch.len() as u64);
output.stdout.extend(output.stderr);
let code = output.status.code().unwrap_or(1);
anyhow::Ok((code, output.stdout))
};
let results = run_by_batch(hook, filenames, entry.argv(), run).await?;
let mut combined_status = 0;
let mut combined_output = Vec::new();
for (code, output) in results {
combined_status |= code;
combined_output.extend(output);
}
reporter.on_run_complete(progress);
Ok((combined_status, combined_output))
}
}
fn bin_path(env_path: &Path) -> PathBuf {
env_path.join("bin")
}
fn package_config_path(env_path: &Path) -> PathBuf {
env_path.join(".dart_tool").join("package_config.json")
}
fn packages_arg_insert_position(entry: &[String], hook_args: &[String]) -> Option<usize> {
fn is_dart_binary(arg: &str) -> bool {
Path::new(arg)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| matches!(name, "dart" | "dart.exe"))
}
fn has_packages_arg(arg: &str) -> bool {
arg == "-p" || arg == "--packages" || arg.starts_with("--packages=")
}
fn is_dart_script(arg: &str) -> bool {
Path::new(arg)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("dart"))
}
let dart_index = entry.iter().position(|arg| is_dart_binary(arg))?;
for (index, arg) in entry
.iter()
.chain(hook_args)
.enumerate()
.skip(dart_index + 1)
{
if has_packages_arg(arg) {
return None;
}
if !arg.starts_with('-') {
if arg != "run" && !is_dart_script(arg) {
return None;
}
return Some(index.min(entry.len()));
}
}
None
}
async fn compile_executables(
dart: &Path,
source_path: &Path,
bin_dir: &Path,
packages_path: &Path,
executables: BTreeMap<String, PubspecExecutable>,
) -> Result<()> {
if executables.is_empty() {
return Ok(());
}
fs_err::create_dir_all(bin_dir)?;
for (output_name, executable) in executables {
let entrypoint = executable.into_entrypoint(&output_name);
let mut relative_entrypoint = PathBuf::from(&entrypoint);
if relative_entrypoint.extension().is_none() {
relative_entrypoint.set_extension("dart");
}
let source_file = source_path.join("bin").join(relative_entrypoint);
if !source_file.exists() {
debug!("Skipping executable `{output_name}`: source file not found");
continue;
}
let output_path = bin_dir.join(&output_name).with_extension(EXE_EXTENSION);
debug!(
"Compiling executable `{output_name}`: {source} -> {output}",
source = source_file.display(),
output = output_path.display(),
);
Cmd::new(dart)
.arg("compile")
.arg("exe")
.arg(format!("--packages={}", packages_path.display()))
.arg(&source_file)
.arg("--output")
.arg(&output_path)
.check(true)
.output()
.await?;
}
Ok(())
}
fn build_env_pubspec(
source_path: Option<&Path>,
package_name: Option<&str>,
dependencies: &rustc_hash::FxHashSet<String>,
) -> Pubspec {
let mut resolved_dependencies = BTreeMap::new();
let mut dependencies = dependencies.iter().collect::<Vec<_>>();
dependencies.sort_unstable();
for dep in dependencies {
if let Some((package, version)) = dep.split_once(':') {
resolved_dependencies.insert(
package.to_string(),
PubspecDependency::Version(version.to_string()),
);
} else {
resolved_dependencies
.insert(dep.clone(), PubspecDependency::Version("any".to_string()));
}
}
if let (Some(source_path), Some(package_name)) = (source_path, package_name) {
resolved_dependencies.insert(
package_name.to_string(),
PubspecDependency::Path {
path: source_path.to_path_buf(),
},
);
}
Pubspec {
name: "prek_dart_env".to_string(),
environment: BTreeMap::from([("sdk".to_string(), ">=2.12.0 <4.0.0".to_string())]),
dependencies: resolved_dependencies,
executables: BTreeMap::new(),
}
}
async fn install_package_config(
dart: &Path,
env_path: &Path,
source_path: Option<&Path>,
package_name: Option<&str>,
dependencies: &rustc_hash::FxHashSet<String>,
) -> Result<()> {
let pubspec = build_env_pubspec(source_path, package_name, dependencies);
let pubspec_content = serde_saphyr::to_string(&pubspec)?;
let pubspec_path = env_path.join(PUBSPEC_YAML);
fs_err::tokio::write(&pubspec_path, pubspec_content).await?;
Cmd::new(dart)
.current_dir(env_path)
.env(EnvVars::PUB_CACHE, env_path)
.arg("pub")
.arg("get")
.check(true)
.output()
.await?;
Ok(())
}
async fn install_from_pubspec(
dart: &Path,
env_path: &Path,
source_path: &Path,
dependencies: &rustc_hash::FxHashSet<String>,
) -> Result<()> {
let pubspec_path = source_path.join(PUBSPEC_YAML);
let pubspec_content = fs_err::read_to_string(&pubspec_path)?;
let pubspec: Pubspec = serde_saphyr::from_str(&pubspec_content)?;
install_package_config(
dart,
env_path,
Some(source_path),
Some(&pubspec.name),
dependencies,
)
.await
.context("Failed to install Dart pubspec dependencies")?;
compile_executables(
dart,
source_path,
&bin_path(env_path),
&package_config_path(env_path),
pubspec.executables,
)
.await
.context("Failed to compile Dart pubspec executables")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(ToString::to_string).collect()
}
#[test]
fn packages_arg_insert_position_inserts_before_dart_run() {
let entry = strings(&["/usr/bin/dart", "run", "bin/hook.dart"]);
assert_eq!(packages_arg_insert_position(&entry, &[]), Some(1));
}
#[test]
fn packages_arg_insert_position_keeps_existing_packages_arg() {
assert_eq!(
packages_arg_insert_position(&strings(&["dart", "--packages=custom", "run"]), &[]),
None
);
assert_eq!(
packages_arg_insert_position(&strings(&["dart", "--packages", "custom", "run"]), &[]),
None
);
assert_eq!(
packages_arg_insert_position(
&strings(&["dart"]),
&strings(&["--packages=custom", "run"])
),
None
);
}
#[test]
fn packages_arg_insert_position_only_checks_vm_options_for_packages_arg() {
assert_eq!(
packages_arg_insert_position(&strings(&["dart", "run", "tool.dart", "-p"]), &[]),
Some(1)
);
assert_eq!(
packages_arg_insert_position(
&strings(&["dart", "tool.dart", "--packages=script-value"]),
&[]
),
Some(1)
);
assert_eq!(
packages_arg_insert_position(
&strings(&["dart"]),
&strings(&["run", "tool.dart", "--packages=script-value"])
),
Some(1)
);
}
#[test]
fn packages_arg_insert_position_checks_hook_args() {
assert_eq!(
packages_arg_insert_position(&strings(&["dart"]), &strings(&["run", "bin/hook.dart"])),
Some(1)
);
assert_eq!(
packages_arg_insert_position(&strings(&["dart"]), &strings(&["bin/hook.dart"])),
Some(1)
);
assert_eq!(
packages_arg_insert_position(
&strings(&["dart", "--enable-asserts"]),
&strings(&["run", "bin/hook.dart"])
),
Some(2)
);
}
#[test]
fn build_env_pubspec_serializes_path_dependency() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let dependencies = rustc_hash::FxHashSet::default();
let pubspec = build_env_pubspec(Some(temp_dir.path()), Some("sample"), &dependencies);
match pubspec.dependencies.get("sample") {
Some(PubspecDependency::Path { path }) => assert_eq!(path, temp_dir.path()),
dependency => panic!("expected path dependency, got {dependency:?}"),
}
Ok(())
}
#[test]
fn pubspec_deserialization_ignores_unread_fields() -> Result<()> {
let pubspec: Pubspec = serde_saphyr::from_str(indoc::indoc! {r"
name: sample
environment:
sdk: '>=2.17.0 <4.0.0'
dependencies:
hosted_dep:
hosted: https://pub.dev
version: ^1.0.0
sdk_dep:
sdk: flutter
executables:
sample:
"})?;
assert_eq!(pubspec.name, "sample");
assert!(pubspec.environment.is_empty());
assert!(pubspec.dependencies.is_empty());
assert!(pubspec.executables.contains_key("sample"));
Ok(())
}
}