deps.edn resolution + a file loader + a project-aware CLI

joltc grew from a single -e expression into a real project runner. require now
loads a namespace's .clj/.cljc from the source roots transitively (load-once),
so a multi-file project works; the corpus/unit gates load compile-eval.ss but
not the loader, so their alias-only require is unchanged.

jolt.deps resolves a deps.edn into ordered source roots — git + local deps only
(no Maven), breadth-first so a top-level pin wins, with aliases (:extra-paths/
:extra-deps/:main-opts) and tasks. Git deps clone into a sha-immutable cache
($JOLT_GITLIBS, else ~/.jolt/gitlibs) by shelling out to git via a new
jolt.host/sh primitive. jolt.main dispatches run -m / -M:alias / -A / repl /
path / a deps.edn task. The launcher passes the user's cwd as JOLT_PWD (the
project dir) since it cd's to the repo root for the runtime's relative loads.
This commit is contained in:
Yogthos 2026-06-22 02:06:05 -04:00
parent 85cec65937
commit 7e2704642b
5 changed files with 451 additions and 29 deletions

125
jolt-core/jolt/deps.clj Normal file
View file

@ -0,0 +1,125 @@
(ns jolt.deps
"Resolve a deps.edn into an ordered list of source roots git + local deps
only, no Maven. A reduced tools.deps: :paths, :deps (`:git/url`+`:git/sha` /
`:local/root`), :aliases (:extra-paths / :extra-deps / :main-opts), :tasks.
The deps walk is breadth-first so a top-level coordinate registers before any
transitive one (a top-level pin wins). Git deps clone into a sha-immutable
cache ($JOLT_GITLIBS, else ~/.jolt/gitlibs) shared across projects. Resolution
shells out to `git` through jolt.host/sh; nothing here touches the JVM."
(:require [clojure.edn :as edn]
[clojure.string :as str]))
;; --- small host seams -------------------------------------------------------
(defn- getenv [n] (jolt.host/getenv n))
(defn- file-exists? [p] (jolt.host/file-exists? p))
(defn- sh [cmd] (jolt.host/sh cmd)) ; exit code, inherits stdout/stderr
(defn- sh-out [cmd] (jolt.host/sh-out cmd)) ; captured stdout
(defn- warn [& xs] (println (str "[jolt.deps] " (apply str xs))))
(defn- read-edn [path]
(when (file-exists? path)
(try (edn/read-string (slurp path))
(catch :default e (warn "could not read " path ": " (ex-message e)) nil))))
(defn- abspath [dir p]
(if (str/starts-with? p "/") p (str dir "/" p)))
;; --- git cache --------------------------------------------------------------
(defn- gitlibs-dir []
(or (getenv "JOLT_GITLIBS")
(str (or (getenv "HOME") ".") "/.jolt/gitlibs")))
(defn- alnum? [c]
(let [n (int c)]
(or (and (>= n 48) (<= n 57)) ; 0-9
(and (>= n 65) (<= n 90)) ; A-Z
(and (>= n 97) (<= n 122))))) ; a-z
(defn- sanitize [s]
(str/join (map (fn [c] (if (or (alnum? c) (= c \.) (= c \-)) c \_)) (seq s))))
(defn- ensure-git
"Clone url at sha into the cache (once); return the checkout dir."
[url sha]
(let [dir (str (gitlibs-dir) "/" (sanitize url) "/" sha)]
(if (file-exists? dir)
dir
(do
(warn "fetching " url " @ " (subs sha 0 (min 12 (count sha))))
(sh (str "mkdir -p " (pr-str dir)))
(when-not (zero? (sh (str "git clone --quiet " (pr-str url) " " (pr-str dir))))
(throw (ex-info (str "git clone failed: " url) {:url url})))
(when-not (zero? (sh (str "git -C " (pr-str dir) " checkout --quiet " (pr-str sha))))
(throw (ex-info (str "git checkout failed: " sha " in " url) {:url url :sha sha})))
;; submodules are pinned in the checkout; pull them if the dep uses any.
(sh (str "git -C " (pr-str dir) " submodule update --init --recursive --quiet"))
dir))))
;; --- coordinate -> root dir -------------------------------------------------
(defn- coord-root
"The on-disk root directory for one dependency coordinate, or nil to skip."
[coord spec base-dir]
(cond
(:local/root spec) (abspath base-dir (:local/root spec))
(and (:git/url spec) (:git/sha spec))
(let [checkout (ensure-git (:git/url spec) (:git/sha spec))]
(if-let [root (:deps/root spec)] (str checkout "/" root) checkout))
(:jolt/module spec)
(do (warn "skipping janet dependency " coord " (:jolt/module is obsolete on Chez)") nil)
:else
(do (warn "skipping unsupported coordinate " coord " " (pr-str spec)) nil)))
(defn- dep-source-roots
"Source roots a resolved dep contributes: its deps.edn :paths (default [\"src\"])
resolved under its root dir."
[root]
(let [edn (read-edn (str root "/deps.edn"))
paths (or (:paths edn) ["src"])]
(map #(abspath root %) paths)))
(defn- resolve-deps
"Breadth-first walk of a deps map; returns an ordered, de-duplicated list of
source-root directories. `base-dir` resolves :local/root and is replaced by a
dep's own root as the walk descends."
[deps base-dir]
(loop [queue (mapv (fn [[c s]] [c s base-dir]) (seq deps))
seen #{}
roots []]
(if (empty? queue)
(distinct roots)
(let [[coord spec bd] (first queue)
queue (subvec (vec queue) 1)]
(if (contains? seen coord)
(recur queue seen roots)
(let [root (coord-root coord spec bd)]
(if (nil? root)
(recur queue (conj seen coord) roots)
(let [edn (read-edn (str root "/deps.edn"))
child (mapv (fn [[c s]] [c s root]) (seq (:deps edn)))]
(recur (into queue child)
(conj seen coord)
(into roots (dep-source-roots root)))))))))))
;; --- public -----------------------------------------------------------------
(defn resolve-project
"Resolve `project-dir`'s deps.edn with the selected alias keywords. Returns
{:roots [...] :main-opts [...] :tasks {...}}; :main-opts is the last selected
alias's, else nil."
([project-dir] (resolve-project project-dir []))
([project-dir alias-kws]
(let [edn (read-edn (str project-dir "/deps.edn"))
aliases (:aliases edn)
selected (keep #(get aliases %) alias-kws)
extra-paths (mapcat :extra-paths selected)
extra-deps (apply merge (map :extra-deps selected))
main-opts (some :main-opts (reverse selected))
project-paths (concat (or (:paths edn) ["src"]) extra-paths)
project-roots (map #(abspath project-dir %) project-paths)
all-deps (merge (:deps edn) extra-deps)
dep-roots (resolve-deps all-deps project-dir)]
{:roots (vec (distinct (concat project-roots dep-roots)))
:main-opts main-opts
:tasks (:tasks edn)})))
(defn has-deps-edn? [project-dir]
(file-exists? (str project-dir "/deps.edn")))

106
jolt-core/jolt/main.clj Normal file
View file

@ -0,0 +1,106 @@
(ns jolt.main
"The jolt CLI dispatch: resolve a project's deps.edn, set the source roots, and
run a namespace's -main, a file, a deps.edn task, or a REPL. Driven by cli.ss,
which hands it the raw argv; the project directory is JOLT_PWD (the user's cwd
before the launcher cd'd to the jolt repo)."
(:require [jolt.deps :as deps]
[clojure.string :as str]))
(defn- project-dir [] (or (jolt.host/getenv "JOLT_PWD") "."))
;; Apply a resolved project's roots on top of the current (jolt-core) roots so app
;; namespaces resolve while jolt.* stays loadable.
(defn- apply-roots! [roots]
(jolt.host/set-source-roots! (vec (distinct (concat roots (jolt.host/source-roots))))))
(defn- run-ns
"Require ns-name and invoke its -main with the string app args."
[ns-name app-args]
(require (symbol ns-name))
(if-let [mainv (ns-resolve (symbol ns-name) (symbol "-main"))]
(apply (deref mainv) app-args)
(throw (ex-info (str "namespace " ns-name " has no -main") {:ns ns-name}))))
;; main-opts is a vector like ["-m" "app.core"] (optionally trailing args). Apply
;; it with the user-supplied extra args appended.
(defn- apply-main-opts [main-opts extra-args]
(cond
(and (seq main-opts) (= "-m" (first main-opts)))
(run-ns (second main-opts) (concat (drop 2 main-opts) extra-args))
:else
(throw (ex-info (str "unsupported :main-opts " (pr-str main-opts)) {}))))
(defn- parse-aliases [s] ; "-M:a:b" / ":a:b" -> [:a :b]
(let [s (if (str/starts-with? s "-") (subs s 2) s)]
(->> (str/split s #":") (remove str/blank?) (map keyword) vec)))
;; run [-m NS args… | FILE]
(defn- cmd-run [more]
(let [{:keys [roots]} (deps/resolve-project (project-dir))]
(apply-roots! roots)
(cond
(= "-m" (first more)) (run-ns (second more) (drop 2 more))
(seq more) (do (load-file (first more)) nil)
:else (throw (ex-info "run needs -m NS or a FILE" {})))))
;; -M:alias… — resolve with the aliases, run their :main-opts
(defn- cmd-M [arg more]
(let [aliases (parse-aliases arg)
{:keys [roots main-opts]} (deps/resolve-project (project-dir) aliases)]
(apply-roots! roots)
(if main-opts
(apply-main-opts main-opts more)
(throw (ex-info (str "alias(es) " (pr-str aliases) " have no :main-opts") {})))))
;; -A:alias… — add the aliases' paths/deps, then run the remaining argv as a command
(defn- cmd-A [arg more]
(let [aliases (parse-aliases arg)
{:keys [roots]} (deps/resolve-project (project-dir) aliases)]
(apply-roots! roots)
(when (seq more) (run-ns (second more) (drop 2 more)))))
(defn- cmd-path []
(let [{:keys [roots]} (deps/resolve-project (project-dir))]
(println (str/join ":" roots))))
(defn- repl []
(println ";; jolt repl — ^D to exit")
(loop []
(print "user=> ") (flush)
(let [line (read-line)]
(when line
(try (println (pr-str (load-string line)))
(catch :default e (println "error:" (ex-message e))))
(recur)))))
;; A deps.edn :tasks entry: a string is a shell command; a map is {:main-opts …}.
(defn- run-task [name more]
(let [{:keys [roots tasks]} (deps/resolve-project (project-dir))
task (get tasks (symbol name))]
(cond
(nil? task) (throw (ex-info (str "unknown command or task: " name) {:name name}))
(string? task) (jolt.host/sh task)
(map? task) (do (apply-roots! roots) (apply-main-opts (:main-opts task) more))
:else (throw (ex-info (str "bad task " name) {})))))
(defn- usage []
(println "usage: jolt <command> [args]")
(println " run -m NS [args] resolve deps.edn, load NS, call its -main")
(println " run FILE load a Clojure file")
(println " -M:alias [args] run the alias's :main-opts")
(println " -A:alias [args] add the alias's paths/deps")
(println " repl start a line REPL")
(println " path print the resolved source roots")
(println " <task> run a deps.edn :tasks entry"))
(defn -main [& args]
(let [[cmd & more] args]
(cond
(nil? cmd) (usage)
(= cmd "run") (cmd-run more)
(= cmd "repl") (repl)
(= cmd "path") (cmd-path)
(str/starts-with? cmd "-M") (cmd-M cmd more)
(str/starts-with? cmd "-A") (cmd-A cmd more)
(= cmd "-m") (cmd-run (cons "-m" more))
:else (run-task cmd more))))