use std::collections::BTreeMap;
use std::env::consts::EXE_EXTENSION;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
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::git::GitCommandExt;
use crate::hook::{Hook, InstallInfo, InstalledHook};
use crate::hook_entry::PreparedHookEntry;
use crate::languages::{ExecutionEnvironment, LanguageBackend};
use crate::process::Cmd;
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)
}
#[async_trait::async_trait(?Send)]
impl LanguageBackend for Dart {
async fn install(
&self,
store: &Store,
hook: Arc<Hook>,
install_cwd: &Path,
reporter: &HookInstallReporter,
) -> Result<InstalledHook> {
let progress = reporter.on_install_start(&hook);
let mut info = InstallInfo::new(&hook, &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,
install_cwd,
)
.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(())
}
fn execution_environment(
&self,
_store: &Store,
hook: &InstalledHook,
) -> Result<ExecutionEnvironment> {
let env_dir = hook.env_path().expect("Dart must have env path");
let new_path = prepend_paths(&[&bin_path(env_dir)]).context("Failed to join PATH")?;
let mut environment = ExecutionEnvironment::new();
environment
.set_path(&new_path)
.env(EnvVars::PUB_CACHE, env_dir);
Ok(environment)
}
fn prepare_hook_entry(
&self,
_store: &Store,
hook: &InstalledHook,
environment: &ExecutionEnvironment,
) -> Result<PreparedHookEntry> {
let env_dir = hook.env_path().expect("Dart must have env path");
let packages_path = package_config_path(env_dir);
let repo_path = hook.repo_path().unwrap_or(hook.work_dir());
let argv_entry = hook.entry.expect_argv_entry();
let mut entry = argv_entry.resolve(repo_path, environment.path(hook), hook.work_dir())?;
if packages_path.exists()
&& let Some(index) = packages_arg_insert_position(&entry, &hook.args)
{
entry.argv_mut().insert(
index,
format!("--packages={}", packages_path.display()).into(),
);
}
Ok(entry)
}
}
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: &[OsString], hook_args: &[String]) -> Option<usize> {
fn is_dart_binary(arg: &OsStr) -> 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: &OsStr) -> bool {
arg.to_str()
.is_some_and(|arg| arg == "-p" || arg == "--packages" || arg.starts_with("--packages="))
}
fn is_dart_script(arg: &OsStr) -> 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.as_ref()))?;
for (index, arg) in entry
.iter()
.map(AsRef::as_ref)
.chain(hook_args.iter().map(OsStr::new))
.enumerate()
.skip(dart_index + 1)
{
if has_packages_arg(arg) {
return None;
}
if !arg.to_str().is_some_and(|arg| 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,
install_cwd: &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)
.current_dir(install_cwd)
.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: &[String],
) -> Pubspec {
let mut resolved_dependencies = BTreeMap::new();
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: &[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")
.sanitize_git_repo_env()
.check(true)
.output()
.await?;
Ok(())
}
async fn install_from_pubspec(
dart: &Path,
env_path: &Path,
source_path: &Path,
dependencies: &[String],
install_cwd: &Path,
) -> 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,
install_cwd,
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 make_entry(values: &[&str]) -> Vec<OsString> {
values.iter().map(OsString::from).collect()
}
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 = make_entry(&["/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(&make_entry(&["dart", "--packages=custom", "run"]), &[]),
None
);
assert_eq!(
packages_arg_insert_position(
&make_entry(&["dart", "--packages", "custom", "run"]),
&[]
),
None
);
assert_eq!(
packages_arg_insert_position(
&make_entry(&["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(&make_entry(&["dart", "run", "tool.dart", "-p"]), &[]),
Some(1)
);
assert_eq!(
packages_arg_insert_position(
&make_entry(&["dart", "tool.dart", "--packages=script-value"]),
&[]
),
Some(1)
);
assert_eq!(
packages_arg_insert_position(
&make_entry(&["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(
&make_entry(&["dart"]),
&strings(&["run", "bin/hook.dart"])
),
Some(1)
);
assert_eq!(
packages_arg_insert_position(&make_entry(&["dart"]), &strings(&["bin/hook.dart"])),
Some(1)
);
assert_eq!(
packages_arg_insert_position(
&make_entry(&["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 = Vec::new();
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(())
}
}