diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f9ba4b..fa601d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,29 @@ jobs: - name: nushell completions run: nu script/completion-smoke.nu + test-squint: + runs-on: ubuntu-latest + + steps: + - name: "Checkout code" + uses: "actions/checkout@v7" + + - name: Prepare java + uses: actions/setup-java@v5 + with: + distribution: "zulu" + java-version: 21 + + - name: Setup Clojure + uses: DeLaGuardo/setup-clojure@13.6.1 + with: + bb: 1.12.218 + cli: latest + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Run tests + run: bb squint-test + test-cljd: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 18fa183..dc3e25a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ README.html pubspec.lock lib/cljd-out test/cljd-out +node_modules +.squint-out diff --git a/CHANGELOG.md b/CHANGELOG.md index 45813c1..246021d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ For breaking changes, check [here](#breaking-changes). ## Unreleased +- Squint support (v0.14.196+) - [#166](https://github.com/babashka/cli/issues/166): trigger negation invalid error for `--no-foo` when `:foo`'s specified `:coerce` is not `:boolean` ([@lread](https://github.com/lread)) diff --git a/bb.edn b/bb.edn index 7088e3a..44bd279 100644 --- a/bb.edn +++ b/bb.edn @@ -20,6 +20,11 @@ cljs-test {:doc "Run CLJS tests" :task (apply clojure "-M:test:cljs-test" *command-line-args*)} + squint-test {:doc "Run squint tests" + :task (do (shell "npm" "install" "--no-save" "--prefix" "." "squint-cljs@latest") + (shell "node_modules/.bin/squint" "compile") + (shell "node" ".squint-out/babashka/run_tests.mjs"))} + cljd-test {:doc "Run ClojureDart tests (requires Dart SDK: https://dart.dev/get-dart)" :task (do (shell "dart pub get") (apply clojure "-M:cljd" "test" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b7ada42 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "cli", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "squint-cljs": "^0.13.195" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/squint-cljs": { + "version": "0.13.195", + "resolved": "https://registry.npmjs.org/squint-cljs/-/squint-cljs-0.13.195.tgz", + "integrity": "sha512-W8TeNy58jMta0v5SyDwPI58y1IQ9Q3OG3gEZbGa+pqRrmkZEVJ5J4K2OovRJO33tQ5sJVqwjg/lyQcjedL7hvQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/borkdude" + } + ], + "dependencies": { + "chokidar": "^4.0.3" + }, + "bin": { + "squint": "node_cli.js" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..22d08ff --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "squint-cljs": "^0.13.195" + } +} diff --git a/squint.edn b/squint.edn new file mode 100644 index 0000000..d45975a --- /dev/null +++ b/squint.edn @@ -0,0 +1,5 @@ +{:paths ["src" "test"] + :deps {io.github.borkdude/deflet {:git/sha "a3c86cdbd23531b2d59e6d7db1c49418cb325c7f" + :git/tag "v0.1.0"}} + :output-dir ".squint-out" + :extension "mjs"} diff --git a/src/babashka/cli.cljc b/src/babashka/cli.cljc index 0bb84ef..c79e5c0 100644 --- a/src/babashka/cli.cljc +++ b/src/babashka/cli.cljc @@ -11,6 +11,9 @@ #?(:clj (set! *warn-on-reflection* true)) +;; squint can't tell an injected keyword from a string arg, so wrap injected opts +#?(:squint (deftype Injected [opt])) + (defn merge-opts "Merges babashka CLI options." [m & ms] @@ -242,7 +245,9 @@ (let [arg-count (count args) cnt (min arg-count (bounded-count arg-count args->opt-keys))] - [(concat (interleave args->opt-keys args) + [(concat (interleave #?(:squint (map ->Injected args->opt-keys) + :default args->opt-keys) + args) (drop cnt args)) (drop cnt args->opt-keys)]) [args args->opt-keys]) @@ -295,7 +300,8 @@ ::resolved true)))) (defn- kw->str [kw] - (subs (str kw) 1)) + #?(:squint (str kw) + :default (subs (str kw) 1))) (defn- option-label "User-facing name for option `opt` in an error message: the literal flag the @@ -346,7 +352,7 @@ (let [coll-coerce? (coll? coerce-k) empty-coll (when coll-coerce? (or (empty coerce-k) [])) cf (coerce-coerce-fn coerce-k) - iv (and implicit-values (k implicit-values))] + iv (and implicit-values (get implicit-values k))] (try (cond (and coll-coerce? (coll? v)) @@ -368,7 +374,9 @@ ;; instead of "cannot transform (implicit) true to ..." :msg (case iv true (str "Missing value for option " (option-label km k)) - false (str "Cannot negate option " (str/replace-first (option-label km k) "no-" "")) + ;; NOTE: squint lacks clojure.string/replace-first until > 0.14.196; use JS interop for now + false (str "Cannot negate option " #?(:squint (.replace (option-label km k) "no-" "") + :default (str/replace-first (option-label km k) "no-" ""))) (str "Invalid value for option " (option-label km k) ": " (coerce-failure-reason (:input data) iv (:coerce-fn data)))) :option k @@ -463,7 +471,7 @@ (:pred vf)) vf)] (when-let [[_ v] (find m k)] - (when-not (f v) + (when-not (if (set? f) (contains? f v) (f v)) (let [ex-msg-fn (or (:ex-msg vf) (fn [{:keys [flag value]}] (str "Invalid value for option " flag ": " value))) @@ -567,7 +575,9 @@ ;; exit loop: no command line args left [acc open-opt valued-opt implicit-values opt-parse-order] (let [raw-arg (first args) - opt-injected? (keyword? raw-arg)] + opt-injected? #?(:squint (instance? Injected raw-arg) + :default (keyword? raw-arg)) + #?@(:squint [raw-arg (if opt-injected? (.-opt raw-arg) raw-arg)])] (if opt-injected? ;; continue loop: this opt and its value was injected by args->opts ;; opt-val-collector does not apply for injected opts, so is `nil` @@ -616,7 +626,7 @@ next-arg (first next-args) next-arg-info (analyze-arg next-arg mode open-opt boolean-opt? valued-opt known-keys alias-keys) negated-opt? (when-not (contains? known-keys parsed-opt) - (str/starts-with? (str parsed-opt) ":no-"))] + (str/starts-with? (str parsed-opt) #?(:squint "no-" :default ":no-")))] (if (or (:hyphen-opt next-arg-info) ;; --open-opt --next (empty? next-args) ;; --open-opt negated-opt?) ;; --no-foo @@ -630,7 +640,7 @@ mode (concat expanded next-args) a->o implicit-values opt-parse-order)) (let [parsed-opt (if negated-opt? - (keyword (str/replace (str parsed-opt) ":no-" "")) + (keyword (str/replace (str parsed-opt) #?(:squint "no-" :default ":no-") "")) parsed-opt)] ;; continue loop: adding true for --foo or false for --no-foo to args (recur (stamp (maybe-close-open-opt acc open-opt valued-opt opt-val-collector) parsed-opt literal-opt) @@ -945,7 +955,7 @@ (if desc desc ""))])) (if (map? spec) (let [order (or order (keys spec))] - (map (fn [k] [k (spec k)]) order)) + (map (fn [k] [k (get spec k)]) order)) spec)))) (defn- opts->help-rows @@ -957,7 +967,7 @@ `format-opts`." [{:keys [spec order required]}] (let [entries (if (map? spec) - (map (fn [k] [k (spec k)]) (or order (keys spec))) + (map (fn [k] [k (get spec k)]) (or order (keys spec))) spec) ;; `:no-doc` options still parse but are hidden from help, like `:no-doc` ;; commands are hidden from the command list @@ -1022,7 +1032,7 @@ (= k prev) (conj (pop acc) (str "<" (name prev) ">...")) :else (recur (next s) (conj acc (str "<" (name k) ">")) k (inc n))))))) -(defn #?(:cljd ^:no-doc cmd-children :default ^:private cmd-children) +(defn #?(:cljd ^:no-doc cmd-children :squint ^:no-doc cmd-children :default ^:private cmd-children) "Visible `[name child]` pairs of `node`'s commands, for display (help, completions, error suggestions): `:no-doc` children are dropped. An explicit node `:cmd-order` (vector of names) selects which children are shown and in @@ -1185,8 +1195,8 @@ (defn- deep-merge [a b] (reduce (fn [acc k] (update acc k (fn [v] (if (map? v) - (deep-merge v (b k)) - (b k))))) + (deep-merge v (get b k)) + (get b k))))) a (keys b))) (defn- inherited-entries @@ -1379,7 +1389,7 @@ (str/split token #"=" 2) [token])) -(defn #?(:cljd ^:no-doc complete-tree* :default ^:private complete-tree*) +(defn #?(:cljd ^:no-doc complete-tree* :squint ^:no-doc complete-tree* :default ^:private complete-tree*) "Returns completion candidate maps (`{:value :description}`) for dispatch tree `cmd-tree` and `args` (a vector of tokens, last = the token being completed). `global-opts` are the dispatch-level opts, accepted at every level like diff --git a/test/babashka/cli/completion_test.cljc b/test/babashka/cli/completion_test.cljc index b717c9c..d02e30e 100644 --- a/test/babashka/cli/completion_test.cljc +++ b/test/babashka/cli/completion_test.cljc @@ -5,6 +5,7 @@ #?(:cljd [cljd.test :refer [deftest is testing]] :default [clojure.test :refer [deftest is testing]]) #?@(:cljd [["dart:io" :as io]] + :cljs [["fs" :as fs]] :clj [[babashka.fs :as fs] [clojure.java.io :as io]]))) @@ -16,8 +17,7 @@ (defn- read-snippet [shell] #?(:cljd (.readAsStringSync (io/File. (str "test/resources/completion/completion." shell))) :clj (slurp (io/resource (str "resources/completion/completion." shell))) - :cljs (.readFileSync (js/require "fs") - (str "test/resources/completion/completion." shell) "utf8"))) + :cljs (fs/readFileSync (str "test/resources/completion/completion." shell) "utf8"))) ;; clojure.string/split-lines drops trailing empty lines. cljd keeps them (defn- lines* [s] @@ -27,12 +27,12 @@ ;; test helpers over the private completion fns: return the candidate value strings (defn complete [table args] - (mapv :value (#?(:cljd cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree table) args))) + (mapv :value (#?(:cljd cli/complete-tree* :squint cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree table) args))) (defn complete-options [opts args] - (mapv :value (#?(:cljd cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree [(assoc opts :cmds [])]) args))) + (mapv :value (#?(:cljd cli/complete-tree* :squint cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree [(assoc opts :cmds [])]) args))) ;; an unconfigured value position defaults to the shell's file completion (defn- files? [table args] - (= [{:file-completion true}] (#?(:cljd cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree table) args))) + (= [{:file-completion true}] (#?(:cljd cli/complete-tree* :squint cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree table) args))) (defn- files-options? [opts args] (files? [(assoc opts :cmds [])] args)) @@ -240,7 +240,7 @@ (testing "no :complete/:complete-fn/:validate -> the shell's file completion" (is (files-options? {:spec {:env {:coerce :string}}} ["--env" ""]))) (testing ":complete false opts out of the file default" - (is (empty? (#?(:cljd cli/complete-tree* :default #'cli/complete-tree*) + (is (empty? (#?(:cljd cli/complete-tree* :squint cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree [{:cmds [] :spec {:msg {:complete false}}}]) ["--msg" ""]))))) @@ -352,7 +352,7 @@ marker))))) (testing ":complete false opts a positional out of the file default" (let [v [{:cmds ["run"] :args->opts [:name] :spec {:name {:complete false}}}]] - (is (not-any? :file-completion (#?(:cljd cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree v) ["run" ""]))))) + (is (not-any? :file-completion (#?(:cljd cli/complete-tree* :squint cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree v) ["run" ""]))))) (testing "variadic :args->opts does not hang and emits the marker" (let [v [{:cmds ["x"] :args->opts (cons :a (repeat :b))}]] (is (contains? (set (lines* (complete-via-cmd v {:prog "p"} "p x one two "))) @@ -400,7 +400,7 @@ (is (= #{"--force"} (set (complete t ["deploy" "--env=dev" ""]))))) (testing "no file fallback inside --opt= (shells match files against the whole token)" (let [u [{:cmds ["deploy"] :spec {:out {:coerce :string}}}]] - (is (empty? (#?(:cljd cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree u) ["deploy" "--out="]))))) + (is (empty? (#?(:cljd cli/complete-tree* :squint cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree u) ["deploy" "--out="]))))) (testing "bash wordbreak splitting (--opt = val arrives as three tokens)" (let [out (fn [& toks] (with-out-str @@ -446,7 +446,7 @@ (is (= #{"--opt"} (set (complete t ["sub" "--verbose" "--"])))))) (testing "dispatch-level :inherit true" (is (= ["--env"] - (mapv :value (#?(:cljd cli/complete-tree* :default #'cli/complete-tree*) + (mapv :value (#?(:cljd cli/complete-tree* :squint cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree [{:cmds [] :spec {:env {}}} {:cmds ["sub"] :fn identity}]) ["sub" "--"] @@ -466,7 +466,7 @@ (deftest dispatch-level-spec-test ;; dispatch-level :spec options parse at every level, so they complete there too (is (= #{"--local" "--glob"} - (set (mapv :value (#?(:cljd cli/complete-tree* :default #'cli/complete-tree*) + (set (mapv :value (#?(:cljd cli/complete-tree* :squint cli/complete-tree* :default #'cli/complete-tree*) (cli/table->tree [{:cmds ["sub"] :fn identity :spec {:local {}}}]) ["sub" "--"] {:spec {:glob {}}})))))) diff --git a/test/babashka/cli_test.cljc b/test/babashka/cli_test.cljc index 479fd3a..2f0ae0b 100644 --- a/test/babashka/cli_test.cljc +++ b/test/babashka/cli_test.cljc @@ -1,7 +1,7 @@ (ns babashka.cli-test (:require [babashka.cli :as cli] - #?@(:cljd [] :default [[babashka.cli.test-report]]) + #?@(:cljd [] :squint [] :default [[babashka.cli.test-report]]) [borkdude.deflet :as d] [clojure.string :as str] #?(:cljd [cljd.test :refer [deftest is testing]] @@ -524,13 +524,13 @@ siblings, in table order" (let [tree (cli/table->tree [{:cmds ["a"] :fn identity :doc "A"} {:cmds [] :cmd {"b" {:fn identity :doc "B"}}}])] - (is (= ["a" "b"] (mapv first (#?(:cljd cli/cmd-children :default #'cli/cmd-children) tree)))) + (is (= ["a" "b"] (mapv first (#?(:cljd cli/cmd-children :squint cli/cmd-children :default #'cli/cmd-children) tree)))) (is (submap? {:dispatch ["a"]} (cli/dispatch tree ["a"]))) (is (submap? {:dispatch ["b"]} (cli/dispatch tree ["b"])))) (testing "catch-all first: its children list first" (let [tree (cli/table->tree [{:cmds [] :cmd {"b" {:fn identity :doc "B"}}} {:cmds ["a"] :fn identity :doc "A"}])] - (is (= ["b" "a"] (mapv first (#?(:cljd cli/cmd-children :default #'cli/cmd-children) tree)))))))) + (is (= ["b" "a"] (mapv first (#?(:cljd cli/cmd-children :squint cli/cmd-children :default #'cli/cmd-children) tree)))))))) (defn- run-dispatch "Run [[cli/dispatch]] capturing stdout+stderr and *exit-fn* calls. Returns @@ -647,7 +647,7 @@ (cli/format-command-help {:table {:cmd-order [] :cmd {"a" {:fn identity}}} :prog "p"})))) (testing ":help true does not change command order (sorted tree, >8 children)" - (let [tree {:cmd (into (sorted-map) + (let [tree {:cmd (into #?(:squint {} :default (sorted-map)) (map (fn [i] [(str "c" i) {:fn identity}])) (range 10))} expected (mapv #(str "c" %) (range 10))] @@ -1080,7 +1080,8 @@ :cljs :default) e (= {:type :org.babashka/cli :cause :validate - :msg "Expected positive number for option :foo but got: 0" + :msg #?(:squint "Expected positive number for option foo but got: 0" + :default "Expected positive number for option :foo but got: 0") :option :foo :flag "--foo" :spec nil diff --git a/test/babashka/run_tests.cljs b/test/babashka/run_tests.cljs new file mode 100644 index 0000000..125a9cc --- /dev/null +++ b/test/babashka/run_tests.cljs @@ -0,0 +1,8 @@ +(ns babashka.run-tests + (:require [clojure.test :as t] + [babashka.cli-test] + [babashka.cli.completion-test])) +(defn -main [& _] + (let [{:keys [fail error]} (t/run-tests 'babashka.cli-test 'babashka.cli.completion-test)] + (when (pos? (+ (or fail 0) (or error 0))) (js/process.exit 1)))) +(-main)