From add80c30186d0591fc74714c47a906fbf7ae9aa2 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Wed, 10 Jun 2026 23:13:46 -0400 Subject: [PATCH] deps: aliases, user config merge, tools.deps conflict semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deps.edn :aliases now work the tools.deps way, scoped to what jolt supports (git/:local, no maven): :extra-paths and :extra-deps accumulate across selected aliases, :main-opts is last-wins. The CLI grows -A:dev:test (selects aliases for path/run/repl/-e) and -M:alias (runs the alias :main-opts through jolt). A user-level deps.edn ($JOLT_CONFIG, else $XDG_CONFIG_HOME/jolt, else ~/.jolt) merges under the project file: :deps and :aliases merge per key with the project winning, everything else replaces. Resolution is now breadth-first so a top-level coordinate always beats a transitive one for the same lib (it was DFS first-wins — a dep's pin could shadow the project's own). Conflicting coordinates for one lib warn on stderr with both coords and which won. Also fixes dedup keying: it hashed the symbol struct's string repr, which carries reader position metadata, so the same lib from two files never deduped. resolve-deps-cached keys on project edn + user edn + aliases. Tests: deps-aliases-test and deps-conflicts-test, local deps only. --- src/jolt/deps.janet | 155 +++++++++++++++++---- src/jolt/deps_cli.janet | 59 +++++--- test/integration/deps-aliases-test.janet | 119 ++++++++++++++++ test/integration/deps-conflicts-test.janet | 69 +++++++++ 4 files changed, 358 insertions(+), 44 deletions(-) create mode 100644 test/integration/deps-aliases-test.janet create mode 100644 test/integration/deps-conflicts-test.janet diff --git a/src/jolt/deps.janet b/src/jolt/deps.janet index 49291f3..669b138 100644 --- a/src/jolt/deps.janet +++ b/src/jolt/deps.janet @@ -42,50 +42,153 @@ [dir edn] (map |(string dir "/" $) (or (and edn (get edn :paths)) ["src"]))) +# --- user config + aliases (tools.deps-shaped, scoped to git/:local) ----------- + +(defn config-dir + "User-level config dir: $JOLT_CONFIG, else $XDG_CONFIG_HOME/jolt, else + ~/.jolt — the same fallback chain the Clojure CLI uses for ~/.clojure." + [] + (or (os/getenv "JOLT_CONFIG") + (when-let [x (os/getenv "XDG_CONFIG_HOME")] + (when (> (length x) 0) (string x "/jolt"))) + (string (os/getenv "HOME") "/.jolt"))) + +(defn- merge-per-key [a b] # dictionary union, b's entries win + (def out @{}) + (each m [a b] (when (dictionary? m) (eachp [k v] m (put out k v)))) + out) + +(defn load-config + "The project deps.edn merged over the user-level one (config-dir)/deps.edn. + tools.deps merge semantics: scalar keys and :paths replace (project wins), + :deps and :aliases merge per key with the project winning. Relative + :local/root in the USER file is resolved against the cwd — prefer absolute + paths there." + [deps-edn-path] + (def proj (read-edn deps-edn-path)) + (def user (read-edn (string (config-dir) "/deps.edn"))) + (cond + (nil? user) proj + (nil? proj) user + (let [out (merge-per-key user proj)] + (put out :deps (merge-per-key (get user :deps) (get proj :deps))) + (put out :aliases (merge-per-key (get user :aliases) (get proj :aliases))) + out))) + +(defn combine-aliases + "Combine the selected alias keywords against `edn`'s :aliases: + :extra-paths and :extra-deps accumulate in order, :main-opts is last-wins + (the tools.deps CLI rules). Unknown alias -> error." + [edn aliases] + (def als (or (and (dictionary? edn) (get edn :aliases)) {})) + (def extra-paths @[]) + (def extra-deps @{}) + (var main-opts nil) + (each a (or aliases []) + (def spec (get als a)) + (when (nil? spec) (error (string "unknown alias: " a))) + (each p (or (get spec :extra-paths) []) (array/push extra-paths p)) + (when (dictionary? (get spec :extra-deps)) + (eachp [lib coord] (get spec :extra-deps) (put extra-deps lib coord))) + (when-let [mo (get spec :main-opts)] + (set main-opts (tuple ;(map string mo))))) + {:extra-paths extra-paths :extra-deps extra-deps :main-opts main-opts}) + +(defn alias-main-opts + "The :main-opts the selected aliases produce (last alias with the key wins), + or nil. Reads the merged user+project config." + [deps-edn-path aliases] + (get (combine-aliases (load-config deps-edn-path) aliases) :main-opts)) + (defn resolve-deps "Resolve the git/:local deps of `deps-edn-path` into an ordered, de-duplicated array of source dirs (the project's own :paths first, then each dependency's, - transitively). `tree` is where jpm's clone cache lives (default ./jpm_tree)." - [deps-edn-path &opt tree] + transitively). `tree` is where jpm's clone cache lives (default ./jpm_tree). + `aliases` (keywords) pull :extra-paths/:extra-deps from the merged config's + :aliases. The user-level deps.edn (see load-config) merges under the project." + [deps-edn-path &opt tree aliases] (default tree (string (os/cwd) "/jpm_tree")) (os/mkdir tree) (ensure-jpm-config tree) + (def edn (load-config deps-edn-path)) + (def extra (combine-aliases edn aliases)) (def roots @[]) - (def seen @{}) + (def seen @{}) # lib name -> chosen coordinate (for conflict reporting) (defn add-root [r] (unless (index-of r roots) (array/push roots r))) - (defn process [edn base-dir own-paths?] - (when (dictionary? edn) - (when own-paths? (each r (src-roots base-dir edn) (add-root r))) - (eachp [lib spec] (or (get edn :deps) {}) - (def k (string lib)) - (unless (get seen k) - (put seen k true) - (def dir - (cond - (and (dictionary? spec) (get spec :git/url)) (clone-git spec) - (and (dictionary? spec) (get spec :local/root)) - (let [lr (get spec :local/root)] - (if (string/has-prefix? "/" lr) lr (string base-dir "/" lr))) - nil)) # :mvn/* and anything else: skip - (when dir - (def dep-edn (read-edn (string dir "/deps.edn"))) - (each r (src-roots dir dep-edn) (add-root r)) - (process dep-edn dir false)))))) - (process (read-edn deps-edn-path) (os/cwd) true) + # Reader symbols carry position metadata, so dedup/conflict keys must use the + # NAME, never (string lib) — two my/b symbols from different files differ. + (defn lib-name [lib] + (if (and (dictionary? lib) (get lib :name)) + (if-let [ns (get lib :ns)] + (string ns "/" (get lib :name)) + (get lib :name)) + (string lib))) + (defn coord-str [spec] + (cond + (and (dictionary? spec) (get spec :local/root)) + (string ":local/root " (get spec :local/root)) + (and (dictionary? spec) (get spec :git/url)) + (string (get spec :git/url) " @ " (or (get spec :git/sha) (get spec :git/tag))) + (string/format "%j" spec))) + (defn coord= [a b] + (and (deep= (get a :local/root) (get b :local/root)) + (deep= (get a :git/url) (get b :git/url)) + (deep= (get a :git/sha) (get b :git/sha)) + (deep= (get a :git/tag) (get b :git/tag)))) + (def queue @[]) + (defn discover [lib spec base-dir] + (def k (lib-name lib)) + (if-let [prev (get seen k)] + (unless (coord= prev spec) + (eprintf "WARNING: %s: conflicting coordinates — using %s, ignoring %s" + k (coord-str prev) (coord-str spec))) + (do + (put seen k spec) + (def dir + (cond + (and (dictionary? spec) (get spec :git/url)) (clone-git spec) + (and (dictionary? spec) (get spec :local/root)) + (let [lr (get spec :local/root)] + (if (string/has-prefix? "/" lr) lr (string base-dir "/" lr))) + nil)) # :mvn/* and anything else: skip + (when dir + (def dep-edn (read-edn (string dir "/deps.edn"))) + (each r (src-roots dir dep-edn) (add-root r)) + (array/push queue [dep-edn dir]))))) + # the project's own paths (+ alias extra paths) lead the roots + (each r (src-roots (os/cwd) edn) (add-root r)) + (each pp (extra :extra-paths) (add-root (string (os/cwd) "/" pp))) + # breadth-first: every top-level dep (incl. alias :extra-deps) registers + # before any transitive dep — so a top-level coordinate always wins, + # matching tools.deps + (eachp [lib spec] (or (and (dictionary? edn) (get edn :deps)) {}) + (discover lib spec (os/cwd))) + (eachp [lib spec] (extra :extra-deps) + (discover lib spec (os/cwd))) + (while (> (length queue) 0) + (def [dep-edn dir] (get queue 0)) + (array/remove queue 0) + (when (dictionary? dep-edn) + (eachp [lib spec] (or (get dep-edn :deps) {}) + (discover lib spec dir)))) roots) (defn resolve-deps-cached "Like resolve-deps, but caches the resolved roots in the tree keyed on a hash - of the deps.edn, so an unchanged deps.edn resolves without re-fetching." - [deps-edn-path &opt tree] + of the project deps.edn + the user deps.edn + the selected aliases, so an + unchanged config resolves without re-fetching." + [deps-edn-path &opt tree aliases] (default tree (string (os/cwd) "/jpm_tree")) (when (os/stat deps-edn-path) (os/mkdir tree) (def cache-file (string tree "/.jolt-deps-roots.jdn")) - (def h (hash (slurp deps-edn-path))) + (def user-path (string (config-dir) "/deps.edn")) + (def h (hash [(slurp deps-edn-path) + (when (os/stat user-path) (slurp user-path)) + (string/format "%j" (map string (or aliases [])))])) (def cached (when (os/stat cache-file) (try (parse (slurp cache-file)) ([_] nil)))) (if (and cached (= h (get cached :hash))) (get cached :roots) - (let [roots (resolve-deps deps-edn-path tree)] + (let [roots (resolve-deps deps-edn-path tree aliases)] (spit cache-file (string/format "%j" {:hash h :roots roots})) roots)))) diff --git a/src/jolt/deps_cli.janet b/src/jolt/deps_cli.janet index 9c8308e..8a64478 100644 --- a/src/jolt/deps_cli.janet +++ b/src/jolt/deps_cli.janet @@ -5,40 +5,63 @@ # api/init). This tool does the resolution (git + :local deps, via jpm's fetch # cache) and either prints the roots or launches jolt with JOLT_PATH set. # -# jolt-deps path print the resolved roots (':'-joined), e.g. for -# JOLT_PATH=$(jolt-deps path) jolt file.clj -# jolt-deps run FILE [args] resolve, then run `jolt FILE args` with JOLT_PATH set -# jolt-deps repl resolve, then start a jolt REPL with JOLT_PATH set -# jolt-deps -e EXPR [args] resolve, then `jolt -e EXPR ...` with JOLT_PATH set -# jolt-deps uberscript OUT -m NS -# resolve, then bundle NS + its deps into one .clj +# jolt-deps [-A:a:b] path print the resolved roots (':'-joined), e.g. +# JOLT_PATH=$(jolt-deps path) jolt file.clj +# jolt-deps [-A:a:b] run FILE [args] +# resolve, then `jolt FILE args` with JOLT_PATH set +# jolt-deps [-A:a:b] repl resolve, then a jolt REPL with JOLT_PATH set +# jolt-deps [-A:a:b] -e EXPR resolve, then `jolt -e EXPR ...` +# jolt-deps -M:a[:b] [args] resolve with the aliases, then run jolt with +# the last alias's :main-opts ++ args +# jolt-deps uberscript OUT -m NS resolve, then bundle NS + deps into one .clj # +# -A:dev:test selects aliases (tools.deps style): their :extra-paths and +# :extra-deps join the resolution. A user-level deps.edn ($JOLT_CONFIG, else +# $XDG_CONFIG_HOME/jolt, else ~/.jolt) merges under the project's. # The jolt binary is found via $JOLT_BIN, else `jolt` on PATH. (import ./deps :as deps) -(defn- roots [] - (if (os/stat "deps.edn") (deps/resolve-deps-cached "deps.edn") @[])) +(defn- parse-alias-flag + "\"-A:dev:test\" -> [:dev :test] (also accepts -M:...)." + [arg] + (map keyword (filter |(not= "" $) (string/split ":" (string/slice arg 2))))) -(defn- exec-jolt [extra-args] +(defn- roots [aliases] + (if (os/stat "deps.edn") (deps/resolve-deps-cached "deps.edn" nil aliases) @[])) + +(defn- exec-jolt [aliases extra-args] # Set JOLT_PATH in our own env and let the child inherit it (os/execute's env # arg isn't honored here; inheriting is reliable). - (def rs (string/join (roots) ":")) + (def rs (string/join (roots aliases) ":")) (def existing (os/getenv "JOLT_PATH")) (os/setenv "JOLT_PATH" (if (and existing (> (length existing) 0)) (string rs ":" existing) rs)) (os/execute [(os/getenv "JOLT_BIN" "jolt") ;extra-args] :p)) (defn- usage [] - (print "usage: jolt-deps [path | run FILE [args] | repl | -e EXPR [args]]")) + (print "usage: jolt-deps [-A:alias[:alias]] [path | run FILE [args] | repl | -e EXPR [args]]") + (print " jolt-deps -M:alias[:alias] [args] (runs the alias :main-opts)") + (print " jolt-deps uberscript OUT -m NS")) (defn main [&] - (def argv (tuple/slice (or (dyn :args) @[]) 1)) + (var argv (tuple/slice (or (dyn :args) @[]) 1)) + (var aliases nil) + # leading -A:... selects aliases for whatever command follows + (while (string/has-prefix? "-A" (or (get argv 0) "")) + (set aliases (array/concat (or aliases @[]) (parse-alias-flag (get argv 0)))) + (set argv (tuple/slice argv 1))) (def cmd (get argv 0)) (cond (or (nil? cmd) (= cmd "help") (= cmd "-h") (= cmd "--help")) (usage) - (= cmd "path") (print (string/join (roots) ":")) - (= cmd "run") (os/exit (exec-jolt (tuple/slice argv 1))) - (= cmd "repl") (os/exit (exec-jolt [])) - (= cmd "-e") (os/exit (exec-jolt argv)) - (= cmd "uberscript") (os/exit (exec-jolt argv)) + (string/has-prefix? "-M" cmd) + (let [als (array/concat (or aliases @[]) (parse-alias-flag cmd)) + mo (or (deps/alias-main-opts "deps.edn" als) + (do (eprint "jolt-deps: no :main-opts in aliases " (string/format "%j" (map string als))) + (os/exit 1)))] + (os/exit (exec-jolt als [;mo ;(tuple/slice argv 1)]))) + (= cmd "path") (print (string/join (roots aliases) ":")) + (= cmd "run") (os/exit (exec-jolt aliases (tuple/slice argv 1))) + (= cmd "repl") (os/exit (exec-jolt aliases [])) + (= cmd "-e") (os/exit (exec-jolt aliases argv)) + (= cmd "uberscript") (os/exit (exec-jolt aliases argv)) (do (eprint "jolt-deps: unknown command " cmd) (usage) (os/exit 1)))) diff --git a/test/integration/deps-aliases-test.janet b/test/integration/deps-aliases-test.janet new file mode 100644 index 0000000..00b21f1 --- /dev/null +++ b/test/integration/deps-aliases-test.janet @@ -0,0 +1,119 @@ +# deps.edn aliases + user-level config merge (jolt-4go). +# +# Mirrors tools.deps semantics scoped to what jolt supports (git/:local, no +# maven): :aliases with :extra-paths / :extra-deps / :main-opts selected by +# keyword; a user deps.edn (under $JOLT_CONFIG, else $XDG_CONFIG_HOME/jolt, +# else ~/.jolt) merged UNDER the project file — :deps and :aliases merge per +# key with the project winning, :paths replaces. Local deps only: no network. + +(import ../../src/jolt/deps :as deps) + +(def base (string (or (os/getenv "TMPDIR") "/tmp") "/jolt-deps-aliases-" (os/time))) +(defn rmrf [p] + (when (os/stat p) + (if (= :directory (os/stat p :mode)) + (do (each e (os/dir p) (rmrf (string p "/" e))) (os/rmdir p)) + (os/rm p)))) +(rmrf base) + +(defn mkdirs [p] + (def abs (string/has-prefix? "/" p)) + (var acc nil) + (each seg (filter |(not= "" $) (string/split "/" p)) + (set acc (cond (nil? acc) (if abs (string "/" seg) seg) (string acc "/" seg))) + (unless (os/stat acc) (os/mkdir acc)))) + +(each d ["proj/src" "proj/dev" "proj/test" "a/src" "c/src" "u/src" "config"] + (mkdirs (string base "/" d))) + +# project: src + dep a; :dev alias adds dev/ + dep c; :test alias adds test/ +(spit (string base "/proj/deps.edn") + `{:paths ["src"] + :deps {my/a {:local/root "../a"}} + :aliases {:dev {:extra-paths ["dev"] + :extra-deps {my/c {:local/root "../c"}}} + :test {:extra-paths ["test"] + :main-opts ["-e" "(run-tests)"]} + :bench {:main-opts ["-e" "(bench)"]}}}`) +(spit (string base "/a/deps.edn") `{:paths ["src"]}`) +(spit (string base "/c/deps.edn") `{:paths ["src"]}`) +(spit (string base "/u/deps.edn") `{:paths ["src"]}`) + +# user-level config: a :user-tool alias the project file doesn't have, plus a +# :dev alias that the PROJECT's :dev must shadow (per-key merge, project wins) +(spit (string base "/config/deps.edn") + `{:aliases {:user-tool {:extra-deps {my/u {:local/root "BASE/u"}}} + :dev {:extra-paths ["should-not-win"]}}}`) +# :local/root in the user file is relative to... nothing useful; use absolute +(spit (string base "/config/deps.edn") + (string/replace "BASE" base (slurp (string base "/config/deps.edn")))) + +(var fails 0) +(defn check [label got expected] + (if (= got expected) (print " ok " label) + (do (++ fails) (printf " FAIL %s: want %q, got %q" label expected got)))) +(defn has-suffix-root [roots suff] + (truthy? (some |(string/has-suffix? suff $) roots))) + +(os/cd (string base "/proj")) +(os/setenv "JOLT_CONFIG" (string base "/config")) +(def tree (string base "/proj/jpm_tree")) + +# --- no aliases: plain resolution, dev/test/c absent --------------------------- +(def plain (deps/resolve-deps "deps.edn" tree)) +(check "plain: project src present" (has-suffix-root plain "/proj/src") true) +(check "plain: dep a present" (has-suffix-root plain "/a/src") true) +(check "plain: alias path absent" (has-suffix-root plain "/proj/dev") false) +(check "plain: alias dep absent" (has-suffix-root plain "/c/src") false) + +# --- :dev alias: extra-paths + extra-deps -------------------------------------- +(def dev (deps/resolve-deps "deps.edn" tree [:dev])) +(check "dev: extra path present" (has-suffix-root dev "/proj/dev") true) +(check "dev: extra dep present" (has-suffix-root dev "/c/src") true) +(check "dev: base dep still present" (has-suffix-root dev "/a/src") true) +(check "dev: project :dev shadows user :dev" + (has-suffix-root dev "/proj/should-not-win") false) + +# --- multiple aliases combine --------------------------------------------------- +(def both (deps/resolve-deps "deps.edn" tree [:dev :test])) +(check "multi: both extra paths" + (and (has-suffix-root both "/proj/dev") (has-suffix-root both "/proj/test")) true) + +# --- alias from the USER deps.edn ---------------------------------------------- +(def ut (deps/resolve-deps "deps.edn" tree [:user-tool])) +(check "user alias resolves its dep" (has-suffix-root ut "/u/src") true) + +# --- main-opts: from alias, last alias wins ------------------------------------ +(check "main-opts from alias" + (deps/alias-main-opts "deps.edn" [:test]) ["-e" "(run-tests)"]) +(check "main-opts last alias wins" + (deps/alias-main-opts "deps.edn" [:test :bench]) ["-e" "(bench)"]) +(check "main-opts absent is nil" + (deps/alias-main-opts "deps.edn" [:dev]) nil) + +# --- cached resolution keys on aliases + user config ---------------------------- +(check "cache: aliased result differs from plain" + (deep= (deps/resolve-deps-cached "deps.edn" tree) + (deps/resolve-deps-cached "deps.edn" tree [:dev])) + false) +(check "cache: same key returns same roots" + (deep= (deps/resolve-deps-cached "deps.edn" tree [:dev]) + (deps/resolve-deps-cached "deps.edn" tree [:dev])) + true) + +# --- unknown alias errors -------------------------------------------------------- +(check "unknown alias errors" + (let [r (protect (deps/resolve-deps "deps.edn" tree [:nope]))] (r 0)) + false) + +# --- works without a user config (env unset) ------------------------------------ +(os/setenv "JOLT_CONFIG" (string base "/no-such-dir")) +(check "no user config still resolves" + (has-suffix-root (deps/resolve-deps "deps.edn" tree) "/a/src") true) + +(os/cd "/") +(rmrf base) + +(if (> fails 0) + (error (string "deps-aliases-test: " fails " failing check(s)")) + (print "\nAll deps-aliases tests passed!")) diff --git a/test/integration/deps-conflicts-test.janet b/test/integration/deps-conflicts-test.janet new file mode 100644 index 0000000..dd082e4 --- /dev/null +++ b/test/integration/deps-conflicts-test.janet @@ -0,0 +1,69 @@ +# deps.edn conflict semantics (jolt-42f), tools.deps-shaped: +# a TOP-LEVEL :deps entry beats any transitive coordinate for the same lib +# (resolution is breadth-first, top level enqueued first), and when two +# DIFFERENT coordinates for one lib meet, a warning naming both goes to stderr. +# Local deps only: no network. + +(import ../../src/jolt/deps :as deps) + +(def base (string (or (os/getenv "TMPDIR") "/tmp") "/jolt-deps-conflicts-" (os/time))) +(defn rmrf [p] + (when (os/stat p) + (if (= :directory (os/stat p :mode)) + (do (each e (os/dir p) (rmrf (string p "/" e))) (os/rmdir p)) + (os/rm p)))) +(rmrf base) +(defn mkdirs [p] + (def abs (string/has-prefix? "/" p)) + (var acc nil) + (each seg (filter |(not= "" $) (string/split "/" p)) + (set acc (cond (nil? acc) (if abs (string "/" seg) seg) (string acc "/" seg))) + (unless (os/stat acc) (os/mkdir acc)))) + +# proj depends on A and on B@b2 (top level). A depends on B@b1 (transitive). +# DFS-first-wins would pick b1; tools.deps semantics pick the top-level b2. +(each d ["proj/src" "a/src" "b1/src" "b2/src"] (mkdirs (string base "/" d))) +(spit (string base "/proj/deps.edn") + `{:paths ["src"] + :deps {my/a {:local/root "../a"} + my/b {:local/root "../b2"}}}`) +(spit (string base "/a/deps.edn") + `{:paths ["src"] :deps {my/b {:local/root "../b1"}}}`) +(spit (string base "/b1/deps.edn") `{:paths ["src"]}`) +(spit (string base "/b2/deps.edn") `{:paths ["src"]}`) + +(var fails 0) +(defn check [label got expected] + (if (= got expected) (print " ok " label) + (do (++ fails) (printf " FAIL %s: want %q, got %q" label expected got)))) +(defn has-suffix-root [roots suff] + (truthy? (some |(string/has-suffix? suff $) roots))) + +(os/cd (string base "/proj")) +(def tree (string base "/proj/jpm_tree")) + +(def warn-buf @"") +(def roots (with-dyns [:err warn-buf] (deps/resolve-deps "deps.edn" tree))) + +(check "top-level coordinate wins" (has-suffix-root roots "/b2/src") true) +(check "transitive loser not on path" (has-suffix-root roots "/b1/src") false) +(check "dep a still resolved" (has-suffix-root roots "/a/src") true) +(check "conflict warning names the lib" + (truthy? (string/find "my/b" (string warn-buf))) true) +(check "conflict warning names both coordinates" + (and (truthy? (string/find "../b1" (string warn-buf))) + (truthy? (string/find "../b2" (string warn-buf)))) true) + +# same coordinate twice from different parents: no warning +(spit (string base "/a/deps.edn") + `{:paths ["src"] :deps {my/b {:local/root "../b2"}}}`) +(def warn2 @"") +(with-dyns [:err warn2] (deps/resolve-deps "deps.edn" tree)) +(check "agreeing coordinates warn nothing" (string warn2) "") + +(os/cd "/") +(rmrf base) + +(if (> fails 0) + (error (string "deps-conflicts-test: " fails " failing check(s)")) + (print "\nAll deps-conflicts tests passed!"))