Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -23,6 +23,7 @@ linguo <language> <command>
| 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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"))
Expand Down
66 changes: 65 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod store;
mod terraform;
mod versions;
mod workspace;
mod zig;

use clap::{Parser, Subcommand};

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -333,6 +339,49 @@ enum RustTargetCommand {
Add { triples: Vec<String> },
}

#[derive(Subcommand)]
enum ZigCommand {
/// Download and install a toolchain (latest stable if no version is given)
Install { version: Option<String> },
/// 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<String> },
/// Fetch everything build.zig.zon declares (zig build --fetch)
Sync,
/// Show which executable a command resolves to (default: zig)
Which { command: Option<String> },
/// Run a command with the pinned toolchain on PATH
Run {
#[arg(trailing_var_arg = true, required = true)]
args: Vec<String>,
},
}

#[derive(Subcommand)]
enum TerraformCommand {
/// Download and install a toolchain (latest stable if no version is given)
Expand Down Expand Up @@ -376,12 +425,13 @@ fn upgrade_all(latest: bool, prune: bool) -> anyhow::Result<()> {
let mut failures: Vec<String> = 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() {
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -105,6 +105,9 @@ fn desired_dirs() -> Result<Vec<PathBuf>> {
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)?));
}
Expand Down
2 changes: 1 addition & 1 deletion src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;
Expand Down
3 changes: 2 additions & 1 deletion src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
Ok(config::toolchains_dir(language)?.join(version.to_string()))
Expand Down Expand Up @@ -55,6 +55,7 @@ fn fallback_pin(language: &str, cwd: &Path) -> Result<Option<Pin>> {
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),
}
}
Expand Down
5 changes: 4 additions & 1 deletion src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -176,6 +177,7 @@ fn ensure_toolchain(language: &str, dir: &Path) -> Result<bool> {
"node" => node::install(raw)?,
"ruby" => ruby::install(raw)?,
"go" => go::install(raw)?,
"zig" => zig::install(raw)?,
other => bail!("no installer for {other}"),
}
Ok(true)
Expand All @@ -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}"),
}
Expand Down
Loading
Loading