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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -26,6 +26,8 @@ linguo <language> <command>
| 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
53 changes: 53 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,59 @@ pub fn resolve_pin(language: &str, cwd: &Path) -> Result<Option<Pin>> {
global_pin(language)
}

/// The JVM binding for a JVM-based language: nearest project linguo.toml's
/// `[jvm] <language> = "..."`, then the global config's.
pub fn jvm_binding(language: &str, cwd: &Path) -> Result<Option<String>> {
let read = |path: &Path| -> Result<Option<String>> {
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] <language> = "<value>"` 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] <language> = "<raw>"` 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) {
Expand Down
22 changes: 13 additions & 9 deletions src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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
Expand Down
63 changes: 63 additions & 0 deletions src/groovy.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<Build>> {
let http = fetch::client()?;
let tags: Vec<Tag> = 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<Build> = 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)
}
Loading
Loading