jerboa: add single-file-package format (exec command + header parser)
ober
fb22e5080e67416e79fde6bd5c2318125347029b
--- a/bin/jerboa +++ b/bin/jerboa @@ -31,6 +31,8 @@ Commands: (no args) Launch the Jerboa REPL repl Launch the Jerboa REPL run <file> Run a Scheme script + exec <file> Run a self-contained script with a requires: header + (auto-installs declared dependencies before running) eval '<expr>' Evaluate a single expression test [dir] Discover and run test files (default: tests/) build Run make build in the project directory @@ -69,6 +71,120 @@ cmd_run() { exec "$SCHEME" --libdirs "$LIBDIRS" --script "$file" } +## +## parse_requires_header <file> +## +## Extract dependency URLs from a ";;; jerboa-package" header block. +## The header lives at the top of the file and looks like: +## +## ;;; jerboa-package +## ;;; name: my-app +## ;;; requires: +## ;;; github.com/alice/jerboa-foo +## ;;; github.com/bob/jerboa-bar +## ;;; +## +## The block ends at the first line that is not a ";;;" comment, or at a +## bare ";;;" (blank comment) after the requires: key. Unknown keys are +## ignored. Any line that isn't "requires:" or an entry under it is +## skipped. +## +## Prints one URL per line. Prints nothing if no header is present. +parse_requires_header() { + awk ' + BEGIN { in_block = 0; in_requires = 0 } + # A line like ";;;" or ";;; " ends the requires sub-block. + /^;;;[[:space:]]*$/ { + if (in_block) { in_requires = 0; next } + exit + } + # Non-comment line: header is over. + !/^;;;/ { + if (in_block) exit + next + } + # Header marker + /^;;;[[:space:]]*jerboa-package[[:space:]]*$/ { in_block = 1; next } + # Only process comments inside the block + in_block == 0 { next } + # Key: value line + /^;;;[[:space:]]*[A-Za-z_-]+:[[:space:]]*/ { + # "requires:" with no value starts a sub-block + if ($0 ~ /^;;;[[:space:]]*requires:[[:space:]]*$/) { + in_requires = 1 + next + } + # Any other key: line exits the requires sub-block + in_requires = 0 + next + } + # Entry line under requires: + in_requires == 1 { + line = $0 + sub(/^;;;[[:space:]]*/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line != "") print line + } + ' "$1" +} + +cmd_exec() { + if [ $# -eq 0 ]; then + echo "Error: jerboa exec requires a file argument" >&2 + echo "Usage: jerboa exec <file>" >&2 + exit 1 + fi + local file="$1" + shift + if [ ! -f "$file" ]; then + echo "Error: file not found: $file" >&2 + exit 1 + fi + + local -a deps=() + while IFS= read -r dep; do + [ -n "$dep" ] && deps+=("$dep") + done < <(parse_requires_header "$file") + + if [ ${#deps[@]} -gt 0 ]; then + local -a missing=() + for dep in "${deps[@]}"; do + local name + name="$(basename "$dep")" + if ! "$SCHEME" --libdirs "$LIBDIRS" --program <(cat <<CHECK +(import (jerboa registry)) +(exit (if (package-installed? "$name") 0 1)) +CHECK + ) >/dev/null 2>&1; then + missing+=("$dep") + fi + done + + if [ ${#missing[@]} -gt 0 ]; then + echo "jerboa exec: $file declares ${#missing[@]} missing package(s):" + for dep in "${missing[@]}"; do + echo " - $dep" + done + if [ "${JERBOA_EXEC_YES:-}" != "1" ]; then + printf "Install now? [y/N] " + read -r reply + case "$reply" in + y|Y|yes|YES) ;; + *) echo "Aborted." >&2; exit 1 ;; + esac + fi + for dep in "${missing[@]}"; do + "$0" install "$dep" || { + echo "Failed to install $dep" >&2 + exit 1 + } + done + fi + fi + + exec "$SCHEME" --libdirs "$LIBDIRS" --script "$file" "$@" +} + cmd_eval() { if [ $# -eq 0 ]; then echo "Error: jerboa eval requires an expression" >&2 @@ -225,6 +341,10 @@ case "${1:-}" in shift cmd_run "$@" ;; + exec) + shift + cmd_exec "$@" + ;; eval) shift cmd_eval "$@" new file mode 100644 --- /dev/null +++ b/docs/single-file-packages.md @@ -0,0 +1,113 @@ +# Single-file Jerboa packages + +Most `.ss` scripts are just "run it with `scheme --script`" — they depend +only on the Jerboa standard library. But sometimes a script wants an +external package (e.g., a Jerboa binding for some C library on GitHub). +Traditionally that requires the reader to: + +1. Find the package. +2. `jerboa install github.com/someone/jerboa-foo`. +3. *Then* run the script. + +The **single-file package format** eliminates step 1-2 for the reader: +the script declares its dependencies in a machine-readable header, and +`jerboa exec` installs anything missing before running. LLMs can emit +complete, runnable programs in a single file. + +## Format + +Put a block of `;;;` comments at the top of the file, starting with the +marker `;;; jerboa-package`: + +```scheme +#!/usr/bin/env jerboa +;;; jerboa-package +;;; name: my-app +;;; version: 0.1.0 +;;; requires: +;;; github.com/alice/jerboa-fancy-json +;;; github.com/bob/jerboa-http-ext +;;; +;;; Free-form description can follow an empty ;;; comment — +;;; these lines are ignored by the parser. +(import (jerboa prelude)) +(import (fancy-json)) +;; ...rest of script +``` + +### Rules + +1. The header must begin at the first `;;; jerboa-package` line. +2. Keys take the form `key: value` inside `;;; ` comments. Recognised + keys: `name`, `version`, `requires`. +3. `requires:` is special — it is followed by indented `;;; url` + lines, one package URL per line. The sub-block ends at a bare + `;;;` (blank comment) or at the next recognised key. +4. The header block ends at the first non-`;;;` line. +5. Unknown keys are ignored (forwards-compatible). + +## Running + +```bash +jerboa exec my-app.ss +``` + +On first run, `jerboa exec` parses the header, compares the required +packages against `jerboa list`, and for each missing one prompts: + +``` +jerboa exec: my-app.ss declares 2 missing package(s): + - github.com/alice/jerboa-fancy-json + - github.com/bob/jerboa-http-ext +Install now? [y/N] +``` + +On `y` it installs each via `jerboa install` and then runs the script. +Set `JERBOA_EXEC_YES=1` to skip the prompt (useful for CI or shebang +scripts). + +## Shebang trick + +With the executable bit set and `jerboa` on `PATH`: + +```scheme +#!/usr/bin/env -S jerboa exec +;;; jerboa-package +;;; name: hello +;;; requires: +;;; github.com/alice/jerboa-greetings +;;; +(import (jerboa prelude) (greetings)) +(greet "world") +``` + +```bash +chmod +x hello.ss +./hello.ss +``` + +Note: `-S` is needed on Linux so `env` forwards two arguments to +`jerboa`. On systems without `env -S`, use an explicit two-line shell +wrapper instead. + +## Why not a separate manifest file? + +Scheme projects with complex layouts still want proper `package.sls` / +Makefile-style manifests. This format targets the other end of the +spectrum: **scripts small enough to live in one file** — exactly the +kind of artifact LLMs produce. A scattered-file layout is friction for +casual sharing via gist, paste, or email. + +## Comparison + +| Tool | Format | +|----------|-----------------------------------------| +| Deno | `// deno-types="..."` in source | +| Go | `// +build ...` comments | +| Python | `# /// script` PEP-723 block | +| Jerboa | `;;; jerboa-package` comment block | + +All four live at the top of a `.ss` / `.ts` / `.go` / `.py` file, use +comment syntax native to the host language, and are parsed by a +separate runner that resolves dependencies before invoking the +interpreter/compiler.