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:
Danzu
2026-03-05 16:11:25 -06:00
committed by rcmerci
parent a89ffb6b0f
commit 051f6381e3
17 changed files with 3388 additions and 25 deletions

View 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)])

View File

@@ -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)])))]

View File

@@ -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"

View File

@@ -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}

View File

@@ -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

View File

@@ -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"

View File

@@ -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)"

View File

@@ -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"

View File

@@ -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)

View 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}))))

View 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]))))))

View 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")))))