mirror of
https://github.com/logseq/logseq.git
synced 2026-06-01 19:01:22 +00:00
Add tests for completions command and completion generator
- Introduced `completions_test.cljs` to validate the structure and behavior of the completions command registration and argument parsing. - Added `completion_generator_test.cljs` to extensively test the completion generator, including spec metadata validation, Zsh and Bash output generation, and end-to-end checks for command entries and structural markers.
This commit is contained in:
12
src/main/logseq/cli/command/completions.cljs
Normal file
12
src/main/logseq/cli/command/completions.cljs
Normal file
@@ -0,0 +1,12 @@
|
||||
(ns logseq.cli.command.completions
|
||||
"Shell completions command."
|
||||
(:require [logseq.cli.command.core :as core]))
|
||||
|
||||
(def ^:private completions-spec
|
||||
{:shell {:desc "Shell (zsh, bash)"
|
||||
:values ["zsh" "bash"]}})
|
||||
|
||||
(def entries
|
||||
[(core/command-entry ["completions"] :completions
|
||||
"Generate shell completion script"
|
||||
completions-spec)])
|
||||
@@ -12,14 +12,18 @@
|
||||
:version {:desc "Show version"
|
||||
:coerce :boolean}
|
||||
:config {:desc "Path to cli.edn (default ~/logseq/cli.edn)"
|
||||
:alias :c}
|
||||
:alias :c
|
||||
:complete :file}
|
||||
:graph {:desc "Graph name"
|
||||
:alias :g}
|
||||
:data-dir {:desc (str "Path to db-worker data dir (default " common-config/default-graphs-dir ")")}
|
||||
:alias :g
|
||||
:complete :graphs}
|
||||
:data-dir {:desc (str "Path to db-worker data dir (default " common-config/default-graphs-dir ")")
|
||||
:complete :dir}
|
||||
:timeout-ms {:desc "Request timeout in ms (default 10000)"
|
||||
:coerce :long}
|
||||
:output {:desc "Output format (human, json, edn). Default: human"
|
||||
:alias :o}
|
||||
:output {:desc "Output format. Default: human"
|
||||
:alias :o
|
||||
:values ["human" "json" "edn"]}
|
||||
:verbose {:desc "Enable verbose debug logging to stderr"
|
||||
:alias :v
|
||||
:coerce :boolean}})
|
||||
@@ -101,7 +105,9 @@
|
||||
{:title "Graph Management"
|
||||
:commands #{"graph" "server" "doctor" "sync"}}
|
||||
{:title "Authentication"
|
||||
:commands #{"login" "logout"}}]
|
||||
:commands #{"login" "logout"}}
|
||||
{:title "Utilities"
|
||||
:commands #{"completions"}}]
|
||||
render-group (fn [{:keys [title commands]}]
|
||||
(let [entries (filter #(contains? commands (first (:cmds %))) table)]
|
||||
(string/join "\n" [title (format-commands entries)])))]
|
||||
|
||||
@@ -10,12 +10,16 @@
|
||||
[promesa.core :as p]))
|
||||
|
||||
(def ^:private graph-export-spec
|
||||
{:type {:desc "Export type (edn, sqlite)"}
|
||||
:file {:desc "Export file path"}})
|
||||
{:type {:desc "Export type"
|
||||
:values ["edn" "sqlite"]}
|
||||
:file {:desc "Export file path"
|
||||
:complete :file}})
|
||||
|
||||
(def ^:private graph-import-spec
|
||||
{:type {:desc "Import type (edn, sqlite)"}
|
||||
:input {:desc "Input path"}})
|
||||
{:type {:desc "Import type"
|
||||
:values ["edn" "sqlite"]}
|
||||
:input {:desc "Input path"
|
||||
:complete :file}})
|
||||
|
||||
(def ^:private graph-validate-spec
|
||||
{:fix {:desc "Attempt to fix validation errors"
|
||||
|
||||
@@ -14,11 +14,14 @@
|
||||
:offset {:desc "Offset results"
|
||||
:coerce :long}
|
||||
:sort {:desc "Sort field"}
|
||||
:order {:desc "Sort order (asc, desc). Default: asc"}})
|
||||
:order {:desc "Sort order. Default: asc"
|
||||
:values ["asc" "desc"]}})
|
||||
|
||||
(def ^:private list-page-spec
|
||||
(merge list-common-spec
|
||||
{:include-journal {:desc "Include journal pages"
|
||||
{:sort {:desc "Sort field"
|
||||
:values ["title" "created-at" "updated-at"]}
|
||||
:include-journal {:desc "Include journal pages"
|
||||
:coerce :boolean}
|
||||
:journal-only {:desc "Only journal pages"
|
||||
:coerce :boolean}
|
||||
@@ -30,7 +33,9 @@
|
||||
|
||||
(def ^:private list-tag-spec
|
||||
(merge list-common-spec
|
||||
{:include-built-in {:desc "Include built-in tags"
|
||||
{:sort {:desc "Sort field"
|
||||
:values ["name" "title"]}
|
||||
:include-built-in {:desc "Include built-in tags"
|
||||
:coerce :boolean}
|
||||
:with-properties {:desc "Include tag properties"
|
||||
:coerce :boolean}
|
||||
@@ -40,7 +45,9 @@
|
||||
|
||||
(def ^:private list-property-spec
|
||||
(merge list-common-spec
|
||||
{:include-built-in {:desc "Include built-in properties"
|
||||
{:sort {:desc "Sort field"
|
||||
:values ["name" "title"]}
|
||||
:include-built-in {:desc "Include built-in properties"
|
||||
:coerce :boolean}
|
||||
:with-classes {:desc "Include property classes"
|
||||
:coerce :boolean}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
|
||||
(def ^:private query-spec
|
||||
{:query {:desc "Datascript query EDN"}
|
||||
:name {:desc "Query name from cli.edn custom-queries or built-ins"}
|
||||
:name {:desc "Query name from cli.edn custom-queries or built-ins"
|
||||
:complete :queries}
|
||||
:inputs {:desc "EDN vector of query inputs"}})
|
||||
|
||||
(def ^:private query-list-spec
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
:uuid {:desc "Block UUID"}})
|
||||
|
||||
(def ^:private remove-page-spec
|
||||
{:name {:desc "Page name"}})
|
||||
{:name {:desc "Page name"
|
||||
:complete :pages}})
|
||||
|
||||
(def ^:private remove-entity-spec
|
||||
{:id {:desc "Entity db/id"
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
(def ^:private show-spec
|
||||
{:id {:desc "Block db/id or EDN vector of ids"}
|
||||
:uuid {:desc "Block UUID"}
|
||||
:page {:desc "Page name"}
|
||||
:page {:desc "Page name"
|
||||
:complete :pages}
|
||||
:linked-references {:desc "Include linked references (default true)"
|
||||
:coerce :boolean}
|
||||
:level {:desc "Limit tree depth (default 10)"
|
||||
|
||||
@@ -16,12 +16,17 @@
|
||||
:target-id {:desc "Target block db/id"
|
||||
:coerce :long}
|
||||
:target-uuid {:desc "Target block UUID"}
|
||||
:target-page {:desc "Target page name"}
|
||||
:pos {:desc "Position (first-child, last-child, sibling). Default: create=last-child, update=first-child"}
|
||||
:target-page {:desc "Target page name"
|
||||
:complete :pages}
|
||||
:pos {:desc "Position. Default: create=last-child, update=first-child"
|
||||
:values ["first-child" "last-child" "sibling"]}
|
||||
:content {:desc "Block content for create mode"}
|
||||
:blocks {:desc "EDN vector of blocks for create mode"}
|
||||
:blocks-file {:desc "EDN file of blocks for create mode"}
|
||||
:status {:desc "Task status (todo, doing, done, etc.)"}
|
||||
:blocks-file {:desc "EDN file of blocks for create mode"
|
||||
:complete :file}
|
||||
:status {:desc "Task status"
|
||||
:values ["todo" "doing" "done" "now" "later" "wait" "waiting"
|
||||
"backlog" "canceled" "cancelled" "in-review" "in-progress"]}
|
||||
:update-tags {:desc "Tags to add/update (EDN vector)"}
|
||||
:update-properties {:desc "Properties to add/update (EDN map)"}
|
||||
:remove-tags {:desc "Tags to remove (EDN vector)"}
|
||||
@@ -30,7 +35,8 @@
|
||||
(def ^:private upsert-page-spec
|
||||
{:id {:desc "Target page db/id (forces update mode)"
|
||||
:coerce :long}
|
||||
:page {:desc "Page name"}
|
||||
:page {:desc "Page name"
|
||||
:complete :pages}
|
||||
:update-tags {:desc "Tags to add/update (EDN vector)"}
|
||||
:update-properties {:desc "Properties to add/update (EDN map)"}
|
||||
:remove-tags {:desc "Tags to remove (EDN vector)"}
|
||||
@@ -45,8 +51,10 @@
|
||||
{:id {:desc "Target property db/id (forces update mode)"
|
||||
:coerce :long}
|
||||
:name {:desc "Property name"}
|
||||
:type {:desc "Property type (default, number, date, datetime, checkbox, url, node, json, string)"}
|
||||
:cardinality {:desc "Property cardinality (one, many)"}
|
||||
:type {:desc "Property type"
|
||||
:values ["default" "number" "date" "datetime" "checkbox" "url" "node" "json" "string"]}
|
||||
:cardinality {:desc "Property cardinality"
|
||||
:values ["one" "many"]}
|
||||
:hide {:desc "Hide property"
|
||||
:coerce :boolean}
|
||||
:public {:desc "Set property public visibility"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
(:require [babashka.cli :as cli]
|
||||
[clojure.string :as string]
|
||||
[logseq.cli.command.auth :as auth-command]
|
||||
[logseq.cli.command.completions :as completions-command]
|
||||
[logseq.cli.command.core :as command-core]
|
||||
[logseq.cli.command.doctor :as doctor-command]
|
||||
[logseq.cli.command.graph :as graph-command]
|
||||
@@ -13,6 +14,7 @@
|
||||
[logseq.cli.command.show :as show-command]
|
||||
[logseq.cli.command.sync :as sync-command]
|
||||
[logseq.cli.command.upsert :as upsert-command]
|
||||
[logseq.cli.completion-generator :as completion-gen]
|
||||
[logseq.cli.server :as cli-server]
|
||||
[promesa.core :as p]))
|
||||
|
||||
@@ -105,7 +107,8 @@
|
||||
show-command/entries
|
||||
doctor-command/entries
|
||||
sync-command/entries
|
||||
auth-command/entries)))
|
||||
auth-command/entries
|
||||
completions-command/entries)))
|
||||
|
||||
;; Global option parsing lives in logseq.cli.command.core.
|
||||
|
||||
@@ -422,6 +425,11 @@
|
||||
(:login :logout)
|
||||
(auth-command/build-action command)
|
||||
|
||||
:completions
|
||||
{:ok? true
|
||||
:action {:type :completions
|
||||
:shell (or (:shell options) (first args))}}
|
||||
|
||||
{:ok? false
|
||||
:error {:code :unknown-command
|
||||
:message (str "unknown command: " command)}}))))
|
||||
@@ -463,6 +471,10 @@
|
||||
:query-list (query-command/execute-query-list action config)
|
||||
:show (show-command/execute-show action config)
|
||||
:doctor (doctor-command/execute-doctor action config)
|
||||
:completions (p/resolved
|
||||
{:status :ok
|
||||
:data {:message (completion-gen/generate-completions
|
||||
(:shell action) table)}})
|
||||
:server-list (server-command/execute-list action config)
|
||||
:server-status (server-command/execute-status action config)
|
||||
:server-start (server-command/execute-start action config)
|
||||
|
||||
786
src/main/logseq/cli/completion_generator.cljs
Normal file
786
src/main/logseq/cli/completion_generator.cljs
Normal file
@@ -0,0 +1,786 @@
|
||||
(ns logseq.cli.completion-generator
|
||||
"Pure function: (generate-completions shell table) → string.
|
||||
Walks the CLI command table and emits zsh or bash completion scripts."
|
||||
(:require [clojure.string :as string]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Table introspection utilities
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn extract-groups
|
||||
"Given a table of entries, group by the first element of :cmds.
|
||||
Returns a map of {group-name [entries...]}."
|
||||
[table]
|
||||
(reduce (fn [acc entry]
|
||||
(let [cmds (:cmds entry)
|
||||
group (first cmds)]
|
||||
(update acc group (fnil conj []) entry)))
|
||||
{}
|
||||
table))
|
||||
|
||||
(defn leaf-commands
|
||||
"Return entries that are leaf commands (single-element :cmds)."
|
||||
[table]
|
||||
(let [groups (extract-groups table)]
|
||||
(->> groups
|
||||
(filter (fn [[_ entries]]
|
||||
(and (= 1 (count entries))
|
||||
(= 1 (count (:cmds (first entries)))))))
|
||||
(mapv (fn [[_ entries]] (first entries))))))
|
||||
|
||||
(defn group-commands
|
||||
"Return group names that have subcommands (multi-element :cmds entries)."
|
||||
[table]
|
||||
(let [groups (extract-groups table)]
|
||||
(->> groups
|
||||
(filter (fn [[_ entries]]
|
||||
(or (> (count entries) 1)
|
||||
(> (count (:cmds (first entries))) 1))))
|
||||
(mapv first))))
|
||||
|
||||
(defn spec->token
|
||||
"Convert a single spec entry [key spec-map] to a token descriptor.
|
||||
Returns {:key k :type t ...} with type being one of:
|
||||
:flag, :enum, :dynamic, :file, :dir, :free"
|
||||
[[k spec-map]]
|
||||
(let [alias (:alias spec-map)
|
||||
desc (or (:desc spec-map) "")
|
||||
coerce (:coerce spec-map)
|
||||
values (:values spec-map)
|
||||
complete (:complete spec-map)]
|
||||
(cond-> {:key k
|
||||
:desc desc}
|
||||
alias (assoc :alias alias)
|
||||
(= coerce :boolean) (assoc :type :flag)
|
||||
(and (not= coerce :boolean) (seq values)) (assoc :type :enum :values values)
|
||||
(and (not= coerce :boolean) (nil? values) (= complete :graphs)) (assoc :type :dynamic :complete :graphs)
|
||||
(and (not= coerce :boolean) (nil? values) (= complete :pages)) (assoc :type :dynamic :complete :pages)
|
||||
(and (not= coerce :boolean) (nil? values) (= complete :queries)) (assoc :type :dynamic :complete :queries)
|
||||
(and (not= coerce :boolean) (nil? values) (= complete :file)) (assoc :type :file)
|
||||
(and (not= coerce :boolean) (nil? values) (= complete :dir)) (assoc :type :dir)
|
||||
(and (not= coerce :boolean) (nil? values) (nil? complete)) (assoc :type :free))))
|
||||
|
||||
(defn spec->tokens
|
||||
"Convert a full spec map to a vector of token descriptors."
|
||||
[spec]
|
||||
(mapv spec->token spec))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Zsh dynamic helpers (verbatim preamble)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private zsh-preamble
|
||||
"#compdef logseq
|
||||
# Auto-generated by `logseq completions zsh` — do not edit manually.
|
||||
|
||||
# --- dynamic helpers ---
|
||||
|
||||
_logseq_json_names() {
|
||||
python3 -c \"
|
||||
import sys, json
|
||||
field = sys.argv[1]
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
v = item.get(field)
|
||||
if isinstance(v, str) and v:
|
||||
print(v)
|
||||
except Exception:
|
||||
pass
|
||||
\" \"$1\" 2>/dev/null
|
||||
}
|
||||
|
||||
_logseq_current_graph() {
|
||||
local i
|
||||
for (( i = 1; i < ${#words[@]}; i++ )); do
|
||||
if [[ \"${words[i]}\" == '--graph' && -n \"${words[i+1]}\" ]]; then
|
||||
print -r -- \"${words[i+1]}\"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
_logseq_graphs() {
|
||||
local cache_id='logseq_graphs'
|
||||
local -a graphs
|
||||
if _cache_invalid \"$cache_id\" || ! _retrieve_cache \"$cache_id\"; then
|
||||
graphs=( ${(f)\"$(logseq graph list --output json 2>/dev/null | _logseq_json_names name)\"} )
|
||||
_store_cache \"$cache_id\" graphs
|
||||
fi
|
||||
compadd -a graphs
|
||||
}
|
||||
|
||||
_logseq_pages() {
|
||||
local graph
|
||||
graph=$(_logseq_current_graph)
|
||||
local cache_id=\"logseq_pages_${graph:-__default__}\"
|
||||
local -a pages
|
||||
if _cache_invalid \"$cache_id\" || ! _retrieve_cache \"$cache_id\"; then
|
||||
if [[ -n \"$graph\" ]]; then
|
||||
pages=( ${(f)\"$(logseq list page --graph \"$graph\" --output json 2>/dev/null | _logseq_json_names title)\"} )
|
||||
fi
|
||||
_store_cache \"$cache_id\" pages
|
||||
fi
|
||||
compadd -a pages
|
||||
}
|
||||
|
||||
_logseq_queries() {
|
||||
local graph
|
||||
graph=$(_logseq_current_graph)
|
||||
local cache_id=\"logseq_queries_${graph:-__default__}\"
|
||||
local -a queries
|
||||
if _cache_invalid \"$cache_id\" || ! _retrieve_cache \"$cache_id\"; then
|
||||
if [[ -n \"$graph\" ]]; then
|
||||
queries=( ${(f)\"$(logseq query list --graph \"$graph\" --output json 2>/dev/null | _logseq_json_names name)\"} )
|
||||
fi
|
||||
_store_cache \"$cache_id\" queries
|
||||
fi
|
||||
compadd -a queries
|
||||
}
|
||||
")
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Zsh generation
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- zsh-option-name
|
||||
[k]
|
||||
(str "--" (name k)))
|
||||
|
||||
(defn- zsh-escape-desc
|
||||
[desc]
|
||||
(-> desc
|
||||
(string/replace "[" "\\[")
|
||||
(string/replace "]" "\\]")
|
||||
(string/replace "'" "'\\''")))
|
||||
|
||||
(defn- zsh-token-for
|
||||
"Generate a zsh _arguments token string for a spec token descriptor."
|
||||
[{:keys [key type alias desc values complete]}]
|
||||
(let [long-opt (zsh-option-name key)
|
||||
desc* (zsh-escape-desc desc)
|
||||
alias-short (when alias (str "-" (name alias)))]
|
||||
(case type
|
||||
:flag
|
||||
(if alias
|
||||
(str "'" (str "(" alias-short " " long-opt ")") "'"
|
||||
"{" alias-short "," long-opt "}"
|
||||
"'[" desc* "]'")
|
||||
(str "'" long-opt "[" desc* "]'"))
|
||||
|
||||
:enum
|
||||
(let [vals-str (string/join " " values)]
|
||||
(if alias
|
||||
(str "'" (str "(" alias-short " " long-opt ")") "'"
|
||||
"{" alias-short "," long-opt "}"
|
||||
"'=[" desc* "]:value:(" vals-str ")'")
|
||||
(str "'" long-opt "=[" desc* "]:value:(" vals-str ")'"))
|
||||
)
|
||||
|
||||
:dynamic
|
||||
(let [action (case complete
|
||||
:graphs "_logseq_graphs"
|
||||
:pages "_logseq_pages"
|
||||
:queries "_logseq_queries")]
|
||||
(if alias
|
||||
(str "'" (str "(" alias-short " " long-opt ")") "'"
|
||||
"{" alias-short "," long-opt "}"
|
||||
"'=[" desc* "]:value:" action "'")
|
||||
(str "'" long-opt "=[" desc* "]:value:" action "'")))
|
||||
|
||||
:file
|
||||
(if alias
|
||||
(str "'" (str "(" alias-short " " long-opt ")") "'"
|
||||
"{" alias-short "," long-opt "}"
|
||||
"'=[" desc* "]:file:_files'")
|
||||
(str "'" long-opt "=[" desc* "]:file:_files'"))
|
||||
|
||||
:dir
|
||||
(if alias
|
||||
(str "'" (str "(" alias-short " " long-opt ")") "'"
|
||||
"{" alias-short "," long-opt "}"
|
||||
"'=[" desc* "]:dir:_files -/'")
|
||||
(str "'" long-opt "=[" desc* "]:dir:_files -/'"))
|
||||
|
||||
:free
|
||||
(if alias
|
||||
(str "'" (str "(" alias-short " " long-opt ")") "'"
|
||||
"{" alias-short "," long-opt "}"
|
||||
"'=[" desc* "]:value:'")
|
||||
(str "'" long-opt "=[" desc* "]:value:'"))
|
||||
|
||||
;; default
|
||||
(str "'" long-opt "=[" desc* "]:value:'"))))
|
||||
|
||||
(defn- zsh-arguments-tokens
|
||||
"Generate _arguments tokens for a spec map."
|
||||
[spec]
|
||||
(->> (spec->tokens spec)
|
||||
(mapv zsh-token-for)))
|
||||
|
||||
(defn- zsh-leaf-function
|
||||
"Generate a _logseq_<command>() function for a leaf command."
|
||||
[func-name spec]
|
||||
(let [tokens (zsh-arguments-tokens spec)
|
||||
token-lines (string/join " \\\n " tokens)]
|
||||
(str func-name "() {\n"
|
||||
" _arguments -s \\\n"
|
||||
" " token-lines "\n"
|
||||
"}\n")))
|
||||
|
||||
(defn- cmd->func-name
|
||||
"Convert a cmds vector like [\"graph\" \"export\"] to \"_logseq_graph_export\"."
|
||||
[cmds]
|
||||
(str "_logseq_" (string/join "_" cmds)))
|
||||
|
||||
(defn- zsh-group-function
|
||||
"Generate a group dispatcher function.
|
||||
Handles groups that have both a root command (e.g. [\"query\"]) and
|
||||
subcommands (e.g. [\"query\" \"list\"])."
|
||||
[group-name subentries global-spec]
|
||||
(let [func-name (str "_logseq_" group-name)
|
||||
;; Separate root entry from subcommand entries
|
||||
root-entry (first (filter #(= 1 (count (:cmds %))) subentries))
|
||||
sub-entries (filter #(> (count (:cmds %)) 1) subentries)
|
||||
;; Root-level options (global + root command's own spec if present)
|
||||
root-spec (if root-entry
|
||||
(:spec root-entry)
|
||||
global-spec)
|
||||
root-tokens (zsh-arguments-tokens root-spec)
|
||||
root-lines (string/join " \\\n " root-tokens)
|
||||
subcmds (->> sub-entries
|
||||
(mapv (fn [entry]
|
||||
(let [subcmd (second (:cmds entry))
|
||||
desc (or (:desc entry) "")]
|
||||
(str " '" subcmd ":" desc "'")))))
|
||||
subcmd-lines (string/join "\n" subcmds)
|
||||
dispatches (->> sub-entries
|
||||
(mapv (fn [entry]
|
||||
(let [subcmd (second (:cmds entry))
|
||||
sub-func (cmd->func-name (:cmds entry))]
|
||||
(str " " subcmd ") " sub-func " ;;"))))
|
||||
)
|
||||
dispatch-lines (string/join "\n" dispatches)]
|
||||
(str func-name "() {\n"
|
||||
" local curcontext=\"$curcontext\" state line\n"
|
||||
" typeset -A opt_args\n"
|
||||
"\n"
|
||||
" _arguments -C -s \\\n"
|
||||
" " root-lines " \\\n"
|
||||
" '1:subcommand:->subcmd' \\\n"
|
||||
" '*::args:->args'\n"
|
||||
"\n"
|
||||
" case $state in\n"
|
||||
" subcmd)\n"
|
||||
" local -a subcmds\n"
|
||||
" subcmds=(\n"
|
||||
subcmd-lines "\n"
|
||||
" )\n"
|
||||
" _describe 'subcommand' subcmds\n"
|
||||
" ;;\n"
|
||||
" args)\n"
|
||||
" case $line[1] in\n"
|
||||
dispatch-lines "\n"
|
||||
" esac\n"
|
||||
" ;;\n"
|
||||
" esac\n"
|
||||
"}\n")))
|
||||
|
||||
(defn- zsh-toplevel-function
|
||||
"Generate the _logseq() root dispatcher."
|
||||
[table global-spec]
|
||||
(let [groups (extract-groups table)
|
||||
group-names (sort (keys groups))
|
||||
;; Build command descriptions for top level
|
||||
cmd-descs (->> group-names
|
||||
(mapv (fn [g]
|
||||
(let [entries (get groups g)
|
||||
desc (if (and (= 1 (count entries))
|
||||
(= 1 (count (:cmds (first entries)))))
|
||||
(or (:desc (first entries)) "")
|
||||
(str g " commands"))]
|
||||
(str " '" g ":" desc "'")))))
|
||||
cmd-desc-lines (string/join "\n" cmd-descs)
|
||||
dispatches (->> group-names
|
||||
(mapv (fn [g]
|
||||
(let [entries (get groups g)
|
||||
func (if (and (= 1 (count entries))
|
||||
(= 1 (count (:cmds (first entries)))))
|
||||
(cmd->func-name (:cmds (first entries)))
|
||||
(str "_logseq_" g))]
|
||||
(str " " g ") " func " ;;"))))
|
||||
)
|
||||
dispatch-lines (string/join "\n" dispatches)
|
||||
global-tokens (zsh-arguments-tokens global-spec)
|
||||
global-lines (string/join " \\\n " global-tokens)]
|
||||
(str "_logseq() {\n"
|
||||
" local curcontext=\"$curcontext\" state line\n"
|
||||
" typeset -A opt_args\n"
|
||||
"\n"
|
||||
" _arguments -C -s \\\n"
|
||||
" " global-lines " \\\n"
|
||||
" '1:command:->cmds' \\\n"
|
||||
" '*::args:->args'\n"
|
||||
"\n"
|
||||
" case $state in\n"
|
||||
" cmds)\n"
|
||||
" local -a cmds\n"
|
||||
" cmds=(\n"
|
||||
cmd-desc-lines "\n"
|
||||
" )\n"
|
||||
" _describe 'command' cmds\n"
|
||||
" ;;\n"
|
||||
" args)\n"
|
||||
" case $line[1] in\n"
|
||||
dispatch-lines "\n"
|
||||
" esac\n"
|
||||
" ;;\n"
|
||||
" esac\n"
|
||||
"}\n")))
|
||||
|
||||
(defn generate-zsh
|
||||
"Generate complete zsh completion script from the command table."
|
||||
[table]
|
||||
(let [groups (extract-groups table)
|
||||
global-spec (-> table first :spec
|
||||
(select-keys [:help :version :config :graph :data-dir
|
||||
:timeout-ms :output :verbose]))
|
||||
;; Generate leaf command functions
|
||||
leaf-fns (->> groups
|
||||
(mapcat (fn [[_ entries]]
|
||||
(mapv (fn [entry]
|
||||
(zsh-leaf-function
|
||||
(cmd->func-name (:cmds entry))
|
||||
(:spec entry)))
|
||||
entries)))
|
||||
(string/join "\n"))
|
||||
;; Generate group dispatchers
|
||||
group-fns (->> groups
|
||||
(filter (fn [[_ entries]]
|
||||
(or (> (count entries) 1)
|
||||
(> (count (:cmds (first entries))) 1))))
|
||||
(mapv (fn [[group-name entries]]
|
||||
(zsh-group-function group-name entries global-spec)))
|
||||
(string/join "\n"))
|
||||
;; Generate top-level dispatcher
|
||||
toplevel (zsh-toplevel-function table global-spec)]
|
||||
(str zsh-preamble "\n"
|
||||
"# --- per-command functions ---\n\n"
|
||||
leaf-fns "\n"
|
||||
"# --- group dispatchers ---\n\n"
|
||||
group-fns "\n"
|
||||
"# --- top-level dispatcher ---\n\n"
|
||||
toplevel "\n"
|
||||
"_logseq \"$@\"\n")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Bash dynamic helpers (verbatim preamble)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private bash-preamble
|
||||
"# Auto-generated by `logseq completions bash` — do not edit manually.
|
||||
|
||||
# --- dynamic helpers ---
|
||||
|
||||
_logseq_json_names_bash() {
|
||||
python3 -c \"
|
||||
import sys, json
|
||||
field = sys.argv[1]
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
v = item.get(field)
|
||||
if isinstance(v, str) and v:
|
||||
print(v)
|
||||
except Exception:
|
||||
pass
|
||||
\" \"$1\" 2>/dev/null
|
||||
}
|
||||
|
||||
_logseq_current_graph_bash() {
|
||||
local i
|
||||
for (( i = 1; i < ${#COMP_WORDS[@]}; i++ )); do
|
||||
if [[ \"${COMP_WORDS[i]}\" == '--graph' && -n \"${COMP_WORDS[i+1]}\" ]]; then
|
||||
printf '%s' \"${COMP_WORDS[i+1]}\"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
_logseq_graphs_bash() {
|
||||
logseq graph list --output json 2>/dev/null | _logseq_json_names_bash name
|
||||
}
|
||||
|
||||
_logseq_pages_bash() {
|
||||
logseq list page --graph \"$1\" --output json 2>/dev/null | _logseq_json_names_bash title
|
||||
}
|
||||
|
||||
_logseq_queries_bash() {
|
||||
logseq query list --graph \"$1\" --output json 2>/dev/null | _logseq_json_names_bash name
|
||||
}
|
||||
|
||||
_logseq_compadd_lines() {
|
||||
local cur=\"$1\" source_fn=\"$2\"; shift 2
|
||||
while IFS= read -r item; do
|
||||
[[ \"$item\" == \"$cur\"* ]] && COMPREPLY+=( \"$item\" )
|
||||
done < <(\"$source_fn\" \"$@\")
|
||||
}
|
||||
")
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Bash generation
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- bash-option-name
|
||||
[k]
|
||||
(str "--" (name k)))
|
||||
|
||||
(defn- bash-all-value-opts
|
||||
"Collect all non-boolean option names (options that consume a value argument)."
|
||||
[table]
|
||||
(let [all-specs (->> table (mapcat (fn [entry] (seq (:spec entry)))))
|
||||
value-opts (->> all-specs
|
||||
(remove (fn [[_ spec-map]]
|
||||
(= :boolean (:coerce spec-map))))
|
||||
(mapv (fn [[k _]] (bash-option-name k))))]
|
||||
(-> (set value-opts)
|
||||
sort
|
||||
vec)))
|
||||
|
||||
(defn- bash-is-value-opt
|
||||
"Generate _logseq_is_value_opt function."
|
||||
[table]
|
||||
(let [opts (bash-all-value-opts table)
|
||||
cases (string/join "|" opts)]
|
||||
(str "_logseq_is_value_opt() {\n"
|
||||
" case \"$1\" in\n"
|
||||
" " cases ")\n"
|
||||
" return 0 ;;\n"
|
||||
" *) return 1 ;;\n"
|
||||
" esac\n"
|
||||
"}\n")))
|
||||
|
||||
(defn- bash-cmd-and-subcmd
|
||||
"Generate _logseq_cmd_and_subcmd function."
|
||||
[]
|
||||
"_logseq_cmd_and_subcmd() {
|
||||
local i skip=0
|
||||
__cmd='' __subcmd=''
|
||||
for (( i = 1; i < COMP_CWORD; i++ )); do
|
||||
local w=\"${COMP_WORDS[i]}\"
|
||||
if (( skip )); then skip=0; continue; fi
|
||||
if [[ \"$w\" == -* ]]; then
|
||||
_logseq_is_value_opt \"$w\" && skip=1
|
||||
continue
|
||||
fi
|
||||
if [[ -z \"$__cmd\" ]]; then
|
||||
__cmd=\"$w\"
|
||||
elif [[ -z \"$__subcmd\" ]]; then
|
||||
__subcmd=\"$w\"
|
||||
fi
|
||||
done
|
||||
}\n")
|
||||
|
||||
(defn- bash-global-opts-string
|
||||
"Generate the global opts wordlist string."
|
||||
[global-spec]
|
||||
(->> (keys global-spec)
|
||||
(mapcat (fn [k]
|
||||
(let [spec-map (get global-spec k)
|
||||
long-opt (bash-option-name k)
|
||||
alias (:alias spec-map)]
|
||||
(if alias
|
||||
[long-opt (str "-" (name alias))]
|
||||
[long-opt]))))
|
||||
(string/join " ")))
|
||||
|
||||
(defn- bash-opts-for
|
||||
"Generate _logseq_opts_for function."
|
||||
[table]
|
||||
(let [groups (extract-groups table)
|
||||
global-spec (-> table first :spec
|
||||
(select-keys [:help :version :config :graph :data-dir
|
||||
:timeout-ms :output :verbose]))
|
||||
global-str (bash-global-opts-string global-spec)
|
||||
;; Build case branches
|
||||
branches
|
||||
(->> (sort-by first groups)
|
||||
(mapv (fn [[group-name entries]]
|
||||
(if (and (= 1 (count entries))
|
||||
(= 1 (count (:cmds (first entries)))))
|
||||
;; Leaf command
|
||||
(let [entry (first entries)
|
||||
cmd-spec (apply dissoc (:spec entry) (keys global-spec))
|
||||
cmd-opts (->> (keys cmd-spec)
|
||||
(mapcat (fn [k]
|
||||
(let [sm (get cmd-spec k)]
|
||||
(if (:alias sm)
|
||||
[(bash-option-name k) (str "-" (name (:alias sm)))]
|
||||
[(bash-option-name k)]))))
|
||||
(string/join " "))]
|
||||
(str " " group-name ") opts+=' " cmd-opts "' ;;"))
|
||||
;; Group with subcommands
|
||||
(let [;; Root entry opts go at group level, sub-entries get case branches
|
||||
root-entry (first (filter #(= 1 (count (:cmds %))) entries))
|
||||
sub-entries (filter #(> (count (:cmds %)) 1) entries)
|
||||
root-opts (when root-entry
|
||||
(let [cmd-spec (apply dissoc (:spec root-entry) (keys global-spec))]
|
||||
(->> (keys cmd-spec)
|
||||
(mapcat (fn [k]
|
||||
(let [sm (get cmd-spec k)]
|
||||
(if (:alias sm)
|
||||
[(bash-option-name k) (str "-" (name (:alias sm)))]
|
||||
[(bash-option-name k)]))))
|
||||
(string/join " "))))
|
||||
sub-branches
|
||||
(->> sub-entries
|
||||
(mapv (fn [entry]
|
||||
(let [subcmd (second (:cmds entry))
|
||||
cmd-spec (apply dissoc (:spec entry) (keys global-spec))
|
||||
cmd-opts (->> (keys cmd-spec)
|
||||
(mapcat (fn [k]
|
||||
(let [sm (get cmd-spec k)]
|
||||
(if (:alias sm)
|
||||
[(bash-option-name k) (str "-" (name (:alias sm)))]
|
||||
[(bash-option-name k)]))))
|
||||
(string/join " "))]
|
||||
(str " " subcmd ") opts+=' " cmd-opts "' ;;"))))
|
||||
(string/join "\n"))]
|
||||
(str " " group-name ")\n"
|
||||
(when (seq root-opts)
|
||||
(str " opts+=' " root-opts "'\n"))
|
||||
" case \"$subcmd\" in\n"
|
||||
sub-branches "\n"
|
||||
" esac\n"
|
||||
" ;;"))))
|
||||
))]
|
||||
(str "_logseq_opts_for() {\n"
|
||||
" local cmd=\"$1\" subcmd=\"$2\"\n"
|
||||
" local opts=\"" global-str "\"\n"
|
||||
"\n"
|
||||
" case \"$cmd\" in\n"
|
||||
(string/join "\n" branches) "\n"
|
||||
" esac\n"
|
||||
"\n"
|
||||
" printf '%s' \"$opts\"\n"
|
||||
"}\n")))
|
||||
|
||||
(defn- bash-prev-completion-case
|
||||
"Generate a case branch for prev-word value completion."
|
||||
[{:keys [key type values complete]}]
|
||||
(let [long-opt (bash-option-name key)]
|
||||
(case type
|
||||
:enum
|
||||
(str " " long-opt ")\n"
|
||||
" COMPREPLY=( $(compgen -W '" (string/join " " values) "' -- \"$cur\") )\n"
|
||||
" return ;;")
|
||||
|
||||
:dynamic
|
||||
(case complete
|
||||
:graphs
|
||||
(str " " long-opt ")\n"
|
||||
" _logseq_compadd_lines \"$cur\" _logseq_graphs_bash\n"
|
||||
" return ;;")
|
||||
:pages
|
||||
(str " " long-opt ")\n"
|
||||
" local graph\n"
|
||||
" graph=\"$(_logseq_current_graph_bash)\"\n"
|
||||
" [[ -n \"$graph\" ]] && _logseq_compadd_lines \"$cur\" _logseq_pages_bash \"$graph\"\n"
|
||||
" return ;;")
|
||||
:queries
|
||||
(str " " long-opt ")\n"
|
||||
" local graph\n"
|
||||
" graph=\"$(_logseq_current_graph_bash)\"\n"
|
||||
" [[ -n \"$graph\" ]] && _logseq_compadd_lines \"$cur\" _logseq_queries_bash \"$graph\"\n"
|
||||
" return ;;"))
|
||||
|
||||
:file
|
||||
(str " " long-opt ")\n"
|
||||
" COMPREPLY=( $(compgen -f -- \"$cur\") )\n"
|
||||
" return ;;")
|
||||
|
||||
:dir
|
||||
(str " " long-opt ")\n"
|
||||
" COMPREPLY=( $(compgen -d -- \"$cur\") )\n"
|
||||
" return ;;")
|
||||
|
||||
nil)))
|
||||
|
||||
(defn- bash-prev-cases
|
||||
"Generate all prev-word value completion cases from the table."
|
||||
[table]
|
||||
(let [all-specs (->> table (mapcat (fn [entry] (seq (:spec entry)))))
|
||||
unique-specs (into {} all-specs) ;; last one wins for duplicates
|
||||
tokens (spec->tokens unique-specs)
|
||||
cases (->> tokens
|
||||
(keep bash-prev-completion-case)
|
||||
(string/join "\n\n"))]
|
||||
cases))
|
||||
|
||||
(defn- bash-subcommand-cases
|
||||
"Generate subcommand completion for each group."
|
||||
[table]
|
||||
(let [groups (extract-groups table)
|
||||
cases (->> (sort-by first groups)
|
||||
(keep (fn [[group-name entries]]
|
||||
(when (or (> (count entries) 1)
|
||||
(> (count (:cmds (first entries))) 1))
|
||||
(let [subcmds (->> entries
|
||||
(keep #(second (:cmds %)))
|
||||
(string/join " "))]
|
||||
(when (seq subcmds)
|
||||
(str " " group-name ") COMPREPLY=( $(compgen -W '"
|
||||
subcmds "' -- \"$cur\") ) ;;")))))))]
|
||||
(string/join "\n" cases)))
|
||||
|
||||
(defn- bash-toplevel-commands
|
||||
"Get all top-level command names."
|
||||
[table]
|
||||
(let [groups (extract-groups table)]
|
||||
(->> (keys groups) sort (string/join " "))))
|
||||
|
||||
(defn- bash-context-dependent-prev-cases
|
||||
"Generate context-dependent prev-word cases (e.g., --name means different things
|
||||
in different commands, --sort has different values per list subcommand)."
|
||||
[table]
|
||||
(let [groups (extract-groups table)
|
||||
;; Find all --name contexts
|
||||
name-cases
|
||||
(->> table
|
||||
(keep (fn [entry]
|
||||
(let [name-spec (get-in entry [:spec :name])
|
||||
complete (:complete name-spec)]
|
||||
(when complete
|
||||
{:cmds (:cmds entry) :complete complete}))))
|
||||
(mapv (fn [{:keys [cmds complete]}]
|
||||
(let [cmd (first cmds)
|
||||
subcmd (second cmds)]
|
||||
(case complete
|
||||
:queries
|
||||
(str " if [[ \"$__cmd\" == '" cmd "' ]]; then\n"
|
||||
" local graph\n"
|
||||
" graph=\"$(_logseq_current_graph_bash)\"\n"
|
||||
" [[ -n \"$graph\" ]] && _logseq_compadd_lines \"$cur\" _logseq_queries_bash \"$graph\"\n"
|
||||
" fi")
|
||||
:pages
|
||||
(str " if [[ \"$__cmd\" == '" cmd "' && \"$__subcmd\" == '" subcmd "' ]]; then\n"
|
||||
" local graph\n"
|
||||
" graph=\"$(_logseq_current_graph_bash)\"\n"
|
||||
" [[ -n \"$graph\" ]] && _logseq_compadd_lines \"$cur\" _logseq_pages_bash \"$graph\"\n"
|
||||
" fi")
|
||||
nil)))))
|
||||
;; Find all --sort contexts
|
||||
sort-cases
|
||||
(->> table
|
||||
(keep (fn [entry]
|
||||
(let [sort-spec (get-in entry [:spec :sort])
|
||||
values (:values sort-spec)]
|
||||
(when (seq values)
|
||||
{:cmds (:cmds entry) :values values}))))
|
||||
(mapv (fn [{:keys [cmds values]}]
|
||||
(let [cmd (first cmds)
|
||||
subcmd (second cmds)
|
||||
vals-str (string/join " " values)]
|
||||
(str " if [[ \"$__cmd\" == '" cmd "' && \"$__subcmd\" == '" subcmd "' ]]; then\n"
|
||||
" COMPREPLY=( $(compgen -W '" vals-str "' -- \"$cur\") )\n"
|
||||
" return\n"
|
||||
" fi")))))]
|
||||
{:name-cases name-cases
|
||||
:sort-cases sort-cases}))
|
||||
|
||||
(defn- bash-main-function
|
||||
"Generate the _logseq() main completion function."
|
||||
[table]
|
||||
(let [groups (extract-groups table)
|
||||
global-spec (-> table first :spec
|
||||
(select-keys [:help :version :config :graph :data-dir
|
||||
:timeout-ms :output :verbose]))
|
||||
;; Collect unique non-context-dependent prev-word cases
|
||||
;; (skip --name and --sort since they're context-dependent)
|
||||
all-specs (->> table (mapcat (fn [entry] (seq (:spec entry)))))
|
||||
unique-specs (into {} all-specs)
|
||||
tokens (spec->tokens unique-specs)
|
||||
context-free-tokens (remove #(#{:name :sort} (:key %)) tokens)
|
||||
prev-cases (->> context-free-tokens
|
||||
(keep bash-prev-completion-case)
|
||||
(string/join "\n\n"))
|
||||
;; Context-dependent cases
|
||||
{:keys [name-cases sort-cases]} (bash-context-dependent-prev-cases table)
|
||||
;; Subcommand completion
|
||||
subcmd-cases (bash-subcommand-cases table)
|
||||
;; Top-level commands
|
||||
top-cmds (bash-toplevel-commands table)]
|
||||
(str "_logseq() {\n"
|
||||
" local cur prev\n"
|
||||
" cur=\"${COMP_WORDS[COMP_CWORD]}\"\n"
|
||||
" prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n"
|
||||
" COMPREPLY=()\n"
|
||||
"\n"
|
||||
" local __cmd __subcmd\n"
|
||||
" _logseq_cmd_and_subcmd\n"
|
||||
"\n"
|
||||
" # --- Option value completion ---\n"
|
||||
" case \"$prev\" in\n"
|
||||
prev-cases "\n"
|
||||
"\n"
|
||||
" --name)\n"
|
||||
(string/join "\n" name-cases) "\n"
|
||||
" return ;;\n"
|
||||
"\n"
|
||||
" --sort)\n"
|
||||
(string/join "\n" sort-cases) "\n"
|
||||
" return ;;\n"
|
||||
" esac\n"
|
||||
"\n"
|
||||
" # --- Flag / positional completion ---\n"
|
||||
" if [[ \"$cur\" == -* ]]; then\n"
|
||||
" # shellcheck disable=SC2046\n"
|
||||
" COMPREPLY=( $(compgen -W \"$(_logseq_opts_for \"$__cmd\" \"$__subcmd\")\" -- \"$cur\") )\n"
|
||||
" return\n"
|
||||
" fi\n"
|
||||
"\n"
|
||||
" if [[ -z \"$__cmd\" ]]; then\n"
|
||||
" COMPREPLY=( $(compgen -W '" top-cmds "' -- \"$cur\") )\n"
|
||||
" return\n"
|
||||
" fi\n"
|
||||
"\n"
|
||||
" if [[ -z \"$__subcmd\" ]]; then\n"
|
||||
" case \"$__cmd\" in\n"
|
||||
subcmd-cases "\n"
|
||||
" esac\n"
|
||||
" return\n"
|
||||
" fi\n"
|
||||
"}\n")))
|
||||
|
||||
(defn generate-bash
|
||||
"Generate complete bash completion script from the command table."
|
||||
[table]
|
||||
(let [is-value-opt (bash-is-value-opt table)
|
||||
cmd-and-subcmd (bash-cmd-and-subcmd)
|
||||
opts-for (bash-opts-for table)
|
||||
main-fn (bash-main-function table)]
|
||||
(str bash-preamble "\n"
|
||||
"# --- generated helpers ---\n\n"
|
||||
is-value-opt "\n"
|
||||
cmd-and-subcmd "\n"
|
||||
"# --- option wordlists ---\n\n"
|
||||
opts-for "\n"
|
||||
"# --- main function ---\n\n"
|
||||
main-fn "\n"
|
||||
"complete -F _logseq logseq\n")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Public API
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn generate-completions
|
||||
"Generate shell completions from the CLI command table.
|
||||
shell: \"zsh\" or \"bash\"
|
||||
table: vector of command entries"
|
||||
[shell table]
|
||||
(case shell
|
||||
"zsh" (generate-zsh table)
|
||||
"bash" (generate-bash table)
|
||||
(throw (ex-info (str "unsupported shell: " shell) {:shell shell}))))
|
||||
40
src/test/logseq/cli/command/completions_test.cljs
Normal file
40
src/test/logseq/cli/command/completions_test.cljs
Normal file
@@ -0,0 +1,40 @@
|
||||
(ns logseq.cli.command.completions-test
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[logseq.cli.command.completions :as completions-command]
|
||||
[logseq.cli.commands :as commands]))
|
||||
|
||||
(deftest test-completions-command-registration
|
||||
(testing "completions entry has correct structure"
|
||||
(let [entry (first completions-command/entries)]
|
||||
(is (= ["completions"] (:cmds entry)))
|
||||
(is (= :completions (:command entry)))
|
||||
(is (= ["zsh" "bash"] (get-in entry [:spec :shell :values]))))))
|
||||
|
||||
(deftest test-parse-args-completions-shell
|
||||
(testing "parse-args recognizes completions --shell zsh"
|
||||
(let [result (commands/parse-args ["completions" "--shell" "zsh"])]
|
||||
(is (true? (:ok? result)))
|
||||
(is (= :completions (:command result)))))
|
||||
(testing "parse-args recognizes completions with positional arg"
|
||||
(let [result (commands/parse-args ["completions" "zsh"])]
|
||||
(is (true? (:ok? result)))
|
||||
(is (= :completions (:command result))))))
|
||||
|
||||
(deftest test-build-action-completions
|
||||
(testing "build-action for :completions returns correct action"
|
||||
(let [parsed {:ok? true
|
||||
:command :completions
|
||||
:options {:shell "zsh"}
|
||||
:args []}
|
||||
action (commands/build-action parsed {})]
|
||||
(is (true? (:ok? action)))
|
||||
(is (= :completions (get-in action [:action :type])))
|
||||
(is (= "zsh" (get-in action [:action :shell])))))
|
||||
(testing "build-action with positional arg"
|
||||
(let [parsed {:ok? true
|
||||
:command :completions
|
||||
:options {}
|
||||
:args ["bash"]}
|
||||
action (commands/build-action parsed {})]
|
||||
(is (true? (:ok? action)))
|
||||
(is (= "bash" (get-in action [:action :shell]))))))
|
||||
333
src/test/logseq/cli/completion_generator_test.cljs
Normal file
333
src/test/logseq/cli/completion_generator_test.cljs
Normal file
@@ -0,0 +1,333 @@
|
||||
(ns logseq.cli.completion-generator-test
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[clojure.string :as string]
|
||||
[logseq.cli.command.completions :as completions-command]
|
||||
[logseq.cli.command.core :as core]
|
||||
[logseq.cli.command.doctor :as doctor-command]
|
||||
[logseq.cli.command.graph :as graph-command]
|
||||
[logseq.cli.command.list :as list-command]
|
||||
[logseq.cli.command.query :as query-command]
|
||||
[logseq.cli.command.remove :as remove-command]
|
||||
[logseq.cli.command.server :as server-command]
|
||||
[logseq.cli.command.show :as show-command]
|
||||
[logseq.cli.command.upsert :as upsert-command]
|
||||
[logseq.cli.completion-generator :as gen]))
|
||||
|
||||
(def ^:private full-table
|
||||
(vec (concat graph-command/entries
|
||||
server-command/entries
|
||||
list-command/entries
|
||||
upsert-command/entries
|
||||
remove-command/entries
|
||||
query-command/entries
|
||||
show-command/entries
|
||||
doctor-command/entries
|
||||
completions-command/entries)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Phase 1 — Spec enrichment tests
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest test-global-spec-metadata
|
||||
(let [spec (core/global-spec)]
|
||||
(testing ":output has :values"
|
||||
(is (= ["human" "json" "edn"] (get-in spec [:output :values]))))
|
||||
(testing ":graph has :complete :graphs"
|
||||
(is (= :graphs (get-in spec [:graph :complete]))))
|
||||
(testing ":config has :complete :file"
|
||||
(is (= :file (get-in spec [:config :complete]))))
|
||||
(testing ":data-dir has :complete :dir"
|
||||
(is (= :dir (get-in spec [:data-dir :complete]))))))
|
||||
|
||||
(deftest test-list-spec-metadata
|
||||
(let [entries list-command/entries
|
||||
page-entry (first (filter #(= :list-page (:command %)) entries))
|
||||
tag-entry (first (filter #(= :list-tag (:command %)) entries))
|
||||
property-entry (first (filter #(= :list-property (:command %)) entries))]
|
||||
(testing "page-spec :sort has correct values"
|
||||
(is (= ["title" "created-at" "updated-at"]
|
||||
(get-in page-entry [:spec :sort :values]))))
|
||||
(testing "tag-spec :sort has correct values"
|
||||
(is (= ["name" "title"]
|
||||
(get-in tag-entry [:spec :sort :values]))))
|
||||
(testing "property-spec :sort has correct values"
|
||||
(is (= ["name" "title"]
|
||||
(get-in property-entry [:spec :sort :values]))))
|
||||
(testing "common :order has correct values"
|
||||
(is (= ["asc" "desc"]
|
||||
(get-in page-entry [:spec :order :values]))))))
|
||||
|
||||
(deftest test-upsert-spec-metadata
|
||||
(let [entries upsert-command/entries
|
||||
block-entry (first (filter #(= :upsert-block (:command %)) entries))
|
||||
page-entry (first (filter #(= :upsert-page (:command %)) entries))
|
||||
property-entry (first (filter #(= :upsert-property (:command %)) entries))]
|
||||
(testing "block-spec :pos has :values"
|
||||
(is (= ["first-child" "last-child" "sibling"]
|
||||
(get-in block-entry [:spec :pos :values]))))
|
||||
(testing "block-spec :status has :values"
|
||||
(is (seq (get-in block-entry [:spec :status :values]))))
|
||||
(testing "block-spec :target-page has :complete :pages"
|
||||
(is (= :pages (get-in block-entry [:spec :target-page :complete]))))
|
||||
(testing "block-spec :blocks-file has :complete :file"
|
||||
(is (= :file (get-in block-entry [:spec :blocks-file :complete]))))
|
||||
(testing "page-spec :page has :complete :pages"
|
||||
(is (= :pages (get-in page-entry [:spec :page :complete]))))
|
||||
(testing "property-spec :type has :values"
|
||||
(is (= ["default" "number" "date" "datetime" "checkbox" "url" "node" "json" "string"]
|
||||
(get-in property-entry [:spec :type :values]))))
|
||||
(testing "property-spec :cardinality has :values"
|
||||
(is (= ["one" "many"]
|
||||
(get-in property-entry [:spec :cardinality :values]))))))
|
||||
|
||||
(deftest test-graph-spec-metadata
|
||||
(let [entries graph-command/entries
|
||||
export-entry (first (filter #(= :graph-export (:command %)) entries))
|
||||
import-entry (first (filter #(= :graph-import (:command %)) entries))]
|
||||
(testing "export-spec :type has :values"
|
||||
(is (= ["edn" "sqlite"] (get-in export-entry [:spec :type :values]))))
|
||||
(testing "export-spec :file has :complete :file"
|
||||
(is (= :file (get-in export-entry [:spec :file :complete]))))
|
||||
(testing "import-spec :type has :values"
|
||||
(is (= ["edn" "sqlite"] (get-in import-entry [:spec :type :values]))))
|
||||
(testing "import-spec :input has :complete :file"
|
||||
(is (= :file (get-in import-entry [:spec :input :complete]))))))
|
||||
|
||||
(deftest test-query-spec-metadata
|
||||
(let [entries query-command/entries
|
||||
query-entry (first (filter #(= :query (:command %)) entries))]
|
||||
(testing "query-spec :name has :complete :queries"
|
||||
(is (= :queries (get-in query-entry [:spec :name :complete]))))))
|
||||
|
||||
(deftest test-show-spec-metadata
|
||||
(let [entries show-command/entries
|
||||
show-entry (first (filter #(= :show (:command %)) entries))]
|
||||
(testing "show-spec :page has :complete :pages"
|
||||
(is (= :pages (get-in show-entry [:spec :page :complete]))))))
|
||||
|
||||
(deftest test-remove-spec-metadata
|
||||
(let [entries remove-command/entries
|
||||
page-entry (first (filter #(= :remove-page (:command %)) entries))
|
||||
tag-entry (first (filter #(= :remove-tag (:command %)) entries))
|
||||
property-entry (first (filter #(= :remove-property (:command %)) entries))]
|
||||
(testing "remove-page :name has :complete :pages"
|
||||
(is (= :pages (get-in page-entry [:spec :name :complete]))))
|
||||
(testing "remove-tag :name does NOT have :complete"
|
||||
(is (nil? (get-in tag-entry [:spec :name :complete]))))
|
||||
(testing "remove-property :name does NOT have :complete"
|
||||
(is (nil? (get-in property-entry [:spec :name :complete]))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Phase 2 — Generator table introspection utilities
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest test-extract-groups
|
||||
(let [groups (gen/extract-groups full-table)]
|
||||
(testing "graph export is in graph group"
|
||||
(let [graph-entries (get groups "graph")]
|
||||
(is (some #(= ["graph" "export"] (:cmds %)) graph-entries))))
|
||||
(testing "show is a standalone group"
|
||||
(let [show-entries (get groups "show")]
|
||||
(is (= 1 (count show-entries)))
|
||||
(is (= ["show"] (:cmds (first show-entries))))))
|
||||
(testing "completions is a standalone group"
|
||||
(let [completions-entries (get groups "completions")]
|
||||
(is (= 1 (count completions-entries)))
|
||||
(is (= ["completions"] (:cmds (first completions-entries))))))))
|
||||
|
||||
(deftest test-leaf-and-group-commands
|
||||
(let [leaves (gen/leaf-commands full-table)
|
||||
groups (gen/group-commands full-table)
|
||||
leaf-names (set (map #(first (:cmds %)) leaves))
|
||||
group-names (set groups)]
|
||||
(testing "show and doctor are leaves"
|
||||
(is (contains? leaf-names "show"))
|
||||
(is (contains? leaf-names "doctor")))
|
||||
(testing "graph, server, list, upsert, remove are groups"
|
||||
(is (contains? group-names "graph"))
|
||||
(is (contains? group-names "server"))
|
||||
(is (contains? group-names "list"))
|
||||
(is (contains? group-names "upsert"))
|
||||
(is (contains? group-names "remove")))))
|
||||
|
||||
(deftest test-spec->token
|
||||
(testing "boolean spec → :flag type"
|
||||
(let [token (gen/spec->token [:help {:coerce :boolean :desc "Show help"}])]
|
||||
(is (= :flag (:type token)))))
|
||||
(testing "spec with :values → :enum type"
|
||||
(let [token (gen/spec->token [:output {:values ["human" "json" "edn"] :desc "Format"}])]
|
||||
(is (= :enum (:type token)))
|
||||
(is (= ["human" "json" "edn"] (:values token)))))
|
||||
(testing "spec with :complete :graphs → :dynamic type"
|
||||
(let [token (gen/spec->token [:graph {:complete :graphs :desc "Graph name"}])]
|
||||
(is (= :dynamic (:type token)))
|
||||
(is (= :graphs (:complete token)))))
|
||||
(testing "spec with :complete :file → :file type"
|
||||
(let [token (gen/spec->token [:config {:complete :file :desc "Config"}])]
|
||||
(is (= :file (:type token)))))
|
||||
(testing "spec with :complete :dir → :dir type"
|
||||
(let [token (gen/spec->token [:data-dir {:complete :dir :desc "Data dir"}])]
|
||||
(is (= :dir (:type token)))))
|
||||
(testing "spec with :alias → includes alias"
|
||||
(let [token (gen/spec->token [:help {:alias :h :coerce :boolean :desc "Help"}])]
|
||||
(is (= :h (:alias token)))))
|
||||
(testing "bare string spec → :free type"
|
||||
(let [token (gen/spec->token [:query {:desc "Query EDN"}])]
|
||||
(is (= :free (:type token))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Phase 3 — Zsh output
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest test-generate-zsh-structure
|
||||
(let [output (gen/generate-completions "zsh" full-table)]
|
||||
(testing "output starts with #compdef logseq"
|
||||
(is (string/starts-with? output "#compdef logseq")))
|
||||
(testing "output contains dynamic helpers"
|
||||
(is (string/includes? output "_logseq_graphs"))
|
||||
(is (string/includes? output "_logseq_pages"))
|
||||
(is (string/includes? output "_logseq_queries"))
|
||||
(is (string/includes? output "_logseq_json_names"))
|
||||
(is (string/includes? output "_logseq_current_graph")))
|
||||
(testing "output contains per-command functions"
|
||||
(is (string/includes? output "_logseq_graph_export()"))
|
||||
(is (string/includes? output "_logseq_show()")))
|
||||
(testing "output contains group dispatchers"
|
||||
(is (string/includes? output "_logseq_graph()"))
|
||||
(is (string/includes? output "_logseq_list()"))
|
||||
(is (string/includes? output "_logseq_upsert()")))
|
||||
(testing "output contains top-level dispatcher"
|
||||
(is (string/includes? output "_logseq()")))
|
||||
(testing "output ends with _logseq \"$@\""
|
||||
(is (string/includes? output "_logseq \"$@\"")))
|
||||
(testing "boolean flags emit bare flag form"
|
||||
(is (re-find #"--verbose\[" output)))
|
||||
(testing "enum options emit value list form"
|
||||
(is (re-find #"--output=.*\(human json edn\)" output)))
|
||||
(testing ":complete :graphs emits _logseq_graphs"
|
||||
(is (re-find #"--graph=.*_logseq_graphs" output)))
|
||||
(testing ":complete :file emits _files"
|
||||
(is (re-find #"--config=.*_files'" output)))
|
||||
(testing ":alias emits grouping"
|
||||
(is (re-find #"\(-h --help\)" output)))))
|
||||
|
||||
(deftest test-zsh-command-specific-values
|
||||
(let [output (gen/generate-completions "zsh" full-table)]
|
||||
(testing "--pos under upsert block offers correct values"
|
||||
(is (re-find #"--pos=.*\(first-child last-child sibling\)" output)))
|
||||
(testing "--sort for list page offers correct values"
|
||||
(is (re-find #"--sort=.*\(title created-at updated-at\)" output)))
|
||||
(testing "--sort for list tag offers name title"
|
||||
;; The list tag function should contain (name title)
|
||||
(let [tag-section (second (re-find #"_logseq_list_tag\(\).*?(?=\n_logseq)" output))]
|
||||
;; Just check globally that name title appears in sort context
|
||||
(is (re-find #"\(name title\)" output))))))
|
||||
|
||||
(deftest test-zsh-all-commands-present
|
||||
(let [output (gen/generate-completions "zsh" full-table)]
|
||||
(testing "every command from the table appears"
|
||||
(doseq [entry full-table]
|
||||
(let [func-name (str "_logseq_" (string/join "_" (:cmds entry)))]
|
||||
(is (string/includes? output (str func-name "()"))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Phase 4 — Bash output
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest test-generate-bash-structure
|
||||
(let [output (gen/generate-completions "bash" full-table)]
|
||||
(testing "output contains dynamic helpers"
|
||||
(is (string/includes? output "_logseq_graphs_bash"))
|
||||
(is (string/includes? output "_logseq_pages_bash"))
|
||||
(is (string/includes? output "_logseq_queries_bash"))
|
||||
(is (string/includes? output "_logseq_compadd_lines"))
|
||||
(is (string/includes? output "_logseq_json_names_bash"))
|
||||
(is (string/includes? output "_logseq_current_graph_bash")))
|
||||
(testing "output contains _logseq_opts_for"
|
||||
(is (string/includes? output "_logseq_opts_for()")))
|
||||
(testing "output contains _logseq_is_value_opt"
|
||||
(is (string/includes? output "_logseq_is_value_opt()")))
|
||||
(testing "output ends with complete -F _logseq logseq"
|
||||
(is (string/includes? output "complete -F _logseq logseq")))
|
||||
(testing "graph export case includes --type and --file"
|
||||
(is (string/includes? output "--type"))
|
||||
(is (string/includes? output "--file")))
|
||||
(testing "boolean flags appear in wordlist"
|
||||
(is (string/includes? output "--verbose")))
|
||||
(testing "enum values use compgen -W"
|
||||
(is (re-find #"compgen -W.*human json edn" output)))
|
||||
(testing ":complete :file uses compgen -f"
|
||||
(is (re-find #"compgen -f" output)))))
|
||||
|
||||
(deftest test-bash-all-commands-present
|
||||
(let [output (gen/generate-completions "bash" full-table)]
|
||||
(testing "every top-level command appears in subcommand completion"
|
||||
(doseq [group-name (distinct (map #(first (:cmds %)) full-table))]
|
||||
(is (string/includes? output group-name)
|
||||
(str "missing command: " group-name))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Phase 5 — Completions command entry
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest test-completions-command-entry
|
||||
(let [entries completions-command/entries]
|
||||
(testing "contains one entry with :cmds [\"completions\"]"
|
||||
(is (= 1 (count entries)))
|
||||
(is (= ["completions"] (:cmds (first entries)))))
|
||||
(testing "command is :completions"
|
||||
(is (= :completions (:command (first entries)))))
|
||||
(testing "spec has :shell with :values"
|
||||
(is (= ["zsh" "bash"]
|
||||
(get-in (first entries) [:spec :shell :values]))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Phase 6 — End-to-end validation
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest test-e2e-zsh-structural-markers
|
||||
(let [output (gen/generate-completions "zsh" full-table)]
|
||||
(testing "key structural markers present"
|
||||
(is (string/includes? output "#compdef"))
|
||||
(is (string/includes? output "_logseq_graph_export"))
|
||||
(is (string/includes? output "_logseq_show"))
|
||||
(is (string/includes? output "_logseq \"$@\"")))))
|
||||
|
||||
(deftest test-e2e-bash-structural-markers
|
||||
(let [output (gen/generate-completions "bash" full-table)]
|
||||
(testing "key structural markers present"
|
||||
(is (string/includes? output "complete -F _logseq logseq"))
|
||||
(is (string/includes? output "_logseq_opts_for")))))
|
||||
|
||||
(deftest test-e2e-sync-adding-command
|
||||
(testing "adding a command updates output"
|
||||
(let [base-output (gen/generate-completions "zsh" full-table)
|
||||
fake-entry (core/command-entry ["fake"] :fake "Fake command"
|
||||
{:foo {:desc "Foo option"}})
|
||||
extended-table (conj full-table fake-entry)
|
||||
new-output (gen/generate-completions "zsh" extended-table)]
|
||||
(is (not (string/includes? base-output "_logseq_fake()")))
|
||||
(is (string/includes? new-output "_logseq_fake()")))))
|
||||
|
||||
(deftest test-e2e-context-dependent-name
|
||||
(let [entries full-table]
|
||||
(testing "query spec has :name with :complete :queries"
|
||||
(let [query-entry (first (filter #(= :query (:command %)) entries))]
|
||||
(is (= :queries (get-in query-entry [:spec :name :complete])))))
|
||||
(testing "remove page spec has :name with :complete :pages"
|
||||
(let [rm-page (first (filter #(= :remove-page (:command %)) entries))]
|
||||
(is (= :pages (get-in rm-page [:spec :name :complete])))))
|
||||
(testing "upsert tag spec does NOT have :complete on :name"
|
||||
(let [tag (first (filter #(= :upsert-tag (:command %)) entries))]
|
||||
(is (nil? (get-in tag [:spec :name :complete])))))
|
||||
(testing "remove tag spec does NOT have :complete on :name"
|
||||
(let [tag (first (filter #(= :remove-tag (:command %)) entries))]
|
||||
(is (nil? (get-in tag [:spec :name :complete])))))))
|
||||
|
||||
(deftest test-e2e-generated-header
|
||||
(testing "zsh output includes do-not-edit header"
|
||||
(let [output (gen/generate-completions "zsh" full-table)]
|
||||
(is (string/includes? output "do not edit manually"))))
|
||||
(testing "bash output includes do-not-edit header"
|
||||
(let [output (gen/generate-completions "bash" full-table)]
|
||||
(is (string/includes? output "do not edit manually")))))
|
||||
Reference in New Issue
Block a user