Skip to content

Commit d030850

Browse files
committed
auto-completions for bb.cli/dispatch
1 parent d319f1f commit d030850

File tree

5 files changed

+239
-16
lines changed

5 files changed

+239
-16
lines changed

src/babashka/cli.cljc

Lines changed: 132 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
#?(:clj [clojure.edn :as edn]
55
:cljs [cljs.reader :as edn])
66
[babashka.cli.internal :as internal]
7-
[clojure.string :as str])
7+
[clojure.string :as str]
8+
[clojure.set :as set])
89
#?(:clj (:import (clojure.lang ExceptionInfo))))
910

1011
#?(:clj (set! *warn-on-reflection* true))
@@ -590,8 +591,116 @@
590591
{} table))
591592

592593
(comment
593-
(table->tree [{:cmds [] :fn identity}])
594-
)
594+
(table->tree [{:cmds [] :fn identity}]))
595+
596+
;; completion
597+
(defn format-long-opt [k]
598+
(str "--" (kw->str k)))
599+
(defn format-short-opt [k]
600+
(str "-" (kw->str k)))
601+
602+
(defn possibilities [cmd-tree]
603+
(concat (keys (:cmd cmd-tree))
604+
(map format-long-opt (keys (:spec cmd-tree)))
605+
(map format-short-opt (keep :alias (vals (:spec cmd-tree))))))
606+
607+
(defn true-prefix? [prefix s]
608+
(and (< (count prefix) (count s))
609+
(str/starts-with? s prefix)))
610+
611+
(defn second-to-last [xs]
612+
(when (>= (count xs) 2) (nth xs (- (count xs) 2))))
613+
614+
(def possible-values (constantly []))
615+
616+
(defn strip-prefix [prefix s]
617+
(if (str/starts-with? s prefix)
618+
(subs s (count prefix))
619+
s))
620+
621+
(defn bool-opt? [o spec]
622+
(let [long-opt? (str/starts-with? o "--")
623+
opt-kw (if long-opt?
624+
(keyword (strip-prefix "--" o))
625+
(some (fn [[k v]] (when (= (keyword (strip-prefix "-" o)) (:alias v)) k)) spec))]
626+
(= :boolean (get-in spec [opt-kw :coerce]))))
627+
628+
(defn is-gnu-option? [s]
629+
(and s (str/starts-with? s "-")))
630+
631+
(defn complete-tree
632+
"given a CLI spec in tree form and input as a list of tokens,
633+
returns possible tokens to complete the input"
634+
[cmd-tree input]
635+
(let [[head & tail] input
636+
head (or head "")
637+
subtree (get-in cmd-tree [:cmd head])]
638+
(if (and subtree (first tail))
639+
;; matching command -> descend tree
640+
(complete-tree subtree tail)
641+
(if (is-gnu-option? head)
642+
(let [{:keys [args opts err]} (try (parse-args input cmd-tree)
643+
(catch clojure.lang.ExceptionInfo _ {:err :error}))]
644+
(if (and args (not (str/blank? (first args))))
645+
;; parsed/consumed options and still have args left -> descend tree
646+
(complete-tree cmd-tree args)
647+
;; no more args -> last input is (part of) an opt or opt value or empty string
648+
(let [to-complete (last input)
649+
previous-token (second-to-last input)]
650+
(if (and (is-gnu-option? previous-token) (not (bool-opt? previous-token (:spec cmd-tree))))
651+
;; complete value
652+
(possible-values previous-token)
653+
(let [possible-commands (keys (:cmd cmd-tree))
654+
;; don't suggest options which we already have parsed
655+
possible-options (set/difference (set (keys (:spec cmd-tree))) (set (keys opts)))
656+
;; generate string representation of possible options
657+
possible-completions (concat possible-commands
658+
(map format-long-opt possible-options)
659+
(keep (fn [option-name]
660+
(when-let [alias (get-in cmd-tree [:spec option-name :alias])]
661+
(format-short-opt alias)))
662+
possible-options))]
663+
(filter (partial true-prefix? to-complete) possible-completions))))))
664+
(filter (partial true-prefix? head) (possibilities cmd-tree))))))
665+
666+
(defn complete [cmd-table input]
667+
(complete-tree (table->tree cmd-table) input))
668+
669+
670+
(defn generate-completion-shell-snippet [type program-name]
671+
(case type
672+
:bash (format "_babashka_cli_dynamic_completion()
673+
{
674+
source <( \"$1\" --babashka.cli/complete \"bash\" \"${COMP_WORDS[*]// / }\" )
675+
}
676+
complete -o nosort -F _babashka_cli_dynamic_completion %s
677+
" program-name)
678+
:zsh (format "#compdef %s
679+
source <( \"${words[1]}\" --babashka.cli/complete \"zsh\" \"${words[*]// / }\" )
680+
" program-name)
681+
:fish (format "function _babashka_cli_dynamic_completion
682+
set --local COMP_LINE (commandline --cut-at-cursor)
683+
%s --babashka.cli/complete fish $COMP_LINE
684+
end
685+
complete --command %s --no-files --arguments \"(_babashka_cli_dynamic_completion)\"
686+
" program-name program-name)))
687+
688+
(defn print-completion-shell-snippet [type program-name]
689+
(print (generate-completion-shell-snippet type program-name)))
690+
691+
(defn format-completion [shell {:keys [completion description]}]
692+
(case shell
693+
:bash (format "COMPREPLY+=( \"%s\" )" completion)
694+
:zsh (str "compadd" (when description (str " -x \"" description "\"")) " -- " completion)
695+
:fish completion))
696+
697+
(defn print-completions [shell tree cmdline]
698+
(let [[_program-name & to-complete] (str/split (str/triml cmdline) #" +" -1)
699+
completions (complete-tree tree to-complete)]
700+
(doseq [completion completions]
701+
(println (format-completion shell {:completion completion})))))
702+
703+
;; dispatch
595704

596705
(defn- deep-merge [a b]
597706
(reduce (fn [acc k] (update acc k (fn [v]
@@ -656,19 +765,26 @@
656765
([tree args]
657766
(dispatch-tree tree args nil))
658767
([tree args opts]
659-
(let [{:as res :keys [cmd-info error available-commands]}
660-
(dispatch-tree' tree args opts)
661-
error-fn (or (:error-fn opts)
662-
(fn [{:keys [msg] :as data}]
663-
(throw (ex-info msg data))))]
664-
(case error
665-
(:no-match :input-exhausted)
666-
(error-fn (merge
667-
{:type :org.babashka/cli
668-
:cause error
669-
:all-commands available-commands}
670-
(select-keys res [:wrong-input :opts :dispatch])))
671-
nil ((:fn cmd-info) (dissoc res :cmd-info))))))
768+
(let [command-name (get-in opts [:completion :command])
769+
[opt shell cmdline] args]
770+
(case opt
771+
"--babashka.cli/completion-snippet"
772+
(print-completion-shell-snippet (keyword shell) command-name)
773+
"--babashka.cli/complete"
774+
(print-completions (keyword shell) tree cmdline)
775+
(let [{:as res :keys [cmd-info error available-commands]}
776+
(dispatch-tree' tree args opts)
777+
error-fn (or (:error-fn opts)
778+
(fn [{:keys [msg] :as data}]
779+
(throw (ex-info msg data))))]
780+
(case error
781+
(:no-match :input-exhausted)
782+
(error-fn (merge
783+
{:type :org.babashka/cli
784+
:cause error
785+
:all-commands available-commands}
786+
(select-keys res [:wrong-input :opts :dispatch])))
787+
nil ((:fn cmd-info) (dissoc res :cmd-info))))))))
672788

673789
(defn dispatch
674790
"Subcommand dispatcher.

test/babashka/cli/completion_test.clj

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
(ns babashka.cli.completion-test
2+
(:require [babashka.cli :as cli :refer [complete]]
3+
[clojure.java.io :as io]
4+
[clojure.test :refer :all]))
5+
6+
(def cmd-table
7+
[{:cmds ["foo"] :spec {:foo-opt {:coerece :string
8+
:alias :f}
9+
:foo-opt2 {:coerece :string}
10+
:foo-flag {:coerce :boolean
11+
:alias :l}}}
12+
{:cmds ["foo" "bar"] :spec {:bar-opt {:coerce :keyword}
13+
:bar-flag {:coerce :boolean}}}
14+
{:cmds ["bar"]}
15+
{:cmds ["bar-baz"]}])
16+
17+
(deftest completion-test
18+
(testing "complete commands"
19+
(is (= #{"foo" "bar" "bar-baz"} (set (complete cmd-table [""]))))
20+
(is (= #{"bar" "bar-baz"} (set (complete cmd-table ["ba"]))))
21+
(is (= #{"bar-baz"} (set (complete cmd-table ["bar"]))))
22+
(is (= #{"foo"} (set (complete cmd-table ["f"])))))
23+
24+
(testing "no completions for full command"
25+
(is (= #{} (set (complete cmd-table ["foo"])))))
26+
27+
(testing "complete subcommands and options"
28+
(is (= #{"bar" "-f" "--foo-opt" "--foo-opt2" "-l" "--foo-flag"} (set (complete cmd-table ["foo" ""])))))
29+
30+
(testing "complete suboption"
31+
(is (= #{"-f" "--foo-opt" "--foo-opt2" "-l" "--foo-flag"} (set (complete cmd-table ["foo" "-"])))))
32+
33+
(testing "complete short-opt"
34+
(is (= #{} (set (complete cmd-table ["foo" "-f"]))))
35+
(is (= #{} (set (complete cmd-table ["foo" "-f" ""]))))
36+
(is (= #{} (set (complete cmd-table ["foo" "-f" "foo-val"]))))
37+
(is (= #{} (set (complete cmd-table ["foo" "-f" "bar"]))))
38+
(is (= #{} (set (complete cmd-table ["foo" "-f" "foo-flag"]))))
39+
(is (= #{} (set (complete cmd-table ["foo" "-f" "foo-opt2"]))))
40+
(is (= #{} (set (complete cmd-table ["foo" "-f" "123"]))))
41+
(is (= #{} (set (complete cmd-table ["foo" "-f" ":foo"]))))
42+
(is (= #{} (set (complete cmd-table ["foo" "-f" "true"]))))
43+
(is (= #{"bar" "--foo-opt2" "-l" "--foo-flag"} (set (complete cmd-table ["foo" "-f" "foo-val" ""])))))
44+
45+
(testing "complete option with same prefix"
46+
(is (= #{"--foo-opt" "--foo-opt2" "--foo-flag"} (set (complete cmd-table ["foo" "--foo"]))))
47+
(is (= #{"--foo-opt2"} (set (complete cmd-table ["foo" "--foo-opt"])))))
48+
49+
(testing "complete long-opt"
50+
(is (= #{} (set (complete cmd-table ["foo" "--foo-opt2"]))))
51+
(is (= #{} (set (complete cmd-table ["foo" "--foo-opt" ""]))))
52+
(is (= #{} (set (complete cmd-table ["foo" "--foo-opt" "foo-val"]))))
53+
(is (= #{} (set (complete cmd-table ["foo" "--foo-opt" "bar"]))))
54+
(is (= #{} (set (complete cmd-table ["foo" "--foo-opt" "foo-flag"]))))
55+
(is (= #{} (set (complete cmd-table ["foo" "--foo-opt" "foo-opt2"]))))
56+
(is (= #{} (set (complete cmd-table ["foo" "--foo-opt" "123"]))))
57+
(is (= #{} (set (complete cmd-table ["foo" "--foo-opt" ":foo"]))))
58+
(is (= #{} (set (complete cmd-table ["foo" "--foo-opt" "true"]))))
59+
(is (= #{"bar" "--foo-opt2" "-l" "--foo-flag"} (set (complete cmd-table ["foo" "--foo-opt" "foo-val" ""])))))
60+
61+
(is (= #{"--foo-flag"} (set (complete cmd-table ["foo" "--foo-f"]))))
62+
63+
(testing "complete short flag"
64+
(is (= #{} (set (complete cmd-table ["foo" "-l"]))))
65+
(is (= #{"bar" "-f" "--foo-opt" "--foo-opt2"} (set (complete cmd-table ["foo" "-l" ""])))))
66+
67+
(testing "complete long flag"
68+
(is (= #{} (set (complete cmd-table ["foo" "--foo-flag"]))))
69+
(is (= #{"bar" "-f" "--foo-opt" "--foo-opt2"} (set (complete cmd-table ["foo" "--foo-flag" ""])))))
70+
71+
(is (= #{"-f" "--foo-opt" "--foo-opt2"} (set (complete cmd-table ["foo" "--foo-flag" "-"]))))
72+
(is (= #{"bar"} (set (complete cmd-table ["foo" "--foo-flag" "b"]))))
73+
74+
(testing "complete subcommand"
75+
(is (= #{"--bar-opt" "--bar-flag"} (set (complete cmd-table ["foo" "--foo-flag" "bar" ""]))))
76+
(is (= #{"--bar-opt" "--bar-flag"} (set (complete cmd-table ["foo" "--foo-flag" "bar" "-"]))))
77+
(is (= #{"--bar-opt" "--bar-flag"} (set (complete cmd-table ["foo" "--foo-flag" "bar" "--"]))))
78+
(is (= #{"--bar-opt" "--bar-flag"} (set (complete cmd-table ["foo" "--foo-flag" "bar" "--bar-"]))))
79+
(is (= #{"--bar-opt"} (set (complete cmd-table ["foo" "--foo-flag" "bar" "--bar-o"]))))
80+
(is (= #{} (set (complete cmd-table ["foo" "--foo-flag" "bar" "--bar-opt" "a"]))))
81+
(is (= #{"--bar-flag"} (set (complete cmd-table ["foo" "--foo-flag" "bar" "--bar-opt" "bar-val" ""]))))))
82+
83+
84+
(deftest parse-opts-completion-test
85+
(cli/parse-opts ["--babashka.cli/completion-snippet" "zsh"] {:complete true})
86+
(cli/parse-opts ["--babashka.cli/complete" "zsh" "foo"] {:complete true}))
87+
88+
(deftest dispatch-completion-test
89+
(is (= (slurp (io/resource "resources/completion/completion.zsh")) (with-out-str (cli/dispatch cmd-table ["--babashka.cli/completion-snippet" "zsh"] {:completion {:command "myprogram"}}))))
90+
(is (= (slurp (io/resource "resources/completion/completion.bash")) (with-out-str (cli/dispatch cmd-table ["--babashka.cli/completion-snippet" "bash"] {:completion {:command "myprogram"}}))))
91+
(is (= (slurp (io/resource "resources/completion/completion.fish")) (with-out-str (cli/dispatch cmd-table ["--babashka.cli/completion-snippet" "fish"] {:completion {:command "myprogram"}}))))
92+
93+
(is (= "compadd -- foo\n" (with-out-str (cli/dispatch cmd-table ["--babashka.cli/complete" "zsh" "myprogram f"] {:completion {:command "myprogram"}}))))
94+
(is (= "COMPREPLY+=( \"foo\" )\n" (with-out-str (cli/dispatch cmd-table ["--babashka.cli/complete" "bash" "myprogram f "] {:completion {:command "myprogram"}}))))
95+
(is (= "foo\n" (with-out-str (cli/dispatch cmd-table ["--babashka.cli/complete" "fish" "myprogram f "] {:completion {:command "myprogram"}})))))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
_babashka_cli_dynamic_completion()
2+
{
3+
source <( "$1" --babashka.cli/complete "bash" "${COMP_WORDS[*]// / }" )
4+
}
5+
complete -o nosort -F _babashka_cli_dynamic_completion myprogram
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
function _babashka_cli_dynamic_completion
2+
set --local COMP_LINE (commandline --cut-at-cursor)
3+
myprogram --babashka.cli/complete fish $COMP_LINE
4+
end
5+
complete --command myprogram --no-files --arguments "(_babashka_cli_dynamic_completion)"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#compdef myprogram
2+
source <( "${words[1]}" --babashka.cli/complete "zsh" "${words[*]// / }" )

0 commit comments

Comments
 (0)