import jdk.jshell.*; import java.util.*; final int USAGE_EXIT = 64; final int FAILURE_EXIT = 1; final String LOCAL_ENGINE = "local"; /// Tiny Java shell for mise inline tasks. /// /// Mise passes each task command as one argv item. This evaluates that item as /// JShell snippets in the local JVM and exits non-zero on Java errors or thrown exceptions. void main(String[] args) throws Exception { if (args.length != 1) { System.err.println("expected one mise task snippet arg, got " + args.length); System.exit(USAGE_EXIT); } try (var jshell = JShell.builder().executionEngine(LOCAL_ENGINE).out(System.out).err(System.err).build()) { if (!evalAll(jshell, startup(), true) || !evalAll(jshell, args[0], false)) System.exit(FAILURE_EXIT); } } /// Evaluate a possibly multi-snippet Java task. /// /// Reason: `JShell.eval(...)` only reliably evaluates one complete snippet at a /// time. Mise tasks are often multi-line blocks, so this uses JShell's parser to /// split complete snippets before execution. /// /// Usage: first evaluate `startup()` with `startup = true`, then the task body /// with `startup = false`. /// /// Pitfall: state lives only inside this Java process. A mise `run = [...]` array /// starts separate shell processes, so variables and `cd(...)` do not cross array /// entries. boolean evalAll(JShell jshell, String input, boolean startup) { for (var rest = input; !rest.isBlank(); ) { var info = jshell.sourceCodeAnalysis().analyzeCompletion(rest); if (!info.completeness().isComplete()) { System.err.println(prefix(startup) + "incomplete snippet: " + info.completeness()); return false; } if (!evalOne(jshell, info.source(), startup)) return false; rest = info.remaining(); } return true; } /// Evaluate one complete JShell snippet and translate JShell status into exit status. /// /// Reason: the `jshell` CLI exits `0` for some rejected snippets. Mise tasks need /// hard failure on compiler errors and thrown exceptions. /// /// Usage: called by `evalAll(...)` after completeness analysis. /// /// Pitfall: diagnostics point at JShell's synthetic snippet, not a physical /// `mise.toml` line number. Keep tasks short and direct. boolean evalOne(JShell jshell, String code, boolean startup) { var ok = true; for (var event : jshell.eval(code)) { if (event.exception() != null) { var message = event.exception().getMessage(); System.err.println(message == null ? event.exception().toString() : message); ok = false; } if (rejected(event.status())) { jshell.diagnostics(event.snippet()).forEach(d -> System.err.println(prefix(startup) + d.getMessage(Locale.ROOT))); ok = false; } } return ok; } boolean rejected(Snippet.Status status) { return status == Snippet.Status.REJECTED || status == Snippet.Status.RECOVERABLE_NOT_DEFINED; } String prefix(boolean startup) { return startup ? "startup: " : ""; } /// Built-in imports and shell-like helpers exposed to every mise task. /// /// Reason: root dorkfiles tasks should stay Java, terse, and cross-platform /// without depending on Bash/Nushell syntax. /// /// Usage: task bodies can call helpers directly, e.g. /// `run("java", "bootstripper.java", "check");`. /// /// Pitfall: this is startup source code for JShell. Syntax errors here break /// every task before the task body runs. String startup() { return String.join("\n", imports(), stateHelpers(), resultHelpers(), pathHelpers(), envHelpers(), processHelpers(), fileHelpers(), deleteHelpers(), globHelpers(), whichHelpers() ); } String imports() { return """ import java.io.*; import java.nio.charset.*; import java.nio.file.*; import java.util.*; import java.util.regex.*; import java.util.stream.*; """; } String stateHelpers() { return """ Path CWD = Path.of("").toAbsolutePath().normalize(); Map ENV = new HashMap<>(System.getenv()); """; } String resultHelpers() { return """ /// Captured process result. /// /// Reason: `run(...)` is fail-fast and streams output. Some tasks need captured /// text or an exit code instead. /// /// Usage: /// `var r = result("git", "status", "--short");` /// `if (r.code() != 0) throw new RuntimeException(r.out());` /// /// Pitfall: stdout and stderr are merged, matching typical task logs but not /// preserving separate streams. record Result(int code, String out) { /// Return captured process output unchanged. /// /// Reason: terse alias when reading command output. /// /// Usage: `IO.print(result("git", "status").text());`. /// /// Pitfall: includes trailing newlines exactly as emitted. String text() { return out; } /// Return captured output stripped of leading/trailing whitespace. /// /// Reason: common for command substitutions like `git rev-parse`. /// /// Usage: `var head = result("git", "rev-parse", "HEAD").trimmed();`. /// /// Pitfall: stripping can destroy meaningful whitespace; use `text()` for raw data. String trimmed() { return out.strip(); } /// Return captured output split into lines. /// /// Reason: Java replacement for shell pipelines that iterate line output. /// /// Usage: `for (var file : result("git", "ls-files").lines()) IO.println(file);`. /// /// Pitfall: uses Java `String.lines()`, so line terminators are removed. List lines() { return out.lines().toList(); } } """; } String pathHelpers() { return """ /// Return the task working directory. /// /// Reason: `cd(...)` is local state inside this Java shell, not OS process state. /// This exposes that state clearly. /// /// Usage: `IO.println(pwd());`. /// /// Pitfall: mise `dir = "..."` sets the initial process directory before this /// shell starts; `pwd()` reports that effective directory. Path pwd() { return CWD; } /// Resolve a path against `pwd()`. /// /// Reason: shell tasks constantly need repo-relative paths. This avoids string /// concatenation and normalizes `.` / `..`. /// /// Usage: `Files.exists(path("scripts", "mise_shell.java"));`. /// /// Pitfall: absolute `first` values stay absolute because `Path.resolve(...)` /// follows Java NIO rules. Prefer relative paths in repo tasks. Path path(String first, String... more) { return CWD.resolve(Path.of(first, more)).toAbsolutePath().normalize(); } /// Change this shell's working directory. /// /// Reason: Java cannot change the OS parent process directory, but this wrapper /// can keep a `CWD` used by `run(...)` and file helpers. /// /// Usage: `cd("pi"); run("mise", "run", "check");`. /// /// Pitfall: state lasts only for the current mise command. It does not cross /// separate `run = [...]` entries or dependent tasks. void cd(String dir) { var next = path(dir); if (!Files.isDirectory(next)) throw new IllegalArgumentException("not a directory: " + next); CWD = next; } """; } String envHelpers() { return """ /// Read a task environment variable from this shell's environment map. /// /// Reason: tasks need access to mise `env = { ... }` values without shell syntax. /// /// Usage: `IO.println(env("HOSTNAME"));`. /// /// Pitfall: returns `null` when absent; check required values explicitly. String env(String name) { return ENV.get(name); } /// Set an environment variable for future child processes launched by helpers. /// /// Reason: Java cannot mutate the parent environment, but tasks often need to /// pass config to later `run(...)` calls. /// /// Usage: `export("HOSTNAME", "desktop"); run("java", "bootstripper.java", "check");`. /// /// Pitfall: affects only child processes launched after the call, not the /// already-running Java shell itself. void export(String name, String value) { ENV.put(name, value); } """; } String processHelpers() { return """ /// Build a configured `ProcessBuilder`. /// /// Reason: centralizes `CWD` and `ENV` application for process helpers. /// /// Usage: prefer `run(...)`, `result(...)`, `out(...)`, or `code(...)`; use this /// only when a task needs custom redirects. /// /// Pitfall: mutating the returned builder does not mutate global helper state. ProcessBuilder process(String... cmd) { var pb = new ProcessBuilder(cmd).directory(CWD.toFile()); pb.environment().clear(); pb.environment().putAll(ENV); return pb; } /// Run a command, stream its IO, and fail on non-zero exit. /// /// Reason: this is the Java replacement for a normal shell command line. The /// shell runs snippets with JShell's local execution engine so inherited child IO /// reaches mise output live. /// /// Usage: `run("java", "bootstripper.java", "check");`. /// /// Pitfall: no shell expansion, globbing, pipes, aliases, or quoting. Pass each /// argv item separately. Use Java helpers for file work. void run(String... cmd) throws Exception { var code = process(cmd).inheritIO().start().waitFor(); if (code != 0) throw new RuntimeException("command failed (" + code + "): " + String.join(" ", cmd)); } /// Run a command and capture merged stdout/stderr. /// /// Reason: command substitution is sometimes needed, but should be explicit. /// /// Usage: `var status = result("git", "status", "--short");`. /// /// Pitfall: does not fail on non-zero exit; inspect `code()` or use `out(...)`. Result result(String... cmd) throws Exception { var p = process(cmd).redirectErrorStream(true).start(); var out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); return new Result(p.waitFor(), out); } /// Run a command and return output, failing on non-zero exit. /// /// Reason: terse command substitution for required commands. /// /// Usage: `var root = out("git", "rev-parse", "--show-toplevel").strip();`. /// /// Pitfall: output is buffered until the command exits; use `run(...)` for long /// commands or progress output. String out(String... cmd) throws Exception { var r = result(cmd); if (r.code() != 0) throw new RuntimeException(r.out()); return r.out(); } /// Run a command and return only its exit code. /// /// Reason: direct replacement for shell tests like `cmd >/dev/null || ...`. /// /// Usage: `if (code("git", "diff", "--quiet") != 0) IO.println("dirty");`. /// /// Pitfall: output is captured and discarded. Use `result(...)` if diagnostics matter. int code(String... cmd) throws Exception { return result(cmd).code(); } """; } String fileHelpers() { return """ /// Read a UTF-8-ish text file using Java defaults. /// /// Reason: common task file reads should not shell out to `cat`. /// /// Usage: `var text = read("mise.toml");`. /// /// Pitfall: path resolves against `pwd()`, not necessarily the repo root after `cd(...)`. String read(String file) throws Exception { return Files.readString(path(file)); } /// Write text to a file, creating parent directories. /// /// Reason: common task file writes should not shell out to `mkdir`/redirects. /// /// Usage: `write("target/out.txt", "ok");`. /// /// Pitfall: overwrites existing content. Use `Files.writeString(...)` directly /// when append/open options matter. void write(String file, String text) throws Exception { var p = path(file); var parent = p.getParent(); if (parent != null) Files.createDirectories(parent); Files.writeString(p, text); } /// Create a directory and missing parents. /// /// Reason: terse Java replacement for `mkdir -p`. /// /// Usage: `mkdir("build/classes");`. /// /// Pitfall: succeeds if the directory already exists; fails if a path component /// is a normal file. void mkdir(String dir) throws Exception { Files.createDirectories(path(dir)); } """; } String deleteHelpers() { return """ /// Guard destructive helpers against catastrophic targets. /// /// Reason: `rm(...)` convenience is useful, but deleting HOME, filesystem roots, /// or the current task directory is never acceptable for dorkfiles tasks. /// /// Usage: internal helper used by `rm(...)`, `rmdir(...)`, and `rmTree(...)`. /// /// Pitfall: this is a safety floor, not a permission model. Review destructive /// paths anyway. Path protectDelete(Path p) { p = p.toAbsolutePath().normalize(); var home = Path.of(System.getProperty("user.home")).toAbsolutePath().normalize(); if (p.getParent() == null || p.equals(home) || p.equals(CWD)) throw new IllegalArgumentException("refuse to delete: " + p); return p; } /// Delete a file or symlink if present. /// /// Reason: `Files.deleteIfExists(...)` is verbose for one-off cleanup tasks. /// /// Usage: `rm("target", "old.log");` or `rm("old.log");`. /// /// Pitfall: refuses normal directories; use `rmdir(...)` for empty directories /// or `rmTree(...)` for recursive deletion. void rm(String first, String... more) throws Exception { var p = protectDelete(path(first, more)); if (Files.isDirectory(p, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(p)) throw new IllegalArgumentException("is a directory; use rmdir or rmTree: " + p); Files.deleteIfExists(p); } /// Delete an empty directory if present. /// /// Reason: common cleanup wants `rmdir` semantics, not recursive deletion. /// /// Usage: `rmdir("target", "empty");`. /// /// Pitfall: fails when the directory is not empty. That is intentional; choose /// `rmTree(...)` explicitly for recursive deletion. void rmdir(String first, String... more) throws Exception { Files.deleteIfExists(protectDelete(path(first, more))); } /// Recursively delete a file tree. /// /// Reason: some build/cache cleanup truly needs recursive deletion, but it /// should be explicit and guarded. /// /// Usage: `rmTree("target");`. /// /// Pitfall: deletes contents recursively. It refuses HOME, filesystem roots, and /// current `pwd()`, but callers still own choosing the right path. void rmTree(String first, String... more) throws Exception { var p = protectDelete(path(first, more)); if (!Files.exists(p, LinkOption.NOFOLLOW_LINKS)) return; if (Files.isSymbolicLink(p) || !Files.isDirectory(p, LinkOption.NOFOLLOW_LINKS)) { Files.delete(p); return; } try (var stream = Files.walk(p)) { for (var x : stream.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(x); } } """; } String globHelpers() { return """ /// List direct children matching a glob in `pwd()`. /// /// Reason: simple file selection should not need shell glob expansion. /// /// Usage: `for (var file : glob("*.symlinks")) IO.println(file);`. /// /// Pitfall: this is not recursive and uses Java `DirectoryStream` glob syntax, /// not Bash globstar rules. List glob(String pattern) throws Exception { try (var stream = Files.newDirectoryStream(CWD, pattern)) { var paths = new ArrayList(); stream.forEach(paths::add); paths.sort(Comparator.naturalOrder()); return paths; } } """; } String whichHelpers() { return """ /// Find an executable on `PATH`. /// /// Reason: tasks sometimes need an explicit dependency probe without shelling out /// to `which`/`command -v`. /// /// Usage: `if (which("podman") == null) throw new IllegalStateException("podman missing");`. /// /// Pitfall: returns `null` when not found. On Windows it checks `PATHEXT`; on /// Unix it checks the exact name only. String which(String name) { var path = ENV.getOrDefault("PATH", ""); var extensions = System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win") ? ENV.getOrDefault("PATHEXT", ".EXE;.CMD;.BAT").split(Pattern.quote(File.pathSeparator)) : new String[] { "" }; for (var dir : path.split(Pattern.quote(File.pathSeparator))) { for (var extension : extensions) { var candidate = Path.of(dir, name + extension); if (Files.isExecutable(candidate)) return candidate.toString(); } } return null; } """; }