From 5171b3cfe866ffee2204b2fa76a9d424df3b570b Mon Sep 17 00:00:00 2001 From: Ryan Draga Date: Fri, 3 Jul 2026 13:43:32 -0400 Subject: [PATCH] Add Zig backend linguo zig install/uninstall/list/use/upgrade/init/add/sync/which/run. Toolchains come from ziglang.org's release index (per-platform tarballs with inline sha256s; the tar.xz archives brought xz support to the shared extractor). Zig's Linux binaries are static, so musl systems work with the same build. Projects are build.zig/build.zig.zon driven through the zig tool: init runs zig init, add wraps zig fetch --save, sync is zig build --fetch. Pins fall back to .zigversion files and build.zig.zon's minimum_zig_version; zig participates in workspaces, the shell hook, auto-install, status, and upgrade sweeps, and the Alpine and Windows CI smoke tests cover it. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 11 ++++ Cargo.lock | 27 +++++++++ Cargo.toml | 1 + README.md | 23 ++++++- src/fetch.rs | 5 ++ src/main.rs | 66 +++++++++++++++++++- src/shell.rs | 5 +- src/status.rs | 2 +- src/store.rs | 3 +- src/workspace.rs | 5 +- src/zig/dist.rs | 90 +++++++++++++++++++++++++++ src/zig/mod.rs | 128 +++++++++++++++++++++++++++++++++++++++ src/zig/project.rs | 123 +++++++++++++++++++++++++++++++++++++ 13 files changed, 481 insertions(+), 8 deletions(-) create mode 100644 src/zig/dist.rs create mode 100644 src/zig/mod.rs create mode 100644 src/zig/project.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2396f0f..4f78e08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,11 @@ jobs: linguo tf use 1.13 linguo tf run -- terraform version + linguo zig install 0.16 + mkdir /tmp/zg && cd /tmp/zg + linguo zig use 0.16 + linguo zig run -- zig version + # node and go have no official musl builds: expect honest errors linguo node install 2>/dev/null && exit 1 || echo "node bails as expected" linguo go install 2>/dev/null && exit 1 || echo "go bails as expected" @@ -131,6 +136,12 @@ jobs: linguo ruby add rake linguo ruby which rake + linguo zig install 0.16 + mkdir "$RUNNER_TEMP/zig-demo" && cd "$RUNNER_TEMP/zig-demo" + linguo zig use 0.16 + linguo zig run -- zig version + linguo zig which + linguo rust install 1.96 mkdir "$RUNNER_TEMP/rust-demo" && cd "$RUNNER_TEMP/rust-demo" linguo rust init demo diff --git a/Cargo.lock b/Cargo.lock index 1088387..4dc5ab3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -819,6 +819,7 @@ dependencies = [ "tar", "tempfile", "toml_edit", + "xz2", "zip", ] @@ -855,6 +856,17 @@ dependencies = [ "byteorder", ] +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "memchr" version = "2.8.2" @@ -943,6 +955,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2051,6 +2069,15 @@ dependencies = [ "rustix", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 0a5431b..46aad06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,4 +44,5 @@ sha2 = "0.10" tar = "0.4" tempfile = "3" toml_edit = "0.22" +xz2 = { version = "0.1.7", features = ["static"] } zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/README.md b/README.md index f03580c..0ba8bc1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![License: MPL-2.0](https://img.shields.io/badge/license-MPL--2.0-blue)](LICENSE) Linguo is a cross-platform, multi-language runtime, package, and project manager: think -[`uv`](https://github.com/astral-sh/uv), but for **Python, Node.js, Ruby, Rust, Go, and Terraform/OpenTofu**. +[`uv`](https://github.com/astral-sh/uv), but for **Python, Node.js, Ruby, Rust, Go, Zig, and Terraform/OpenTofu**. One binary manages runtime versions, per-project pins, and project workflows for every language, with the same command shape everywhere: @@ -23,6 +23,7 @@ linguo | Ruby | [rv-ruby](https://github.com/spinel-coop/rv-ruby) relocatable builds; [RubyInstaller](https://rubyinstaller.org) on Windows | Gemfile via bundler (shared per-toolchain gems) | | Rust | [static.rust-lang.org](https://static.rust-lang.org) dist channels | Cargo.toml via cargo | | Go | [go.dev/dl](https://go.dev/dl) | go.mod via the go tool | +| Zig | [ziglang.org](https://ziglang.org/download) (static, musl-friendly) | build.zig.zon via the zig tool | | Terraform / OpenTofu | [releases.hashicorp.com](https://releases.hashicorp.com) / [get.opentofu.org](https://get.opentofu.org) | runtime-only (providers stay terraform's job) | Every download is sha256-verified against its upstream's published checksums. @@ -160,6 +161,11 @@ linguo rust component add rust-analyzer rust-src linguo rust target add wasm32-unknown-unknown ``` +Zig projects work the same way (`linguo zig init/sync/run/which`); `add` +wraps `zig fetch --save`, which takes archive URLs or paths rather than +registry names. + + ### Version pins Pins live in `linguo.toml`, resolved from the nearest one up the directory @@ -182,13 +188,24 @@ forward. Existing projects work without a `linguo.toml`: when none covers a language, linguo honors the ecosystem's own pin file (`.python-version`, `.nvmrc` / -`.node-version`, `.ruby-version`, go.mod's `toolchain`/`go` directives, and -`rust-toolchain(.toml)`), as long as it holds a plain version (or, for +`.node-version`, `.ruby-version`, go.mod's `toolchain`/`go` directives, +`rust-toolchain(.toml)`, `.zigversion`, and build.zig.zon's +`minimum_zig_version`), as long as it holds a plain version (or, for rust, a channel; node aliases like `lts/*` are still ignored). Precedence: project `linguo.toml`, then the ecosystem pin file, then the global config. ## Roadmap +Next up, in release order: + +- **1.2.0 Java and JDK-based languages**: JDK management plus Kotlin, + Groovy, and Scala. +- **1.3.0 PHP**. + +Then, under consideration: + +- **Unit-testing framework support** for the managed languages (pairs with + developer tool management below). - **Windows arm64 binaries**: the backends already map the targets; needs a release lane and CI coverage. - **Developer tool management**: install linters, formatters, and test diff --git a/src/fetch.rs b/src/fetch.rs index 397d4a5..708cfcf 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -98,6 +98,11 @@ fn unpack(archive: &[u8], name: &str, dir: &Path) -> Result<()> { .context("failed to extract archive") } else if name.ends_with(".zip") { extract_zip(archive, dir) + } else if name.ends_with(".tar.xz") { + let xz = xz2::read::XzDecoder::new(archive); + tar::Archive::new(xz) + .unpack(dir) + .context("failed to extract archive") } else if name.ends_with(".7z") { sevenz_rust::decompress(std::io::Cursor::new(archive), dir) .map_err(|e| anyhow::anyhow!("failed to extract 7z archive: {e}")) diff --git a/src/main.rs b/src/main.rs index be985f8..11d9352 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod store; mod terraform; mod versions; mod workspace; +mod zig; use clap::{Parser, Subcommand}; @@ -55,6 +56,11 @@ enum Command { #[command(subcommand)] command: RustCommand, }, + /// Manage Zig toolchains and projects + Zig { + #[command(subcommand)] + command: ZigCommand, + }, /// Manage Terraform toolchains #[command(alias = "tf")] Terraform { @@ -333,6 +339,49 @@ enum RustTargetCommand { Add { triples: Vec }, } +#[derive(Subcommand)] +enum ZigCommand { + /// Download and install a toolchain (latest stable if no version is given) + Install { version: Option }, + /// Remove an installed toolchain + Uninstall { version: String }, + /// List installed toolchains + List { + /// List versions available for download instead + #[arg(long)] + available: bool, + }, + /// Pin a version for this directory (or globally) + Use { + version: String, + #[arg(long)] + global: bool, + }, + /// Upgrade the pinned toolchain: newest release within the pin, or bump + /// the pin itself with --latest + Upgrade { + /// Bump the pin to the newest stable release (same granularity) + #[arg(long)] + latest: bool, + /// Uninstall older toolchains the pin previously matched + #[arg(long)] + prune: bool, + }, + /// Create a new project: zig init and version pin + Init, + /// zig fetch --save packages (archive URLs or paths) into the project + Add { packages: Vec }, + /// Fetch everything build.zig.zon declares (zig build --fetch) + Sync, + /// Show which executable a command resolves to (default: zig) + Which { command: Option }, + /// Run a command with the pinned toolchain on PATH + Run { + #[arg(trailing_var_arg = true, required = true)] + args: Vec, + }, +} + #[derive(Subcommand)] enum TerraformCommand { /// Download and install a toolchain (latest stable if no version is given) @@ -376,12 +425,13 @@ fn upgrade_all(latest: bool, prune: bool) -> anyhow::Result<()> { let mut failures: Vec = Vec::new(); let mut any = false; type UpgradeFn = fn(bool, bool) -> anyhow::Result<()>; - let languages: [(&str, UpgradeFn); 5] = [ + let languages: [(&str, UpgradeFn); 6] = [ (python::LANGUAGE, python::upgrade), (node::LANGUAGE, node::upgrade), (ruby::LANGUAGE, ruby::upgrade), (go::LANGUAGE, go::upgrade), (rust::LANGUAGE, rust::upgrade), + (zig::LANGUAGE, zig::upgrade), ]; for (language, upgrade) in languages { if store::resolve_pin(language, &cwd)?.is_none() { @@ -493,6 +543,20 @@ fn main() -> anyhow::Result<()> { RustTargetCommand::Add { triples } => rust::target_add(&triples), }, }, + Command::Zig { command } => match command { + ZigCommand::Install { version } => zig::install(version), + ZigCommand::Uninstall { version } => store::uninstall(zig::LANGUAGE, &version), + ZigCommand::List { available } => zig::list(available), + ZigCommand::Use { version, global } => { + store::use_version(zig::LANGUAGE, &version, global) + } + ZigCommand::Upgrade { latest, prune } => zig::upgrade(latest, prune), + ZigCommand::Init => zig::project::init(), + ZigCommand::Add { packages } => zig::project::add(&packages), + ZigCommand::Sync => zig::project::sync(), + ZigCommand::Which { command } => zig::project::which(command), + ZigCommand::Run { args } => zig::project::run(&args), + }, Command::Terraform { command } => match command { TerraformCommand::Install { version } => terraform::install(version), TerraformCommand::Uninstall { version } => terraform::uninstall(&version), diff --git a/src/shell.rs b/src/shell.rs index 729cda1..7abb8f4 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -8,7 +8,7 @@ use anyhow::Result; use clap::ValueEnum; use crate::config::PinSource; -use crate::{go, node, python, ruby, rust, terraform}; +use crate::{go, node, python, ruby, rust, terraform, zig}; /// Env var tracking which directories linguo has prepended to PATH, so they /// can be removed again when the active project changes. @@ -105,6 +105,9 @@ fn desired_dirs() -> Result> { if let Some((_, version)) = auto(go::LANGUAGE, &|v| go::install(Some(v.into())))? { dirs.push(go::dist::bin_dir(&go::toolchain_path(&version)?)); } + if let Some((_, version)) = auto(zig::LANGUAGE, &|v| zig::install(Some(v.into())))? { + dirs.push(zig::dist::bin_dir(&zig::toolchain_path(&version)?)); + } if let Some((_, toolchain)) = rust::resolve_active_auto(&cwd)? { dirs.push(rust::dist::bin_dir(&rust::toolchain_dir(&toolchain)?)); } diff --git a/src/status.rs b/src/status.rs index 9b0d734..51dc306 100644 --- a/src/status.rs +++ b/src/status.rs @@ -9,7 +9,7 @@ use crate::{rust, store, terraform}; /// Languages whose pins are plain version requests (including ecosystem /// pin-file fallbacks, which store::resolve_pin handles); terraform prints /// its own section because its pins carry a distribution. -const GENERIC_LANGUAGES: &[&str] = &["python", "node", "ruby", "go"]; +const GENERIC_LANGUAGES: &[&str] = &["python", "node", "ruby", "go", "zig"]; pub fn status() -> Result<()> { let cwd = std::env::current_dir()?; diff --git a/src/store.rs b/src/store.rs index 583e9dc..73a1825 100644 --- a/src/store.rs +++ b/src/store.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result, bail}; use crate::config::{self, Pin, PinSource}; use crate::versions::{Version, VersionReq}; -use crate::{go, node, python, ruby, rust}; +use crate::{go, node, python, ruby, rust, zig}; pub fn toolchain_path(language: &str, version: &Version) -> Result { Ok(config::toolchains_dir(language)?.join(version.to_string())) @@ -55,6 +55,7 @@ fn fallback_pin(language: &str, cwd: &Path) -> Result> { ruby::LANGUAGE => ruby::fallback_pin(cwd), go::LANGUAGE => go::fallback_pin(cwd), rust::LANGUAGE => rust::fallback_pin(cwd), + zig::LANGUAGE => zig::fallback_pin(cwd), _ => Ok(None), } } diff --git a/src/workspace.rs b/src/workspace.rs index 4b9d073..d1b77c5 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use crate::{go, node, python, ruby, rust, store, terraform}; +use crate::{go, node, python, ruby, rust, store, terraform, zig}; /// Project manifests that make a directory a workspace member, and the /// language each belongs to. @@ -21,6 +21,7 @@ const MANIFESTS: &[(&str, &str)] = &[ ("Gemfile", "ruby"), ("Cargo.toml", "rust"), ("go.mod", "go"), + ("build.zig", "zig"), ]; /// Directories never descended into during discovery. @@ -176,6 +177,7 @@ fn ensure_toolchain(language: &str, dir: &Path) -> Result { "node" => node::install(raw)?, "ruby" => ruby::install(raw)?, "go" => go::install(raw)?, + "zig" => zig::install(raw)?, other => bail!("no installer for {other}"), } Ok(true) @@ -195,6 +197,7 @@ fn sync_language(language: &str, dir: &Path) -> Result<()> { "ruby" => ruby::project::sync_in(dir), "rust" => rust::project::sync_in(dir), "go" => go::project::sync_in(dir), + "zig" => zig::project::sync_in(dir), "terraform" => Ok(()), other => bail!("no sync for {other}"), } diff --git a/src/zig/dist.rs b/src/zig/dist.rs new file mode 100644 index 0000000..21f530e --- /dev/null +++ b/src/zig/dist.rs @@ -0,0 +1,90 @@ +//! Fetching Zig toolchains from ziglang.org's release index. Zig's Linux +//! binaries are static, so one build serves glibc and musl alike. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +use crate::fetch; +use crate::versions::Version; + +const INDEX_URL: &str = "https://ziglang.org/download/index.json"; + +/// Platform key in the index, e.g. `aarch64-macos`. +fn platform() -> Result { + let os = match std::env::consts::OS { + "macos" => "macos", + "linux" => "linux", + "windows" => "windows", + other => bail!("unsupported platform for zig: {other}"), + }; + let arch = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + other => bail!("unsupported architecture for zig: {other}"), + }; + Ok(format!("{arch}-{os}")) +} + +#[derive(Debug, Deserialize)] +struct Entry { + tarball: String, + shasum: String, +} + +pub struct AvailableBuild { + pub version: Version, + url: String, + shasum: String, +} + +/// All stable Zig versions with a build for the current platform, ascending. +/// The `master` nightly entry doesn't parse as semver and is skipped. +pub fn fetch_available() -> Result> { + let platform = platform()?; + let index: HashMap> = fetch::client()? + .get(INDEX_URL) + .send() + .context("failed to query ziglang.org downloads")? + .error_for_status() + .context("zig download index query failed")? + .json() + .context("failed to parse zig download index")?; + + let mut builds: Vec = index + .into_iter() + .filter_map(|(name, targets)| { + let version: Version = name.parse().ok()?; + let entry: Entry = serde_json::from_value(targets.get(&platform)?.clone()).ok()?; + Some(AvailableBuild { + version, + url: entry.tarball, + shasum: entry.shasum.to_ascii_lowercase(), + }) + }) + .collect(); + builds.sort_by_key(|b| b.version); + Ok(builds) +} + +/// Download the build, verify its checksum, and extract it so that +/// `dest/zig` exists (the binary lives at the archive's top level). +pub fn install_build(build: &AvailableBuild, dest: &Path) -> Result<()> { + let archive_name = build.url.rsplit('/').next().unwrap_or(&build.url); + eprintln!("downloading {}", build.url); + let archive = fetch::download(&fetch::client()?, &build.url)?; + fetch::verify_sha256(&archive, &build.shasum, archive_name)?; + + // Archives hold a single top-level dir named after the archive. + let subdir = archive_name + .trim_end_matches(".tar.xz") + .trim_end_matches(".zip"); + fetch::extract_archive_subdir(&archive, archive_name, subdir, dest) +} + +/// The zig binary sits at the toolchain root. +pub fn bin_dir(toolchain: &Path) -> PathBuf { + toolchain.to_path_buf() +} diff --git a/src/zig/mod.rs b/src/zig/mod.rs new file mode 100644 index 0000000..0794eeb --- /dev/null +++ b/src/zig/mod.rs @@ -0,0 +1,128 @@ +pub mod dist; +pub mod project; + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use crate::config::Pin; +use crate::store; +use crate::versions::{Version, VersionReq}; + +pub const LANGUAGE: &str = "zig"; + +pub fn toolchain_path(version: &Version) -> Result { + store::toolchain_path(LANGUAGE, version) +} + +pub fn upgrade(latest: bool, prune: bool) -> Result<()> { + let available: Vec = dist::fetch_available()?.iter().map(|b| b.version).collect(); + let newest = available.last().copied(); + store::upgrade(LANGUAGE, &available, newest, latest, prune, &|v| { + install(Some(v.to_string())) + }) +} + +/// Community convention: the nearest `.zigversion` holding a plain version, +/// or build.zig.zon's `minimum_zig_version` field. +pub fn fallback_pin(cwd: &Path) -> Result> { + for dir in cwd.ancestors() { + let path = dir.join(".zigversion"); + if let Some(raw) = store::read_version_file(&path)? { + return Ok(store::file_pin(&raw, &path)); + } + let zon = dir.join("build.zig.zon"); + if zon.is_file() { + let text = std::fs::read_to_string(&zon) + .with_context(|| format!("failed to read {}", zon.display()))?; + return Ok(zon_minimum_zig_version(&text).and_then(|v| store::file_pin(&v, &zon))); + } + } + Ok(None) +} + +/// Extract `.minimum_zig_version = "X.Y.Z"` from build.zig.zon (zon is zig +/// syntax, so this is a targeted line scan, not a full parser). +fn zon_minimum_zig_version(text: &str) -> Option { + for line in text.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix(".minimum_zig_version") { + let value = rest.trim_start_matches(['=', ' ', '\t']); + let value = value.trim_start_matches('"'); + if let Some(end) = value.find('"') { + return Some(value[..end].to_string()); + } + } + } + None +} + +pub fn install(request: Option) -> Result<()> { + let builds = dist::fetch_available()?; + if builds.is_empty() { + bail!("no builds available for this platform"); + } + + let build = match &request { + Some(raw) => { + let req: VersionReq = raw.parse()?; + builds + .iter() + .rev() + .find(|b| req.matches(&b.version)) + .with_context(|| format!("no available build matches '{raw}'"))? + } + None => builds.last().unwrap(), + }; + + let dest = toolchain_path(&build.version)?; + if dest.exists() { + eprintln!("zig {} is already installed", build.version); + return Ok(()); + } + std::fs::create_dir_all(dest.parent().unwrap()) + .with_context(|| format!("failed to create {}", dest.parent().unwrap().display()))?; + + dist::install_build(build, &dest)?; + eprintln!("installed zig {} to {}", build.version, dest.display()); + Ok(()) +} + +pub fn list(available: bool) -> Result<()> { + if !available { + return store::list_installed(LANGUAGE); + } + let builds = dist::fetch_available()?; + if builds.is_empty() { + println!("no builds available for this platform"); + return Ok(()); + } + let installed = store::installed_versions(LANGUAGE)?; + for build in builds { + let marker = if installed.contains(&build.version) { + " (installed)" + } else { + "" + }; + println!("{}{marker}", build.version); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_zon_minimum_zig_version() { + let zon = r#".{ + .name = .demo, + .version = "0.0.1", + .minimum_zig_version = "0.15.2", + .dependencies = .{}, +} +"#; + assert_eq!(zon_minimum_zig_version(zon), Some("0.15.2".to_string())); + assert_eq!(zon_minimum_zig_version(".{ .name = .demo }"), None); + } +} diff --git a/src/zig/project.rs b/src/zig/project.rs new file mode 100644 index 0000000..a00f94b --- /dev/null +++ b/src/zig/project.rs @@ -0,0 +1,123 @@ +//! Project management: build.zig/build.zig.zon, driven through the zig tool. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +use crate::store; + +/// Nearest ancestor directory (including `start`) containing a build.zig. +fn find_project_root(start: &Path) -> Option { + start + .ancestors() + .find(|dir| dir.join("build.zig").is_file()) + .map(Path::to_path_buf) +} + +fn project_root() -> Result { + let cwd = std::env::current_dir()?; + find_project_root(&cwd) + .context("no build.zig found in this directory or any parent (run `linguo zig init` first)") +} + +fn toolchain_bin(dir: &Path) -> Result { + let version = store::required_toolchain(super::LANGUAGE, dir)?; + Ok(super::dist::bin_dir(&super::toolchain_path(&version)?)) +} + +fn prepended_path(dirs: &[PathBuf]) -> Result { + let current = std::env::var_os("PATH").unwrap_or_default(); + std::env::join_paths(dirs.iter().cloned().chain(std::env::split_paths(¤t))) + .context("invalid PATH entry") +} + +/// The pinned toolchain's zig, with its dir on PATH. +fn zig(dir: &Path) -> Result { + let bin = toolchain_bin(dir)?; + let mut cmd = Command::new(bin.join(crate::exec::exe("zig"))); + cmd.current_dir(dir).env("PATH", prepended_path(&[bin])?); + Ok(cmd) +} + +fn run_checked(cmd: &mut Command, what: &str) -> Result<()> { + let status = cmd + .status() + .with_context(|| format!("failed to run {what}"))?; + if !status.success() { + bail!("{what} failed"); + } + Ok(()) +} + +pub fn init() -> Result<()> { + let cwd = std::env::current_dir()?; + if cwd.join("build.zig").exists() { + bail!("{} already exists", cwd.join("build.zig").display()); + } + + let version = store::pick_project_version(super::LANGUAGE, &cwd)?; + + // Not the zig() helper: the pin this project will use is written below. + let bin = super::dist::bin_dir(&super::toolchain_path(&version)?); + let mut cmd = Command::new(bin.join(crate::exec::exe("zig"))); + cmd.current_dir(&cwd).env("PATH", prepended_path(&[bin])?); + run_checked(cmd.arg("init"), "zig init")?; + + let req = crate::versions::VersionReq::MajorMinor(version.major, version.minor); + crate::config::write_pin( + &cwd.join(crate::config::PIN_FILE), + super::LANGUAGE, + &req.to_string(), + )?; + + println!("initialized zig project with zig {version}"); + Ok(()) +} + +/// `zig fetch --save` a package (zig's package manager takes URLs or paths). +pub fn add(packages: &[String]) -> Result<()> { + if packages.is_empty() { + bail!("no packages given (zig fetch takes archive URLs or paths)"); + } + let root = project_root()?; + for package in packages { + run_checked( + zig(&root)?.args(["fetch", "--save"]).arg(package), + "zig fetch --save", + )?; + } + Ok(()) +} + +pub fn sync() -> Result<()> { + sync_in(&project_root()?) +} + +/// Sync a specific project directory (used by workspace sync): fetch the +/// dependency tree without building. +pub fn sync_in(root: &Path) -> Result<()> { + run_checked(zig(root)?.args(["build", "--fetch"]), "zig build --fetch") +} + +/// Print the path of the executable a command resolves to (default: zig). +pub fn which(command: Option) -> Result<()> { + let cwd = std::env::current_dir()?; + let name = command.unwrap_or_else(|| "zig".to_string()); + if let Some(path) = crate::exec::find_in_dir(&toolchain_bin(&cwd)?, &name) { + println!("{}", path.display()); + return Ok(()); + } + bail!("'{name}' not found in the pinned toolchain"); +} + +/// Run a command with the pinned toolchain on PATH. +pub fn run(args: &[String]) -> Result<()> { + let (program, rest) = args.split_first().context("no command given")?; + let cwd = std::env::current_dir()?; + let bin = toolchain_bin(&cwd)?; + + let mut cmd = Command::new(program); + cmd.args(rest).env("PATH", prepended_path(&[bin])?); + crate::exec::exec(cmd, program) +}