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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ jobs:
linguo zig use 0.16
linguo zig run -- zig version

linguo php install 8.5
mkdir /tmp/ph && cd /tmp/ph
linguo php init
linguo php run -- php -r "echo \"php on musl ok\n\";"
linguo php run -- composer --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 @@ -136,6 +142,13 @@ jobs:
linguo ruby add rake
linguo ruby which rake

linguo php install 8.5
mkdir "$RUNNER_TEMP/php-demo" && cd "$RUNNER_TEMP/php-demo"
linguo php init
linguo php run -- php -r "echo 'php on windows ok';"
linguo php run -- composer --version
linguo php which

linguo zig install 0.16
mkdir "$RUNNER_TEMP/zig-demo" && cd "$RUNNER_TEMP/zig-demo"
linguo zig use 0.16
Expand Down
11 changes: 6 additions & 5 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, Zig, and Terraform/OpenTofu**.
[`uv`](https://github.com/astral-sh/uv), but for **Python, Node.js, Ruby, PHP, 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 @@ -24,6 +24,7 @@ linguo <language> <command>
| 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 |
| PHP | [static-php-cli](https://dl.static-php.dev) builds (static); [windows.php.net](https://windows.php.net) on Windows | composer.json via bundled Composer |
| 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 @@ -125,6 +126,7 @@ linguo python init # pyproject.toml + linguo.toml pin + .venv
linguo python add "requests>=2.31"
linguo node add typescript && linguo node run -- tsc --version
linguo ruby add rails
linguo php add monolog/monolog # composer, bundled with every php toolchain
linguo rust add serde && linguo rust run -- cargo build
linguo go add rsc.io/quote
linguo <lang> remove <pkg>
Expand Down Expand Up @@ -189,18 +191,17 @@ 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,
`rust-toolchain(.toml)`, `.zigversion`, and build.zig.zon's
`minimum_zig_version`), as long as it holds a plain version (or, for
`rust-toolchain(.toml)`, `.zigversion`, build.zig.zon's
`minimum_zig_version`, and `.php-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,
- **1.3.0 Java and JDK-based languages**: JDK management plus Kotlin,
Groovy, and Scala.
- **1.3.0 PHP**.

Then, under consideration:

Expand Down
69 changes: 68 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod exec;
mod fetch;
mod go;
mod node;
mod php;
mod python;
mod ruby;
mod rust;
Expand Down Expand Up @@ -56,6 +57,11 @@ enum Command {
#[command(subcommand)]
command: RustCommand,
},
/// Manage PHP toolchains and projects
Php {
#[command(subcommand)]
command: PhpCommand,
},
/// Manage Zig toolchains and projects
Zig {
#[command(subcommand)]
Expand Down Expand Up @@ -339,6 +345,51 @@ enum RustTargetCommand {
Add { triples: Vec<String> },
}

#[derive(Subcommand)]
enum PhpCommand {
/// 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: composer.json and version pin
Init,
/// composer require packages into the project
Add { packages: Vec<String> },
/// composer remove packages from the project
Remove { packages: Vec<String> },
/// Install everything composer.json declares
Sync,
/// Show which executable a command resolves to (default: php)
Which { command: Option<String> },
/// Run a command with vendor/bin and the toolchain on PATH
Run {
#[arg(trailing_var_arg = true, required = true)]
args: Vec<String>,
},
}

#[derive(Subcommand)]
enum ZigCommand {
/// Download and install a toolchain (latest stable if no version is given)
Expand Down Expand Up @@ -425,13 +476,14 @@ 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); 6] = [
let languages: [(&str, UpgradeFn); 7] = [
(python::LANGUAGE, python::upgrade),
(node::LANGUAGE, node::upgrade),
(ruby::LANGUAGE, ruby::upgrade),
(go::LANGUAGE, go::upgrade),
(rust::LANGUAGE, rust::upgrade),
(zig::LANGUAGE, zig::upgrade),
(php::LANGUAGE, php::upgrade),
];
for (language, upgrade) in languages {
if store::resolve_pin(language, &cwd)?.is_none() {
Expand Down Expand Up @@ -543,6 +595,21 @@ fn main() -> anyhow::Result<()> {
RustTargetCommand::Add { triples } => rust::target_add(&triples),
},
},
Command::Php { command } => match command {
PhpCommand::Install { version } => php::install(version),
PhpCommand::Uninstall { version } => store::uninstall(php::LANGUAGE, &version),
PhpCommand::List { available } => php::list(available),
PhpCommand::Use { version, global } => {
store::use_version(php::LANGUAGE, &version, global)
}
PhpCommand::Upgrade { latest, prune } => php::upgrade(latest, prune),
PhpCommand::Init => php::project::init(),
PhpCommand::Add { packages } => php::project::add(&packages),
PhpCommand::Remove { packages } => php::project::remove(&packages),
PhpCommand::Sync => php::project::sync(),
PhpCommand::Which { command } => php::project::which(command),
PhpCommand::Run { args } => php::project::run(&args),
},
Command::Zig { command } => match command {
ZigCommand::Install { version } => zig::install(version),
ZigCommand::Uninstall { version } => store::uninstall(zig::LANGUAGE, &version),
Expand Down
184 changes: 184 additions & 0 deletions src/php/dist.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! Fetching PHP builds: static-php-cli's static builds on unix (musl-static
//! on Linux, so every distro works), and official windows.php.net zips on
//! Windows. Composer ships into every toolchain.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde::Deserialize;

use crate::fetch;
use crate::versions::Version;

const STATIC_PHP_BASE: &str = "https://dl.static-php.dev";
const STATIC_PHP_INDEX: &str = "https://dl.static-php.dev/static-php-cli/common/?format=json";
const WINDOWS_INDEX: &str = "https://downloads.php.net/~windows/releases/releases.json";
const COMPOSER_URL: &str = "https://getcomposer.org/download/latest-stable/composer.phar";

pub struct AvailableBuild {
pub version: Version,
url: String,
/// sha256 when the source publishes one (windows.php.net does;
/// static-php-cli doesn't).
sha256: Option<String>,
}

#[derive(Debug, Deserialize)]
struct StaticPhpEntry {
is_dir: bool,
full_path: String,
name: String,
}

fn fetch_static_php() -> Result<Vec<AvailableBuild>> {
let os = match std::env::consts::OS {
"macos" => "macos",
"linux" => "linux",
other => bail!("unsupported platform for php: {other}"),
};
let arch = match std::env::consts::ARCH {
"x86_64" => "x86_64",
"aarch64" => "aarch64",
other => bail!("unsupported architecture for php: {other}"),
};
let suffix = format!("-cli-{os}-{arch}.tar.gz");

let entries: Vec<StaticPhpEntry> = fetch::client()?
.get(STATIC_PHP_INDEX)
.send()
.context("failed to query dl.static-php.dev")?
.error_for_status()
.context("static-php index query failed")?
.json()
.context("failed to parse static-php index")?;

let mut builds: Vec<AvailableBuild> = entries
.into_iter()
.filter(|e| !e.is_dir)
.filter_map(|e| {
let version: Version = e
.name
.strip_prefix("php-")?
.strip_suffix(&suffix)?
.parse()
.ok()?;
Some(AvailableBuild {
version,
url: format!("{STATIC_PHP_BASE}{}", e.full_path),
sha256: None,
})
})
.collect();
builds.sort_by_key(|b| b.version);
Ok(builds)
}

#[derive(Debug, Deserialize)]
struct WindowsZip {
path: String,
sha256: String,
}

fn fetch_windows_php() -> Result<Vec<AvailableBuild>> {
if std::env::consts::ARCH != "x86_64" {
bail!("windows.php.net publishes x64 builds only; no official arm64 PHP for Windows yet");
}
let index: std::collections::HashMap<String, serde_json::Value> = fetch::client()?
.get(WINDOWS_INDEX)
.send()
.context("failed to query windows.php.net releases")?
.error_for_status()
.context("windows php release query failed")?
.json()
.context("failed to parse windows php releases")?;

let mut builds: Vec<AvailableBuild> = index
.into_values()
.filter_map(|branch| {
// Non-thread-safe x64 is the CLI-appropriate variant.
let zip: WindowsZip =
serde_json::from_value(branch.get("nts-vs17-x64")?.get("zip")?.clone()).ok()?;
let version: Version = branch.get("version")?.as_str()?.parse().ok()?;
Some(AvailableBuild {
version,
url: format!("https://downloads.php.net/~windows/releases/{}", zip.path),
sha256: Some(zip.sha256.to_ascii_lowercase()),
})
})
.collect();
builds.sort_by_key(|b| b.version);
Ok(builds)
}

/// All PHP versions available for the current platform, ascending.
pub fn fetch_available() -> Result<Vec<AvailableBuild>> {
if std::env::consts::OS == "windows" {
fetch_windows_php()
} else {
fetch_static_php()
}
}

/// Download the build (and composer), verify what can be verified, and lay
/// out the toolchain so `dest/php` (or php.exe) and `dest/composer.phar`
/// exist. Both archive styles hold their files at the top level.
pub fn install_build(build: &AvailableBuild, dest: &Path) -> Result<()> {
let http = fetch::client()?;
let archive_name = build.url.rsplit('/').next().unwrap_or(&build.url);

eprintln!("downloading {}", build.url);
let archive = fetch::download(&http, &build.url)?;
match &build.sha256 {
Some(expected) => fetch::verify_sha256(&archive, expected, archive_name)?,
None => eprintln!("warning: no published checksum for this build; skipping verification"),
}
fetch::extract_archive_root(&archive, archive_name, dest)?;

// Composer rides along in every toolchain, verified against its
// published sha256.
eprintln!("downloading {COMPOSER_URL}");
let phar = fetch::download(&http, COMPOSER_URL)?;
let sums = http
.get(format!("{COMPOSER_URL}.sha256sum"))
.send()
.and_then(|r| r.error_for_status())
.context("failed to fetch composer checksum")?
.text()?;
let expected = sums
.split_whitespace()
.next()
.context("empty composer checksum")?;
fetch::verify_sha256(&phar, expected, "composer.phar")?;
std::fs::write(dest.join("composer.phar"), &phar)
.with_context(|| format!("failed to write {}", dest.join("composer.phar").display()))?;

// A `composer` wrapper so the shell hook's PATH offers it directly.
if cfg!(windows) {
std::fs::write(
dest.join("composer.bat"),
"@echo off\r\n\"%~dp0php.exe\" \"%~dp0composer.phar\" %*\r\n",
)?;
} else {
let wrapper = dest.join("composer");
std::fs::write(
&wrapper,
"#!/bin/sh\nexec \"$(dirname \"$0\")/php\" \"$(dirname \"$0\")/composer.phar\" \"$@\"\n",
)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755))?;
}
}
Ok(())
}

/// Both archive styles put executables at the toolchain root.
pub fn bin_dir(toolchain: &Path) -> PathBuf {
toolchain.to_path_buf()
}

/// The php interpreter's file name.
pub fn php_exe() -> &'static str {
if cfg!(windows) { "php.exe" } else { "php" }
}
Loading
Loading