From 54549f740a7928c3b2c6500bcc8264973793a1b9 Mon Sep 17 00:00:00 2001 From: Ryan Draga Date: Sat, 4 Jul 2026 10:35:07 -0400 Subject: [PATCH 1/3] Add the JVM stack: Temurin JDKs plus Kotlin, Groovy, and Scala linguo jvm manages Eclipse Temurin JDKs from the Adoptium API (per-platform tarballs with inline sha256s, including native alpine-linux musl builds; macOS Contents/Home nesting handled; install and upgrade --latest default to the newest LTS). Pins fall back to .java-version, and run/which/the shell hook wire JAVA_HOME, which the hook now exports on entry and unsets on leave. Kotlin, Groovy, and Scala are runtime-only JVM languages sharing one engine (jvmlang): Kotlin compiler zips from JetBrains releases and Scala 3 tarballs verified against GitHub asset digests, Apache Groovy zips against Apache's .sha256 sidecars. Each language resolves its JDK through a per-language [jvm] binding written by `linguo set-jvm ` (project linguo.toml or --global), falling back to the directory's plain jvm pin, so mixed-JDK setups work per language. Alpine and Windows CI smoke tests cover the jvm and groovy flows. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 17 ++++ README.md | 26 ++++-- src/config.rs | 53 +++++++++++ src/groovy.rs | 63 +++++++++++++ src/jvm/dist.rs | 165 +++++++++++++++++++++++++++++++++ src/jvm/mod.rs | 161 ++++++++++++++++++++++++++++++++ src/jvmlang.rs | 194 +++++++++++++++++++++++++++++++++++++++ src/kotlin.rs | 66 +++++++++++++ src/main.rs | 133 ++++++++++++++++++++++++++- src/scala.rs | 67 ++++++++++++++ src/shell.rs | 68 +++++++++++++- src/status.rs | 4 +- src/store.rs | 3 +- 13 files changed, 1004 insertions(+), 16 deletions(-) create mode 100644 src/groovy.rs create mode 100644 src/jvm/dist.rs create mode 100644 src/jvm/mod.rs create mode 100644 src/jvmlang.rs create mode 100644 src/kotlin.rs create mode 100644 src/scala.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eea4cd0..009207e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,14 @@ jobs: linguo php run -- php -r "echo \"php on musl ok\n\";" linguo php run -- composer --version + linguo jvm install 21 + mkdir /tmp/jv && cd /tmp/jv + linguo jvm use 21 + linguo jvm run -- java -version + linguo groovy install + linguo groovy use 5 + linguo groovy run -- groovy -e "println \"groovy on musl ok\" + # 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" @@ -156,6 +164,15 @@ jobs: linguo php run -- composer --version linguo php which + linguo jvm install 21 + mkdir "$RUNNER_TEMP/jvm-demo" && cd "$RUNNER_TEMP/jvm-demo" + linguo jvm use 21 + linguo jvm run -- java -version + linguo groovy install + linguo groovy use 5 + linguo groovy run -- groovy -e "println 'groovy on windows ok'" + linguo groovy which + linguo zig install 0.16 mkdir "$RUNNER_TEMP/zig-demo" && cd "$RUNNER_TEMP/zig-demo" linguo zig use 0.16 diff --git a/README.md b/README.md index 9a90b50..a9edf4d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,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, PHP, Rust, Go, Zig, and Terraform/OpenTofu**. +[`uv`](https://github.com/astral-sh/uv), but for **Python, Node.js, Ruby, PHP, Rust, Go, Zig, the JVM stack (Java, Kotlin, Groovy, Scala), and Terraform/OpenTofu**. One binary manages runtime versions, per-project pins, and project workflows for every language, with the same command shape everywhere: @@ -26,6 +26,8 @@ linguo | 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 | +| JVM (Temurin JDKs) | [Adoptium API](https://adoptium.net) (incl. Alpine builds) | runtime-only; owns JAVA_HOME | +| Kotlin / Groovy / Scala | JetBrains & Scala GitHub releases; Apache dist | runtime-only, layered on a JVM via `set-jvm` | | 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. @@ -174,6 +176,19 @@ linguo rust component add rust-analyzer rust-src linguo rust target add wasm32-unknown-unknown ``` +The JVM stack layers: JDKs (Eclipse Temurin) install and pin like any +runtime and own JAVA_HOME; Kotlin, Groovy, and Scala are toolchains that run +against the directory's jvm pin, or against a per-language binding when you +need mixed JDKs in one place: + +```sh +linguo jvm install 21 # latest LTS if no version is given +linguo jvm use 21 # every JVM language here uses it... +linguo kotlin install && linguo kotlin use 2.4 +linguo groovy set-jvm 17 # ...except groovy, now bound to 17 +linguo kotlin run -- kotlinc app.kt -include-runtime -d app.jar +``` + 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. @@ -203,18 +218,13 @@ 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`, build.zig.zon's -`minimum_zig_version`, and `.php-version`), as long as it holds a plain version (or, for +`minimum_zig_version`, `.php-version`, and `.java-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.3.0 Java and JDK-based languages**: JDK management plus Kotlin, - Groovy, and Scala. - -Then, under consideration: +Under consideration: - **Unit-testing framework support** for the managed languages (pairs with developer tool management below). diff --git a/src/config.rs b/src/config.rs index 9704367..0ad4cd0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -111,6 +111,59 @@ pub fn resolve_pin(language: &str, cwd: &Path) -> Result> { global_pin(language) } +/// The JVM binding for a JVM-based language: nearest project linguo.toml's +/// `[jvm] = "..."`, then the global config's. +pub fn jvm_binding(language: &str, cwd: &Path) -> Result> { + let read = |path: &Path| -> Result> { + if !path.is_file() { + return Ok(None); + } + let text = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let doc: DocumentMut = text + .parse() + .with_context(|| format!("failed to parse {}", path.display()))?; + Ok(doc + .get("jvm") + .and_then(|t| t.get(language)) + .and_then(|v| v.as_str()) + .map(str::to_string)) + }; + for dir in cwd.ancestors() { + let candidate = dir.join(PIN_FILE); + if candidate.is_file() + && let Some(binding) = read(&candidate)? + { + return Ok(Some(binding)); + } + } + read(&linguo_root()?.join(GLOBAL_CONFIG)) +} + +/// Set `[jvm] = ""` in `path`, creating the file if needed. +pub fn write_jvm_binding(path: &Path, language: &str, raw: &str) -> Result<()> { + let mut doc: DocumentMut = match std::fs::read_to_string(path) { + Ok(text) => text + .parse() + .with_context(|| format!("failed to parse {}", path.display()))?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(), + Err(err) => { + return Err(err).with_context(|| format!("failed to read {}", path.display())); + } + }; + if doc.get("jvm").is_none() { + doc["jvm"] = Item::Table(Table::new()); + } + doc["jvm"][language] = value(raw); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + std::fs::write(path, doc.to_string()) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + /// Set `[runtimes] = ""` in `path`, creating the file if needed. pub fn write_pin(path: &Path, language: &str, raw: &str) -> Result<()> { let mut doc: DocumentMut = match std::fs::read_to_string(path) { diff --git a/src/groovy.rs b/src/groovy.rs new file mode 100644 index 0000000..c27fba2 --- /dev/null +++ b/src/groovy.rs @@ -0,0 +1,63 @@ +//! Apache Groovy: binary zips from archive.apache.org, versions enumerated +//! from apache/groovy git tags, verified against Apache's .sha256 sidecars. + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::jvmlang::{Build, Def}; +use crate::{fetch, versions::Version}; + +pub const DEF: Def = Def { + language: "groovy", + default_bin: "groovy", + fetch_available, +}; + +const TAGS_URL: &str = "https://api.github.com/repos/apache/groovy/tags?per_page=100"; +const ARCHIVE_BASE: &str = "https://archive.apache.org/dist/groovy"; + +#[derive(Debug, Deserialize)] +struct Tag { + name: String, +} + +fn fetch_available() -> Result> { + let http = fetch::client()?; + let tags: Vec = fetch::github_api_get(&http, TAGS_URL) + .send() + .context("failed to query Groovy tags")? + .error_for_status() + .context("Groovy tag query failed")? + .json() + .context("failed to parse Groovy tags")?; + + let mut builds: Vec = tags + .into_iter() + .filter_map(|t| { + // Tags look like GROOVY_4_0_28; prerelease tags carry extra + // parts (GROOVY_5_0_0_alpha_1) and fail the parse. + let version: Version = t + .name + .strip_prefix("GROOVY_")? + .replace('_', ".") + .parse() + .ok()?; + Some(Build { + url: format!( + "{ARCHIVE_BASE}/{version}/distribution/apache-groovy-binary-{version}.zip" + ), + sha256: None, + // Apache publishes .sha256 sidecars; fetched at install time. + sha256_url: Some(format!( + "{ARCHIVE_BASE}/{version}/distribution/apache-groovy-binary-{version}.zip.sha256" + )), + subdir: format!("groovy-{version}"), + asset_name: format!("apache-groovy-binary-{version}.zip"), + version, + }) + }) + .collect(); + builds.sort_by_key(|b| b.version); + builds.dedup_by_key(|b| b.version); + Ok(builds) +} diff --git a/src/jvm/dist.rs b/src/jvm/dist.rs new file mode 100644 index 0000000..de061f4 --- /dev/null +++ b/src/jvm/dist.rs @@ -0,0 +1,165 @@ +//! Fetching Eclipse Temurin JDKs from the Adoptium API. The API serves +//! per-platform tarballs (zips on Windows) with inline sha256 checksums, +//! including native alpine-linux (musl) builds. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +use crate::fetch; +use crate::versions::Version; + +const API_BASE: &str = "https://api.adoptium.net/v3"; + +/// (os, architecture) in Adoptium API naming. +fn platform() -> Result<(&'static str, &'static str)> { + let os = match std::env::consts::OS { + "macos" => "mac", + "linux" if cfg!(target_env = "musl") => "alpine-linux", + "linux" => "linux", + "windows" => "windows", + other => bail!("unsupported platform for jvm: {other}"), + }; + let arch = match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "aarch64", + other => bail!("unsupported architecture for jvm: {other}"), + }; + Ok((os, arch)) +} + +#[derive(Debug, Deserialize)] +struct AvailableReleases { + available_releases: Vec, + available_lts_releases: Vec, +} + +#[derive(Debug, Deserialize)] +struct Asset { + release_name: String, + binary: Binary, + version: VersionData, +} + +#[derive(Debug, Deserialize)] +struct Binary { + package: Package, +} + +#[derive(Debug, Deserialize)] +struct Package { + name: String, + link: String, + checksum: String, +} + +#[derive(Debug, Deserialize)] +struct VersionData { + semver: String, +} + +pub struct AvailableBuild { + pub version: Version, + pub feature: u32, + pub lts: bool, + url: String, + checksum: String, + asset_name: String, + /// Top-level directory inside the archive, e.g. `jdk-21.0.11+10`. + release_name: String, +} + +/// Parse Adoptium's semver (`21.0.11+10.0.LTS`) down to plain X.Y.Z. +fn parse_semver(raw: &str) -> Option { + raw.split(['+', '-']).next()?.parse().ok() +} + +/// The latest Temurin build of every available feature release for this +/// platform, ascending. +pub fn fetch_available() -> Result> { + let (os, arch) = platform()?; + let http = fetch::client()?; + + let releases: AvailableReleases = http + .get(format!("{API_BASE}/info/available_releases")) + .send() + .context("failed to query the Adoptium API")? + .error_for_status() + .context("Adoptium available_releases query failed")? + .json() + .context("failed to parse Adoptium release info")?; + + let mut builds = Vec::new(); + for feature in &releases.available_releases { + let url = format!( + "{API_BASE}/assets/latest/{feature}/hotspot?os={os}&architecture={arch}&image_type=jdk&vendor=eclipse" + ); + let assets: Vec = match http.get(&url).send().and_then(|r| r.error_for_status()) { + Ok(response) => response.json().unwrap_or_default(), + // Not every feature release has builds for every platform. + Err(_) => continue, + }; + let Some(asset) = assets.into_iter().next() else { + continue; + }; + let Some(version) = parse_semver(&asset.version.semver) else { + continue; + }; + builds.push(AvailableBuild { + version, + feature: *feature, + lts: releases.available_lts_releases.contains(feature), + url: asset.binary.package.link, + checksum: asset.binary.package.checksum.to_ascii_lowercase(), + asset_name: asset.binary.package.name, + release_name: asset.release_name, + }); + } + builds.sort_by_key(|b| b.version); + Ok(builds) +} + +/// Download the build, verify its checksum, and extract it so that +/// `java_home(dest)/bin/java` exists. +pub fn install_build(build: &AvailableBuild, dest: &Path) -> Result<()> { + eprintln!("downloading {}", build.url); + let archive = fetch::download(&fetch::client()?, &build.url)?; + fetch::verify_sha256(&archive, &build.checksum, &build.asset_name)?; + fetch::extract_archive_subdir(&archive, &build.asset_name, &build.release_name, dest) +} + +/// JAVA_HOME inside an installed toolchain: macOS bundles nest the JDK under +/// Contents/Home. +pub fn java_home(toolchain: &Path) -> PathBuf { + let nested = toolchain.join("Contents").join("Home"); + if nested.is_dir() { + nested + } else { + toolchain.to_path_buf() + } +} + +/// The directory containing java/javac inside an installed toolchain. +pub fn bin_dir(toolchain: &Path) -> PathBuf { + java_home(toolchain).join("bin") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_adoptium_semver() { + assert_eq!( + parse_semver("21.0.11+10.0.LTS"), + Some("21.0.11".parse().unwrap()) + ); + assert_eq!(parse_semver("8.0.442+6"), Some("8.0.442".parse().unwrap())); + assert_eq!( + parse_semver("25.0.1-beta+9"), + Some("25.0.1".parse().unwrap()) + ); + assert_eq!(parse_semver("garbage"), None); + } +} diff --git a/src/jvm/mod.rs b/src/jvm/mod.rs new file mode 100644 index 0000000..7a3ce11 --- /dev/null +++ b/src/jvm/mod.rs @@ -0,0 +1,161 @@ +pub mod dist; + +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 = "jvm"; + +pub fn toolchain_path(version: &Version) -> Result { + store::toolchain_path(LANGUAGE, version) +} + +pub fn upgrade(latest: bool, prune: bool) -> Result<()> { + let builds = dist::fetch_available()?; + let available: Vec = builds.iter().map(|b| b.version).collect(); + // --latest targets the newest LTS line, the sensible default for JDKs. + let newest = builds + .iter() + .rev() + .find(|b| b.lts) + .or(builds.last()) + .map(|b| b.version); + store::upgrade(LANGUAGE, &available, newest, latest, prune, &|v| { + install(Some(v.to_string())) + }) +} + +/// jenv convention: the nearest `.java-version` holding a plain version. +pub fn fallback_pin(cwd: &Path) -> Result> { + for dir in cwd.ancestors() { + let path = dir.join(".java-version"); + if let Some(raw) = store::read_version_file(&path)? { + return Ok(store::file_pin(&raw, &path)); + } + } + Ok(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}'"))? + } + // Default to the newest LTS, like most of the Java ecosystem. + None => builds + .iter() + .rev() + .find(|b| b.lts) + .unwrap_or(builds.last().unwrap()), + }; + + let dest = toolchain_path(&build.version)?; + if dest.exists() { + eprintln!("jvm {} 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 jvm {} (Temurin {}) to {}", + build.version, + build.feature, + 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 lts = if build.lts { " (LTS)" } else { "" }; + let marker = if installed.contains(&build.version) { + " (installed)" + } else { + "" + }; + println!("{}{lts}{marker}", build.version); + } + println!("(latest Temurin build per feature release)"); + Ok(()) +} + +/// The JAVA_HOME a JVM-based `language` should use in `dir`: its `[jvm]` +/// binding when one is set, else the directory's plain jvm pin. +pub fn resolve_for(language: &str, dir: &Path) -> Result<(Version, PathBuf)> { + let req: VersionReq = match crate::config::jvm_binding(language, dir)? { + Some(raw) => raw + .parse() + .with_context(|| format!("invalid jvm binding '{raw}' for {language}"))?, + None => match store::resolve_pin(LANGUAGE, dir)? { + Some(pin) => pin + .raw + .parse() + .with_context(|| format!("invalid jvm version '{}' pinned", pin.raw))?, + None => bail!( + "no jvm configured for {language}: run `linguo {language} set-jvm ` or pin one (`linguo jvm use `)" + ), + }, + }; + let version = store::find_installed(LANGUAGE, &req)? + .with_context(|| format!("jvm {req} is not installed (run `linguo jvm install {req}`)"))?; + let home = dist::java_home(&toolchain_path(&version)?); + Ok((version, home)) +} + +/// Print the path of the executable a command resolves to (default: java). +pub fn which(command: Option) -> Result<()> { + let cwd = std::env::current_dir()?; + let version = store::required_toolchain(LANGUAGE, &cwd)?; + let bin = dist::bin_dir(&toolchain_path(&version)?); + let name = command.unwrap_or_else(|| "java".to_string()); + if let Some(path) = crate::exec::find_in_dir(&bin, &name) { + println!("{}", path.display()); + return Ok(()); + } + bail!("'{name}' not found in the pinned jvm"); +} + +/// Run a command with the pinned JDK on PATH and JAVA_HOME set. +pub fn run(args: &[String]) -> Result<()> { + let (program, rest) = args.split_first().context("no command given")?; + let cwd = std::env::current_dir()?; + let version = store::required_toolchain(LANGUAGE, &cwd)?; + let toolchain = toolchain_path(&version)?; + let bin = dist::bin_dir(&toolchain); + + let current = std::env::var_os("PATH").unwrap_or_default(); + let path = + std::env::join_paths(std::iter::once(bin.clone()).chain(std::env::split_paths(¤t))) + .context("invalid PATH entry")?; + + let mut cmd = crate::exec::command_in(std::slice::from_ref(&bin), program); + cmd.args(rest) + .env("PATH", path) + .env("JAVA_HOME", dist::java_home(&toolchain)); + crate::exec::exec(cmd, program) +} diff --git a/src/jvmlang.rs b/src/jvmlang.rs new file mode 100644 index 0000000..945f062 --- /dev/null +++ b/src/jvmlang.rs @@ -0,0 +1,194 @@ +//! Shared machinery for JVM-based languages (Kotlin, Groovy, Scala): each is +//! a runtime-only toolchain whose run/which put the language's bin AND its +//! bound JDK on PATH with JAVA_HOME set. `set-jvm` binds a specific JVM per +//! language; without a binding, the directory's plain jvm pin applies. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use crate::versions::{Version, VersionReq}; +use crate::{config, jvm, store}; + +pub struct Build { + pub version: Version, + pub url: String, + /// sha256 known up front (e.g. GitHub asset digests). + pub sha256: Option, + /// URL of a checksum sidecar to fetch at install time (Apache-style). + pub sha256_url: Option, + /// Top-level directory inside the archive holding the distribution. + pub subdir: String, + pub asset_name: String, +} + +/// A JVM language definition: everything the shared commands need. +pub struct Def { + pub language: &'static str, + /// Default binary for `which` (e.g. `scala`). + pub default_bin: &'static str, + pub fetch_available: fn() -> Result>, +} + +pub fn toolchain_bin(def: &Def, version: &Version) -> Result { + Ok(store::toolchain_path(def.language, version)?.join("bin")) +} + +pub fn install(def: &Def, request: Option) -> Result<()> { + let builds = (def.fetch_available)()?; + if builds.is_empty() { + bail!("no builds available"); + } + 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 = store::toolchain_path(def.language, &build.version)?; + if dest.exists() { + eprintln!("{} {} is already installed", def.language, build.version); + return Ok(()); + } + std::fs::create_dir_all(dest.parent().unwrap()) + .with_context(|| format!("failed to create {}", dest.parent().unwrap().display()))?; + + let http = crate::fetch::client()?; + let expected = match (&build.sha256, &build.sha256_url) { + (Some(sha), _) => Some(sha.clone()), + (None, Some(url)) => { + let text = http + .get(url) + .send() + .and_then(|r| r.error_for_status()) + .with_context(|| format!("failed to fetch checksum from {url}"))? + .text()?; + Some( + text.split_whitespace() + .next() + .context("empty checksum file")? + .to_ascii_lowercase(), + ) + } + (None, None) => None, + }; + + eprintln!("downloading {}", build.url); + let archive = crate::fetch::download(&http, &build.url)?; + match expected { + Some(expected) => crate::fetch::verify_sha256(&archive, &expected, &build.asset_name)?, + None => eprintln!("warning: no published checksum for this build; skipping verification"), + } + crate::fetch::extract_archive_subdir(&archive, &build.asset_name, &build.subdir, &dest)?; + eprintln!( + "installed {} {} to {}", + def.language, + build.version, + dest.display() + ); + Ok(()) +} + +pub fn list(def: &Def, available: bool) -> Result<()> { + if !available { + return store::list_installed(def.language); + } + let builds = (def.fetch_available)()?; + if builds.is_empty() { + println!("no builds available"); + return Ok(()); + } + let installed = store::installed_versions(def.language)?; + // Show the latest release per minor line. + let mut previous: Option = None; + let mut latest_per_minor: Vec = Vec::new(); + for build in &builds { + if let Some(prev) = previous + && (prev.major, prev.minor) != (build.version.major, build.version.minor) + { + latest_per_minor.push(prev); + } + previous = Some(build.version); + } + latest_per_minor.extend(previous); + for version in latest_per_minor { + let marker = if installed.contains(&version) { + " (installed)" + } else { + "" + }; + println!("{version}{marker}"); + } + println!("(latest release per minor line; any exact version can be installed)"); + Ok(()) +} + +pub fn upgrade(def: &'static Def, latest: bool, prune: bool) -> Result<()> { + let available: Vec = (def.fetch_available)()?.iter().map(|b| b.version).collect(); + let newest = available.last().copied(); + store::upgrade(def.language, &available, newest, latest, prune, &|v| { + install(def, Some(v.to_string())) + }) +} + +/// Bind the JVM this language uses (writes `[jvm] = ""`). +pub fn set_jvm(def: &Def, raw: &str, global: bool) -> Result<()> { + let req: VersionReq = raw.parse()?; + let path = if global { + config::linguo_root()?.join(config::GLOBAL_CONFIG) + } else { + std::env::current_dir()?.join(config::PIN_FILE) + }; + config::write_jvm_binding(&path, def.language, &req.to_string())?; + println!("bound {} to jvm {req} in {}", def.language, path.display()); + if store::find_installed(jvm::LANGUAGE, &req)?.is_none() { + println!("note: no installed jvm matches; run `linguo jvm install {req}`"); + } + Ok(()) +} + +/// The language's bin plus its resolved JDK bin, for PATH assembly. +pub fn managed_dirs(def: &Def, dir: &Path) -> Result<(Vec, PathBuf)> { + let version = store::required_toolchain(def.language, dir)?; + let lang_bin = toolchain_bin(def, &version)?; + let (_, java_home) = jvm::resolve_for(def.language, dir)?; + Ok((vec![lang_bin, java_home.join("bin")], java_home)) +} + +/// Print the path of the executable a command resolves to. +pub fn which(def: &Def, command: Option) -> Result<()> { + let cwd = std::env::current_dir()?; + let (dirs, _) = managed_dirs(def, &cwd)?; + let name = command.unwrap_or_else(|| def.default_bin.to_string()); + for dir in &dirs { + if let Some(path) = crate::exec::find_in_dir(dir, &name) { + println!("{}", path.display()); + return Ok(()); + } + } + bail!( + "'{name}' not found in the pinned {} toolchain or its jvm", + def.language + ); +} + +/// Run a command with the language and its JDK on PATH and JAVA_HOME set. +pub fn run(def: &Def, args: &[String]) -> Result<()> { + let (program, rest) = args.split_first().context("no command given")?; + let cwd = std::env::current_dir()?; + let (dirs, java_home) = managed_dirs(def, &cwd)?; + + let current = std::env::var_os("PATH").unwrap_or_default(); + let path = std::env::join_paths(dirs.iter().cloned().chain(std::env::split_paths(¤t))) + .context("invalid PATH entry")?; + + let mut cmd = crate::exec::command_in(&dirs, program); + cmd.args(rest).env("PATH", path).env("JAVA_HOME", java_home); + crate::exec::exec(cmd, program) +} diff --git a/src/kotlin.rs b/src/kotlin.rs new file mode 100644 index 0000000..09b7664 --- /dev/null +++ b/src/kotlin.rs @@ -0,0 +1,66 @@ +//! Kotlin: command-line compiler zips from JetBrains/kotlin GitHub releases, +//! verified against GitHub's per-asset sha256 digests. Archives unpack to a +//! fixed `kotlinc/` directory. + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::jvmlang::{Build, Def}; +use crate::{fetch, versions::Version}; + +pub const DEF: Def = Def { + language: "kotlin", + default_bin: "kotlin", + fetch_available, +}; + +const RELEASES_URL: &str = "https://api.github.com/repos/JetBrains/kotlin/releases?per_page=100"; + +#[derive(Debug, Deserialize)] +struct Release { + tag_name: String, + prerelease: bool, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct Asset { + name: String, + browser_download_url: String, + digest: Option, +} + +fn fetch_available() -> Result> { + let http = fetch::client()?; + let releases: Vec = fetch::github_api_get(&http, RELEASES_URL) + .send() + .context("failed to query Kotlin releases")? + .error_for_status() + .context("Kotlin release query failed")? + .json() + .context("failed to parse Kotlin releases")?; + + let mut builds: Vec = releases + .into_iter() + .filter(|r| !r.prerelease) + .filter_map(|r| { + let version: Version = r.tag_name.strip_prefix('v')?.parse().ok()?; + let wanted = format!("kotlin-compiler-{version}.zip"); + let asset = r.assets.into_iter().find(|a| a.name == wanted)?; + Some(Build { + version, + url: asset.browser_download_url, + sha256: asset + .digest + .as_deref() + .and_then(|d| d.strip_prefix("sha256:")) + .map(str::to_string), + sha256_url: None, + subdir: "kotlinc".to_string(), + asset_name: asset.name, + }) + }) + .collect(); + builds.sort_by_key(|b| b.version); + Ok(builds) +} diff --git a/src/main.rs b/src/main.rs index 80106a9..276fbcd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,11 +2,16 @@ mod config; mod exec; mod fetch; mod go; +mod groovy; +mod jvm; +mod jvmlang; +mod kotlin; mod node; mod php; mod python; mod ruby; mod rust; +mod scala; mod shell; mod status; mod store; @@ -57,6 +62,26 @@ enum Command { #[command(subcommand)] command: RustCommand, }, + /// Manage JVMs (Eclipse Temurin JDKs) + Jvm { + #[command(subcommand)] + command: JvmCommand, + }, + /// Manage Kotlin toolchains (JVM-based; see set-jvm) + Kotlin { + #[command(subcommand)] + command: JvmLangCommand, + }, + /// Manage Groovy toolchains (JVM-based; see set-jvm) + Groovy { + #[command(subcommand)] + command: JvmLangCommand, + }, + /// Manage Scala toolchains (JVM-based; see set-jvm) + Scala { + #[command(subcommand)] + command: JvmLangCommand, + }, /// Manage PHP toolchains and projects Php { #[command(subcommand)] @@ -345,6 +370,82 @@ enum RustTargetCommand { Add { triples: Vec }, } +#[derive(Subcommand)] +enum JvmCommand { + /// Download and install a JDK (latest LTS if no version is given) + Install { version: Option }, + /// Remove an installed JDK + Uninstall { version: String }, + /// List installed JDKs + List { + /// List versions available for download instead + #[arg(long)] + available: bool, + }, + /// Pin a JVM version for this directory (or globally) + Use { + version: String, + #[arg(long)] + global: bool, + }, + /// Upgrade the pinned JDK: newest build within the pin, or bump the pin + /// with --latest (targets the newest LTS) + Upgrade { + #[arg(long)] + latest: bool, + #[arg(long)] + prune: bool, + }, + /// Show which executable a command resolves to (default: java) + Which { command: Option }, + /// Run a command with the pinned JDK on PATH and JAVA_HOME set + Run { + #[arg(trailing_var_arg = true, required = true)] + args: Vec, + }, +} + +#[derive(Subcommand)] +enum JvmLangCommand { + /// 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 { + #[arg(long)] + latest: bool, + #[arg(long)] + prune: bool, + }, + /// Bind which JVM this language uses here (or globally) + SetJvm { + version: String, + #[arg(long)] + global: bool, + }, + /// Show which executable a command resolves to + Which { command: Option }, + /// Run a command with the toolchain and its JVM on PATH, JAVA_HOME set + Run { + #[arg(trailing_var_arg = true, required = true)] + args: Vec, + }, +} + #[derive(Subcommand)] enum PhpCommand { /// Download and install a toolchain (latest stable if no version is given) @@ -470,13 +571,28 @@ enum TerraformCommand { }, } +fn run_jvm_lang(def: &'static jvmlang::Def, command: JvmLangCommand) -> anyhow::Result<()> { + match command { + JvmLangCommand::Install { version } => jvmlang::install(def, version), + JvmLangCommand::Uninstall { version } => store::uninstall(def.language, &version), + JvmLangCommand::List { available } => jvmlang::list(def, available), + JvmLangCommand::Use { version, global } => { + store::use_version(def.language, &version, global) + } + JvmLangCommand::Upgrade { latest, prune } => jvmlang::upgrade(def, latest, prune), + JvmLangCommand::SetJvm { version, global } => jvmlang::set_jvm(def, &version, global), + JvmLangCommand::Which { command } => jvmlang::which(def, command), + JvmLangCommand::Run { args } => jvmlang::run(def, &args), + } +} + /// Upgrade every language with a resolvable pin in the current directory. fn upgrade_all(latest: bool, prune: bool) -> anyhow::Result<()> { let cwd = std::env::current_dir()?; let mut failures: Vec = Vec::new(); let mut any = false; type UpgradeFn = fn(bool, bool) -> anyhow::Result<()>; - let languages: [(&str, UpgradeFn); 7] = [ + let languages: [(&str, UpgradeFn); 8] = [ (python::LANGUAGE, python::upgrade), (node::LANGUAGE, node::upgrade), (ruby::LANGUAGE, ruby::upgrade), @@ -484,6 +600,7 @@ fn upgrade_all(latest: bool, prune: bool) -> anyhow::Result<()> { (rust::LANGUAGE, rust::upgrade), (zig::LANGUAGE, zig::upgrade), (php::LANGUAGE, php::upgrade), + (jvm::LANGUAGE, jvm::upgrade), ]; for (language, upgrade) in languages { if store::resolve_pin(language, &cwd)?.is_none() { @@ -595,6 +712,20 @@ fn main() -> anyhow::Result<()> { RustTargetCommand::Add { triples } => rust::target_add(&triples), }, }, + Command::Jvm { command } => match command { + JvmCommand::Install { version } => jvm::install(version), + JvmCommand::Uninstall { version } => store::uninstall(jvm::LANGUAGE, &version), + JvmCommand::List { available } => jvm::list(available), + JvmCommand::Use { version, global } => { + store::use_version(jvm::LANGUAGE, &version, global) + } + JvmCommand::Upgrade { latest, prune } => jvm::upgrade(latest, prune), + JvmCommand::Which { command } => jvm::which(command), + JvmCommand::Run { args } => jvm::run(&args), + }, + Command::Kotlin { command } => run_jvm_lang(&kotlin::DEF, command), + Command::Groovy { command } => run_jvm_lang(&groovy::DEF, command), + Command::Scala { command } => run_jvm_lang(&scala::DEF, command), Command::Php { command } => match command { PhpCommand::Install { version } => php::install(version), PhpCommand::Uninstall { version } => store::uninstall(php::LANGUAGE, &version), diff --git a/src/scala.rs b/src/scala.rs new file mode 100644 index 0000000..e4a003a --- /dev/null +++ b/src/scala.rs @@ -0,0 +1,67 @@ +//! Scala 3: distribution tarballs from scala/scala3 GitHub releases, +//! verified against GitHub's per-asset sha256 digests. + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::jvmlang::{Build, Def}; +use crate::{fetch, versions::Version}; + +pub const DEF: Def = Def { + language: "scala", + default_bin: "scala", + fetch_available, +}; + +const RELEASES_URL: &str = "https://api.github.com/repos/scala/scala3/releases?per_page=100"; + +#[derive(Debug, Deserialize)] +struct Release { + tag_name: String, + prerelease: bool, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct Asset { + name: String, + browser_download_url: String, + digest: Option, +} + +fn fetch_available() -> Result> { + let http = fetch::client()?; + let releases: Vec = fetch::github_api_get(&http, RELEASES_URL) + .send() + .context("failed to query Scala releases")? + .error_for_status() + .context("Scala release query failed")? + .json() + .context("failed to parse Scala releases")?; + + let mut builds: Vec = releases + .into_iter() + .filter(|r| !r.prerelease) + .filter_map(|r| { + // Tags are plain versions (3.3.8); RCs carry suffixes and fail + // the parse. + let version: Version = r.tag_name.parse().ok()?; + let wanted = format!("scala3-{version}.tar.gz"); + let asset = r.assets.into_iter().find(|a| a.name == wanted)?; + Some(Build { + version, + url: asset.browser_download_url, + sha256: asset + .digest + .as_deref() + .and_then(|d| d.strip_prefix("sha256:")) + .map(str::to_string), + sha256_url: None, + subdir: format!("scala3-{version}"), + asset_name: asset.name, + }) + }) + .collect(); + builds.sort_by_key(|b| b.version); + Ok(builds) +} diff --git a/src/shell.rs b/src/shell.rs index fcbd393..f70901a 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -8,11 +8,15 @@ use anyhow::Result; use clap::ValueEnum; use crate::config::PinSource; -use crate::{go, node, php, python, ruby, rust, terraform, zig}; +use crate::{ + go, groovy, jvm, jvmlang, kotlin, node, php, python, ruby, rust, scala, terraform, zig, +}; /// Env var tracking which directories linguo has prepended to PATH, so they /// can be removed again when the active project changes. const DIRS_VAR: &str = "__LINGUO_DIRS"; +/// Env var marking that linguo set JAVA_HOME, so it can be unset on leave. +const JAVA_VAR: &str = "__LINGUO_JAVA_HOME"; #[derive(Debug, Clone, Copy, ValueEnum)] pub enum Shell { @@ -69,7 +73,7 @@ _linguo_hook"# } /// Directories that should be on PATH for the current directory. -fn desired_dirs() -> Result> { +fn desired_dirs() -> Result<(Vec, Option)> { let cwd = std::env::current_dir()?; let mut dirs = Vec::new(); // When auto-install is enabled, unsatisfied pins install on the spot. @@ -125,7 +129,31 @@ fn desired_dirs() -> Result> { dist, &version, )?)); } - Ok(dirs) + + // JVM stack: the jvm pin itself, then the JVM languages (each pushes its + // own bin plus its bound JDK's bin; the plain jvm pin owns JAVA_HOME). + let mut java_home: Option = None; + if let Some((_, version)) = auto(jvm::LANGUAGE, &|v| jvm::install(Some(v.into())))? { + let toolchain = jvm::toolchain_path(&version)?; + dirs.push(jvm::dist::bin_dir(&toolchain)); + java_home = Some(jvm::dist::java_home(&toolchain)); + } + for def in [&kotlin::DEF, &groovy::DEF, &scala::DEF] { + let installer = |v: &str| jvmlang::install(def, Some(v.into())); + if let Some((_, version)) = auto(def.language, &installer)? { + dirs.push(jvmlang::toolchain_bin(def, &version)?); + if let Ok((_, home)) = jvm::resolve_for(def.language, &cwd) { + let bin = home.join("bin"); + if !dirs.contains(&bin) { + dirs.push(bin); + } + if java_home.is_none() { + java_home = Some(home); + } + } + } + } + Ok((dirs, java_home)) } fn quote(s: &str) -> String { @@ -133,11 +161,13 @@ fn quote(s: &str) -> String { } pub fn env(shell: Shell) -> Result<()> { - let desired = desired_dirs()?; + let (desired, java_home) = desired_dirs()?; let previous: Vec = std::env::var(DIRS_VAR) .map(|v| std::env::split_paths(&v).collect()) .unwrap_or_default(); - if desired == previous { + let previous_java = std::env::var(JAVA_VAR).ok().filter(|v| !v.is_empty()); + let desired_java = java_home.map(|p| p.display().to_string()); + if desired == previous && desired_java == previous_java { return Ok(()); } @@ -156,6 +186,14 @@ pub fn env(shell: Shell) -> Result<()> { } else { println!("export {DIRS_VAR}={}", quote(&dirs_value)); } + match &desired_java { + Some(home) => { + println!("export JAVA_HOME={}", quote(home)); + println!("export {JAVA_VAR}={}", quote(home)); + } + None if previous_java.is_some() => println!("unset JAVA_HOME {JAVA_VAR}"), + None => {} + } } Shell::Fish => { // fish's PATH is a list variable; set each directory as an element. @@ -169,6 +207,16 @@ pub fn env(shell: Shell) -> Result<()> { } else { println!("set -gx {DIRS_VAR} {}", quote(&dirs_value)); } + match &desired_java { + Some(home) => { + println!("set -gx JAVA_HOME {}", quote(home)); + println!("set -gx {JAVA_VAR} {}", quote(home)); + } + None if previous_java.is_some() => { + println!("set -e JAVA_HOME; set -e {JAVA_VAR}") + } + None => {} + } } Shell::Powershell => { let new_path = std::env::join_paths(&new_dirs)?; @@ -178,6 +226,16 @@ pub fn env(shell: Shell) -> Result<()> { } else { println!("$env:{DIRS_VAR} = {}", quote_ps(&dirs_value)); } + match &desired_java { + Some(home) => { + println!("$env:JAVA_HOME = {}", quote_ps(home)); + println!("$env:{JAVA_VAR} = {}", quote_ps(home)); + } + None if previous_java.is_some() => println!( + "Remove-Item Env:\\JAVA_HOME, Env:\\{JAVA_VAR} -ErrorAction SilentlyContinue" + ), + None => {} + } } } Ok(()) diff --git a/src/status.rs b/src/status.rs index 95161c9..2938f60 100644 --- a/src/status.rs +++ b/src/status.rs @@ -9,7 +9,9 @@ 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", "php", "go", "zig"]; +const GENERIC_LANGUAGES: &[&str] = &[ + "python", "node", "ruby", "php", "go", "zig", "jvm", "kotlin", "groovy", "scala", +]; pub fn status() -> Result<()> { let cwd = std::env::current_dir()?; diff --git a/src/store.rs b/src/store.rs index 847913e..29bcdce 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, php, python, ruby, rust, zig}; +use crate::{go, jvm, node, php, python, ruby, rust, zig}; pub fn toolchain_path(language: &str, version: &Version) -> Result { Ok(config::toolchains_dir(language)?.join(version.to_string())) @@ -57,6 +57,7 @@ fn fallback_pin(language: &str, cwd: &Path) -> Result> { rust::LANGUAGE => rust::fallback_pin(cwd), zig::LANGUAGE => zig::fallback_pin(cwd), php::LANGUAGE => php::fallback_pin(cwd), + jvm::LANGUAGE => jvm::fallback_pin(cwd), _ => Ok(None), } } From 9c8f7fe5f518b2ec6e9c96125a452dd0e93592bb Mon Sep 17 00:00:00 2001 From: Ryan Draga Date: Sat, 4 Jul 2026 10:52:39 -0400 Subject: [PATCH 2/3] Never resolve extensionless scripts on Windows Toolchains like groovy and kotlin ship an extensionless unix launcher next to the .bat; find_in_dir preferred the exact name, handing CreateProcess a shell script (error 193). On Windows only .exe/.cmd/ .bat/.com count, extension search order otherwise. Also restores a quote my earlier edit dropped in the Alpine groovy smoke line. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 2 +- src/exec.rs | 22 +++++++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 009207e..64f98cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,7 @@ jobs: linguo jvm run -- java -version linguo groovy install linguo groovy use 5 - linguo groovy run -- groovy -e "println \"groovy on musl ok\" + linguo groovy run -- groovy -e "println \"groovy on musl ok\"" # node and go have no official musl builds: expect honest errors linguo node install 2>/dev/null && exit 1 || echo "node bails as expected" diff --git a/src/exec.rs b/src/exec.rs index 5645734..c3466fa 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -14,22 +14,26 @@ pub fn exe(name: &str) -> String { } } -/// Locate an executable named `name` in `dir`, trying Windows executable -/// extensions when `name` has none. +/// Locate an executable named `name` in `dir`. On Windows only files with +/// executable extensions count: toolchains often ship an extensionless unix +/// script alongside a .bat (groovy, kotlin), and handing the script to +/// CreateProcess fails with "not a valid Win32 application". pub fn find_in_dir(dir: &Path, name: &str) -> Option { - let direct = dir.join(name); - if is_executable(&direct) { - return Some(direct); - } - if cfg!(windows) && Path::new(name).extension().is_none() { - for ext in ["exe", "cmd", "bat"] { + if cfg!(windows) { + if Path::new(name).extension().is_some() { + let direct = dir.join(name); + return direct.is_file().then_some(direct); + } + for ext in ["exe", "cmd", "bat", "com"] { let candidate = dir.join(format!("{name}.{ext}")); if candidate.is_file() { return Some(candidate); } } + return None; } - None + let direct = dir.join(name); + is_executable(&direct).then_some(direct) } /// A Command for `program`, resolved against linguo-managed dirs first with From 63648e0c4bf78fc12d0eb04aa7280f910632ea86 Mon Sep 17 00:00:00 2001 From: Ryan Draga Date: Sat, 4 Jul 2026 10:57:07 -0400 Subject: [PATCH 3/3] Normalize JAVA_HOME to native separators on Windows Batch launchers validate JAVA_HOME with `if exist`, which rejects the mixed-separator paths that leak in when $LINGUO_ROOT is set with forward slashes (e.g. Git Bash). Co-Authored-By: Claude Fable 5 --- src/jvm/dist.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/jvm/dist.rs b/src/jvm/dist.rs index de061f4..5ca9d3e 100644 --- a/src/jvm/dist.rs +++ b/src/jvm/dist.rs @@ -130,13 +130,22 @@ pub fn install_build(build: &AvailableBuild, dest: &Path) -> Result<()> { } /// JAVA_HOME inside an installed toolchain: macOS bundles nest the JDK under -/// Contents/Home. +/// Contents/Home. On Windows the path is normalized to backslashes — batch +/// launchers (groovy.bat and friends) validate JAVA_HOME with `if exist`, +/// which rejects mixed separators that leak in via $LINGUO_ROOT. pub fn java_home(toolchain: &Path) -> PathBuf { - let nested = toolchain.join("Contents").join("Home"); - if nested.is_dir() { - nested + let home = { + let nested = toolchain.join("Contents").join("Home"); + if nested.is_dir() { + nested + } else { + toolchain.to_path_buf() + } + }; + if cfg!(windows) { + PathBuf::from(home.to_string_lossy().replace('/', "\\")) } else { - toolchain.to_path_buf() + home } }