pyworklog 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
worklog/completion.py ADDED
@@ -0,0 +1,624 @@
1
+ """Shell completion generation (fish / bash / zsh).
2
+
3
+ argparse is the source of truth: each generator walks `build_parser()`
4
+ and emits a completion script tailored to that shell. The scripts run
5
+ under init-load (`wl print-completion fish | source` in shell rc), so
6
+ new subcommands / flags propagate automatically.
7
+
8
+ Dynamic completion (node id / tag / date) uses SQLite directly via
9
+ helper functions inlined into each script — no Python startup, sub-50ms
10
+ Tab response. The helpers know the DB resolution order ($WORKLOG_DB,
11
+ then $XDG_DATA_HOME/worklog/worklog.db).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import re
17
+ import sys
18
+
19
+
20
+ _FISH_HELPERS = {
21
+ # (sub_cmd, opt_name) → fish completion source
22
+ # opt_name None = positional argument
23
+ ("__any__", "--parent"): "(__wl_list_nodes)",
24
+ ("__any__", "--root"): "(__wl_list_nodes)",
25
+ ("__any__", "--id"): "(__wl_list_nodes)",
26
+ ("__any__", "--node"): "(__wl_list_nodes)",
27
+ ("__any__", "--ids"): "(__wl_list_nodes)",
28
+ ("__any__", "--tag"): "(__wl_list_tags)",
29
+ ("sched", "--recur"): "(__wl_recur_suggestions)",
30
+ # time / date related -> date suggestions
31
+ ("log", "--date"): "(__wl_date_suggestions)",
32
+ ("logs", "--date"): "(__wl_date_suggestions)",
33
+ ("unlog", "--date"): "(__wl_date_suggestions)",
34
+ ("dateinfo", "date"): "(__wl_date_suggestions)",
35
+ ("dateinfo", None): "(__wl_date_suggestions)",
36
+ ("day", "date"): "(__wl_date_suggestions)",
37
+ ("sched", "when"): "(__wl_date_suggestions)",
38
+ ("defer", "date"): "(__wl_date_suggestions)",
39
+ }
40
+ # subcommands whose default positional argument takes a node id (when not explicitly specified)
41
+
42
+ _FISH_POSITIONAL_NODE = {"log", "done", "defer", "start", "stop", "wait", "reopen",
43
+ "cancel", "tick", "link", "set", "show", "focus", "ancestors",
44
+ "descendants", "spent", "unlog", "relog"}
45
+
46
+ _FISH_HELPER_FUNCTIONS = r"""# --- helper functions (dynamic queries against worklog.db; no Python startup, fast) ---
47
+ function __wl_db_path
48
+ # $WORKLOG_DB env, else $XDG_DATA_HOME/worklog/worklog.db (default ~/.local/share/worklog/worklog.db)
49
+ if set -q WORKLOG_DB
50
+ echo $WORKLOG_DB
51
+ else if set -q XDG_DATA_HOME
52
+ echo $XDG_DATA_HOME/worklog/worklog.db
53
+ else
54
+ echo $HOME/.local/share/worklog/worklog.db
55
+ end
56
+ end
57
+
58
+ function __wl_list_nodes
59
+ set -l db (__wl_db_path)
60
+ test -f $db; or return
61
+ # SQLite char(9) = tab (fish completion uses \t to separate token + desc)
62
+ sqlite3 $db "SELECT id || char(9) || title FROM node WHERE status IS NULL OR status NOT IN ('DONE', 'CANCELED') ORDER BY id DESC LIMIT 80" 2>/dev/null
63
+ end
64
+
65
+ function __wl_list_tags
66
+ set -l db (__wl_db_path)
67
+ test -f $db; or return
68
+ sqlite3 $db "SELECT DISTINCT tag FROM tag ORDER BY tag" 2>/dev/null
69
+ end
70
+
71
+ function __wl_date_suggestions
72
+ printf 'today\ttoday\nyesterday\tyesterday\nday-before-yesterday\tday before yesterday\ntomorrow\ttomorrow\nday-after-tomorrow\tday after tomorrow\n'
73
+ printf 'someday\tno specific time\n'
74
+ set -l today (date +%Y-%m-%d)
75
+ printf '%s\ttoday YYYY-MM-DD\n' $today
76
+ end
77
+
78
+ function __wl_recur_suggestions
79
+ printf 'daily\tevery day\n'
80
+ printf 'weekly:Mon,Wed,Fri\tMon/Wed/Fri\n'
81
+ printf 'weekly:Sat,Sun\tweekends\n'
82
+ printf 'weekly:-1\tevery Sunday (last day)\n'
83
+ printf 'monthly:1\t1st of every month\n'
84
+ printf 'monthly:15\t15th of every month\n'
85
+ printf 'monthly:-1\tlast day of every month\n'
86
+ printf 'quarterly:1-1\tfirst day of every quarter\n'
87
+ printf 'quarterly:-1\tlast day of every quarter\n'
88
+ printf 'yearly:01-01\tJan 1 every year\n'
89
+ printf 'yearly:-1\tlast day of year (12-31)\n'
90
+ end
91
+ """
92
+
93
+ def _completion_iter_actions(parser):
94
+ """yield action; skip help / version / dest=cmd / subparsers"""
95
+ for a in parser._actions:
96
+ if isinstance(a, (argparse._HelpAction, argparse._VersionAction)):
97
+ continue
98
+ if isinstance(a, argparse._SubParsersAction):
99
+ continue
100
+ yield a
101
+
102
+ def _fish_escape(s):
103
+ """fish string escape: wrap in single quotes; inner single quote becomes \\'"""
104
+ if s is None:
105
+ return ""
106
+ return s.replace("\\", "\\\\").replace("'", "\\'")
107
+
108
+
109
+ def _fish_one_complete(prefix, action, sub_cmd=None):
110
+ """Emit one fish complete line for a single action. prefix is the leading text of the complete line (with -c wl -n ...)."""
111
+ lines = []
112
+ descr = (action.help or "").split("\n")[0].strip()
113
+ # short / long options
114
+ short = []
115
+ long_ = []
116
+ for o in action.option_strings:
117
+ (long_ if o.startswith("--") else short).append(o.lstrip("-"))
118
+ opt_parts = []
119
+ for s in short:
120
+ opt_parts.append(f"-s {s}")
121
+ for l in long_:
122
+ opt_parts.append(f"-l {l}")
123
+ opt_str = " ".join(opt_parts)
124
+ if not opt_str:
125
+ return [] # no short/long = positional; handled by caller
126
+
127
+ # value-taking options disable filename completion (-x); store_true / store_false take no value
128
+ takes_value = not isinstance(action, (argparse._StoreTrueAction, argparse._StoreFalseAction,
129
+ argparse._StoreConstAction, argparse._CountAction))
130
+ if takes_value:
131
+ opt_str += " -x"
132
+
133
+ line = f"{prefix} {opt_str}"
134
+ if descr:
135
+ line += f' -d "{_fish_escape(descr)}"'
136
+
137
+ # value-completion source: choices > helper map > default (none)
138
+ val_src = None
139
+ if action.choices:
140
+ val_src = " ".join(str(c) for c in action.choices)
141
+ else:
142
+ # find helper: first (sub_cmd, --long), then (__any__, --long)
143
+ for opt in action.option_strings:
144
+ for key in [(sub_cmd, opt), ("__any__", opt)]:
145
+ if key in _FISH_HELPERS:
146
+ val_src = _FISH_HELPERS[key]
147
+ break
148
+ if val_src:
149
+ break
150
+ if val_src:
151
+ line += f' -a "{val_src}"'
152
+
153
+ lines.append(line)
154
+ return lines
155
+
156
+
157
+ def _fish_positional_complete(parser, sub_cmd):
158
+ """Positional argument completion for a subcommand (mostly node id / date)."""
159
+ lines = []
160
+ for a in parser._actions:
161
+ if a.option_strings or isinstance(a, (argparse._SubParsersAction,
162
+ argparse._HelpAction, argparse._VersionAction)):
163
+ continue
164
+ # positional. Look up dest -> helper
165
+ prefix = f'complete -c wl -n "__fish_seen_subcommand_from {sub_cmd}"'
166
+ val_src = None
167
+ # explicit helper
168
+ for key in [(sub_cmd, a.dest), (sub_cmd, None)]:
169
+ if key in _FISH_HELPERS:
170
+ val_src = _FISH_HELPERS[key]
171
+ break
172
+ # default: subcommand in node-id operation set -> __wl_list_nodes
173
+ if val_src is None and sub_cmd in _FISH_POSITIONAL_NODE:
174
+ val_src = "(__wl_list_nodes)"
175
+ if val_src is None and a.choices:
176
+ val_src = " ".join(str(c) for c in a.choices)
177
+ if val_src:
178
+ descr = (a.help or "").split("\n")[0].strip()
179
+ line = f"{prefix} -f -a \"{val_src}\""
180
+ if descr:
181
+ line += f' -d "{_fish_escape(descr)}"'
182
+ lines.append(line)
183
+ return lines
184
+
185
+ def _generate_fish_completion(parser):
186
+ """Walk build_parser() to produce full fish completion. argparse is the source of truth."""
187
+ lines = [
188
+ "# wl fish completion (auto-generated by `wl --print-completion fish`)",
189
+ "# Load: add `wl --print-completion fish | source` to ~/.config/fish/config.fish",
190
+ "",
191
+ "complete -c wl -f # disable filename completion by default",
192
+ "",
193
+ _FISH_HELPER_FUNCTIONS,
194
+ "# --- global args ---",
195
+ ]
196
+ # global (top-level parser) actions
197
+ for a in _completion_iter_actions(parser):
198
+ lines += _fish_one_complete('complete -c wl', a, sub_cmd=None)
199
+
200
+ # subcommands
201
+ subparsers_action = next((x for x in parser._actions
202
+ if isinstance(x, argparse._SubParsersAction)), None)
203
+ if subparsers_action is None:
204
+ return "\n".join(lines) + "\n"
205
+
206
+ # use _collect_sub_meta to get (name, help, sub, aliases)
207
+ sub_metas = _collect_sub_meta(parser)
208
+ lines.append("")
209
+ lines.append("# --- subcommand names (+ aliases) ---")
210
+ for name, descr, _sub, aliases in sub_metas:
211
+ descr_part = f' -d "{_fish_escape(descr)}"' if descr else ""
212
+ lines.append(f'complete -c wl -n "__fish_use_subcommand" -a "{name}"{descr_part}')
213
+ for alias in aliases:
214
+ alias_descr = f"{descr} (= {name})" if descr else f"alias of {name}"
215
+ lines.append(f'complete -c wl -n "__fish_use_subcommand" -a "{alias}"'
216
+ f' -d "{_fish_escape(alias_descr)}"')
217
+
218
+ # per-subcommand arguments -- condition includes the primary name + all aliases
219
+ lines.append("")
220
+ lines.append("# --- per-subcommand arguments ---")
221
+ for name, _descr, sub, aliases in sub_metas:
222
+ all_names = " ".join([name] + aliases)
223
+ cond = f'__fish_seen_subcommand_from {all_names}'
224
+ prefix = f'complete -c wl -n "{cond}"'
225
+ section = [f"\n# {name}"]
226
+ for a in _completion_iter_actions(sub):
227
+ section += _fish_one_complete(prefix, a, sub_cmd=name)
228
+ section += _fish_positional_complete(sub, name)
229
+ if len(section) > 1:
230
+ lines += section
231
+
232
+ return "\n".join(lines) + "\n"
233
+
234
+ _BASH_HELPER_FUNCTIONS = r"""# helper functions (local SQLite query against worklog.db; no Python startup)
235
+ __wl_db_path_bash() {
236
+ # $WORKLOG_DB env, else $XDG_DATA_HOME/worklog/worklog.db (default ~/.local/share/worklog/worklog.db)
237
+ if [ -n "$WORKLOG_DB" ]; then
238
+ echo "$WORKLOG_DB"
239
+ else
240
+ echo "${XDG_DATA_HOME:-$HOME/.local/share}/worklog/worklog.db"
241
+ fi
242
+ }
243
+
244
+ __wl_list_nodes_bash() {
245
+ local db=$(__wl_db_path_bash)
246
+ [ -f "$db" ] || return
247
+ sqlite3 "$db" "SELECT id FROM node WHERE status IS NULL OR status NOT IN ('DONE', 'CANCELED') ORDER BY id DESC LIMIT 80" 2>/dev/null
248
+ }
249
+
250
+ __wl_list_tags_bash() {
251
+ local db=$(__wl_db_path_bash)
252
+ [ -f "$db" ] || return
253
+ sqlite3 "$db" "SELECT DISTINCT tag FROM tag ORDER BY tag" 2>/dev/null
254
+ }
255
+
256
+ __wl_date_suggestions_bash() {
257
+ echo "today yesterday day-before-yesterday tomorrow day-after-tomorrow someday $(date +%Y-%m-%d)"
258
+ }
259
+
260
+ __wl_recur_suggestions_bash() {
261
+ echo "daily weekly:Mon,Wed,Fri weekly:Sat,Sun weekly:-1 monthly:1 monthly:15 monthly:-1 quarterly:1-1 quarterly:-1 yearly:01-01 yearly:-1"
262
+ }
263
+ """
264
+
265
+ # subcommand / argument -> bash helper function name (outputs token list, consumed by compgen -W)
266
+
267
+ _BASH_DYN_HELPERS = {
268
+ ("__any__", "--parent"): "__wl_list_nodes_bash",
269
+ ("__any__", "--root"): "__wl_list_nodes_bash",
270
+ ("__any__", "--id"): "__wl_list_nodes_bash",
271
+ ("__any__", "--node"): "__wl_list_nodes_bash",
272
+ ("__any__", "--ids"): "__wl_list_nodes_bash",
273
+ ("__any__", "--tag"): "__wl_list_tags_bash",
274
+ ("sched", "--recur"): "__wl_recur_suggestions_bash",
275
+ ("log", "--date"): "__wl_date_suggestions_bash",
276
+ ("logs", "--date"): "__wl_date_suggestions_bash",
277
+ ("unlog", "--date"): "__wl_date_suggestions_bash",
278
+ }
279
+
280
+ def _collect_sub_meta(parser):
281
+ """Return [(sub_name, sub_help, sub_parser, [aliases])].
282
+ aliases are all alias names of the sub (excluding the primary name); the primary sub matches via choices key against _choices_actions; aliases point to the same parser object."""
283
+ subparsers_action = next((x for x in parser._actions
284
+ if isinstance(x, argparse._SubParsersAction)), None)
285
+ if not subparsers_action:
286
+ return []
287
+ # reverse map: parser obj id -> list of names
288
+ parser_to_names = {}
289
+ for name, sub_p in subparsers_action.choices.items():
290
+ parser_to_names.setdefault(id(sub_p), []).append(name)
291
+ # primary names: those in _choices_actions with help text
292
+ primary_names = set()
293
+ if subparsers_action._choices_actions:
294
+ for c in subparsers_action._choices_actions:
295
+ primary_names.add(c.dest)
296
+ result = []
297
+ seen = set()
298
+ for name, sub in subparsers_action.choices.items():
299
+ if id(sub) in seen:
300
+ continue
301
+ if name not in primary_names:
302
+ # this is an alias; skip for now, collected together when the primary name appears
303
+ continue
304
+ seen.add(id(sub))
305
+ help_text = ""
306
+ if subparsers_action._choices_actions:
307
+ for c in subparsers_action._choices_actions:
308
+ if c.dest == name:
309
+ help_text = c.help or ""
310
+ break
311
+ aliases = [n for n in parser_to_names[id(sub)] if n != name]
312
+ result.append((name, (help_text or "").split("\n")[0].strip(), sub, aliases))
313
+ return result
314
+
315
+
316
+ def _sub_options(sub_parser):
317
+ """List of all --long / -short options for a subcommand."""
318
+ opts = []
319
+ for a in _completion_iter_actions(sub_parser):
320
+ for o in a.option_strings:
321
+ opts.append(o)
322
+ return opts
323
+
324
+ def _generate_bash_completion(parser):
325
+ """argparse → bash _wl() function + complete -F _wl wl."""
326
+ sub_metas = _collect_sub_meta(parser)
327
+ # subcmds list includes primary names + aliases
328
+ all_sub_names = []
329
+ for name, _, _, aliases in sub_metas:
330
+ all_sub_names.append(name)
331
+ all_sub_names.extend(aliases)
332
+ sub_names = " ".join(all_sub_names)
333
+
334
+ # global flags (top-level parser)
335
+ global_opts = []
336
+ for a in _completion_iter_actions(parser):
337
+ global_opts.extend(a.option_strings)
338
+ global_opts_str = " ".join(global_opts)
339
+
340
+ lines = [
341
+ "# wl bash completion (auto-generated by `wl print-completion bash`)",
342
+ "# Load: add `eval \"$(wl print-completion bash)\"` to ~/.bashrc",
343
+ "",
344
+ _BASH_HELPER_FUNCTIONS,
345
+ "_wl() {",
346
+ ' local cur="${COMP_WORDS[COMP_CWORD]}"',
347
+ ' local prev="${COMP_WORDS[COMP_CWORD-1]}"',
348
+ "",
349
+ " # find current sub: first word not starting with -",
350
+ ' local sub=""',
351
+ " local i",
352
+ " for ((i=1; i<COMP_CWORD; i++)); do",
353
+ ' case "${COMP_WORDS[i]}" in',
354
+ " -*) ;;",
355
+ ' *) sub="${COMP_WORDS[i]}"; break ;;',
356
+ " esac",
357
+ " done",
358
+ "",
359
+ f' local global_opts="{global_opts_str}"',
360
+ f' local subcmds="{sub_names}"',
361
+ "",
362
+ ' if [ -z "$sub" ]; then',
363
+ ' if [[ "$cur" == -* ]]; then',
364
+ ' COMPREPLY=( $(compgen -W "$global_opts" -- "$cur") )',
365
+ " else",
366
+ ' COMPREPLY=( $(compgen -W "$subcmds" -- "$cur") )',
367
+ " fi",
368
+ " return",
369
+ " fi",
370
+ "",
371
+ ' case "$sub" in',
372
+ ]
373
+
374
+ for name, _, sub, aliases in sub_metas:
375
+ opts = _sub_options(sub)
376
+ opts_str = " ".join(opts)
377
+ # bash case pattern: name|alias1|alias2)
378
+ case_pattern = "|".join([name] + aliases)
379
+ case_lines = [f' {case_pattern})']
380
+ # when prev is a long option, look up its helper / choices
381
+ prev_cases = []
382
+ for a in _completion_iter_actions(sub):
383
+ if not a.option_strings:
384
+ continue
385
+ long_opts = [o for o in a.option_strings if o.startswith("--")]
386
+ if not long_opts:
387
+ continue
388
+ for opt in long_opts:
389
+ src = None
390
+ if a.choices:
391
+ src = " ".join(str(c) for c in a.choices)
392
+ else:
393
+ for key in [(name, opt), ("__any__", opt)]:
394
+ if key in _BASH_DYN_HELPERS:
395
+ src = f'$({_BASH_DYN_HELPERS[key]})'
396
+ break
397
+ if src:
398
+ prev_cases.append((opt, src))
399
+ if prev_cases:
400
+ case_lines.append(' case "$prev" in')
401
+ for opt, src in prev_cases:
402
+ if src.startswith("$("):
403
+ case_lines.append(f' {opt}) COMPREPLY=( $(compgen -W "{src}" -- "$cur") ); return ;;')
404
+ else:
405
+ case_lines.append(f' {opt}) COMPREPLY=( $(compgen -W "{src}" -- "$cur") ); return ;;')
406
+ case_lines.append(' esac')
407
+
408
+ case_lines.append(' if [[ "$cur" == -* ]]; then')
409
+ case_lines.append(f' COMPREPLY=( $(compgen -W "{opts_str} $global_opts" -- "$cur") )')
410
+ case_lines.append(' else')
411
+ # positional: when the subcommand operates on node ids -> __wl_list_nodes_bash
412
+ if name in _FISH_POSITIONAL_NODE:
413
+ case_lines.append(f' COMPREPLY=( $(compgen -W "$(__wl_list_nodes_bash)" -- "$cur") )')
414
+ else:
415
+ case_lines.append(' :')
416
+ case_lines.append(' fi')
417
+ case_lines.append(' ;;')
418
+ lines.extend(case_lines)
419
+
420
+ lines.append(' esac')
421
+ lines.append('}')
422
+ lines.append('complete -F _wl wl')
423
+ return "\n".join(lines) + "\n"
424
+
425
+ _ZSH_HELPER_FUNCTIONS = r"""# helper functions (local SQLite query against worklog.db; no Python startup)
426
+ __wl_db_path_zsh() {
427
+ # $WORKLOG_DB env, else $XDG_DATA_HOME/worklog/worklog.db (default ~/.local/share/worklog/worklog.db)
428
+ if [ -n "$WORKLOG_DB" ]; then
429
+ echo "$WORKLOG_DB"
430
+ else
431
+ echo "${XDG_DATA_HOME:-$HOME/.local/share}/worklog/worklog.db"
432
+ fi
433
+ }
434
+
435
+ __wl_list_nodes_zsh() {
436
+ local db=$(__wl_db_path_zsh)
437
+ [ -f "$db" ] || return
438
+ local -a nodes
439
+ nodes=( "${(@f)$(sqlite3 "$db" "SELECT id || ':' || replace(title, ':', '\\:') FROM node WHERE status IS NULL OR status NOT IN ('DONE', 'CANCELED') ORDER BY id DESC LIMIT 80" 2>/dev/null)}" )
440
+ _describe 'node' nodes
441
+ }
442
+
443
+ __wl_list_tags_zsh() {
444
+ local db=$(__wl_db_path_zsh)
445
+ [ -f "$db" ] || return
446
+ local -a tags
447
+ tags=( "${(@f)$(sqlite3 "$db" "SELECT DISTINCT tag FROM tag ORDER BY tag" 2>/dev/null)}" )
448
+ _values 'tag' $tags
449
+ }
450
+
451
+ __wl_date_suggestions_zsh() {
452
+ local today=$(date +%Y-%m-%d)
453
+ _describe 'date' \
454
+ "today:today" "yesterday:yesterday" "day-before-yesterday:day before yesterday" "tomorrow:tomorrow" "day-after-tomorrow:day after tomorrow" \
455
+ "someday:no specific time" "$today:today YYYY-MM-DD"
456
+ }
457
+
458
+ __wl_recur_suggestions_zsh() {
459
+ _describe 'recur' \
460
+ "daily:every day" \
461
+ "weekly\\:Mon,Wed,Fri:Mon/Wed/Fri" \
462
+ "weekly\\:Sat,Sun:weekends" \
463
+ "weekly\\:-1:every Sunday (last day)" \
464
+ "monthly\\:1:1st of every month" \
465
+ "monthly\\:15:15th of every month" \
466
+ "monthly\\:-1:last day of every month" \
467
+ "quarterly\\:1-1:first day of every quarter" \
468
+ "quarterly\\:-1:last day of every quarter" \
469
+ "yearly\\:01-01:Jan 1 every year" \
470
+ "yearly\\:-1:last day of year (12-31)"
471
+ }
472
+ """
473
+
474
+ def _zsh_escape(s):
475
+ """zsh string escape: backticks / square brackets / single + double quotes"""
476
+ if s is None:
477
+ return ""
478
+ return s.replace("\\", "\\\\").replace("'", "''").replace("[", "\\[").replace("]", "\\]").replace(":", "\\:")
479
+
480
+
481
+
482
+ _ZSH_DYN_HELPERS = {
483
+ ("__any__", "--parent"): "__wl_list_nodes_zsh",
484
+ ("__any__", "--root"): "__wl_list_nodes_zsh",
485
+ ("__any__", "--id"): "__wl_list_nodes_zsh",
486
+ ("__any__", "--node"): "__wl_list_nodes_zsh",
487
+ ("__any__", "--ids"): "__wl_list_nodes_zsh",
488
+ ("__any__", "--tag"): "__wl_list_tags_zsh",
489
+ ("sched", "--recur"): "__wl_recur_suggestions_zsh",
490
+ ("log", "--date"): "__wl_date_suggestions_zsh",
491
+ ("logs", "--date"): "__wl_date_suggestions_zsh",
492
+ ("unlog", "--date"): "__wl_date_suggestions_zsh",
493
+ }
494
+
495
+ def _zsh_arg_spec(action, sub_cmd):
496
+ """For a single action, produce the _arguments spec string. None means positional (handled separately)."""
497
+ if not action.option_strings:
498
+ return None # positional
499
+ descr = (action.help or "").split("\n")[0].strip()
500
+ descr_part = f"[{_zsh_escape(descr)}]" if descr else ""
501
+
502
+ takes_value = not isinstance(action, (argparse._StoreTrueAction, argparse._StoreFalseAction,
503
+ argparse._StoreConstAction, argparse._CountAction))
504
+
505
+ val_part = ""
506
+ if takes_value:
507
+ # value-completion source
508
+ val_src = None
509
+ if action.choices:
510
+ val_src = "(" + " ".join(str(c) for c in action.choices) + ")"
511
+ else:
512
+ for opt in action.option_strings:
513
+ for key in [(sub_cmd, opt), ("__any__", opt)]:
514
+ if key in _ZSH_DYN_HELPERS:
515
+ val_src = _ZSH_DYN_HELPERS[key]
516
+ break
517
+ if val_src:
518
+ break
519
+ if val_src:
520
+ val_part = f": :{val_src}" if val_src.startswith("__") else f": :{val_src}"
521
+ else:
522
+ val_part = ": :"
523
+
524
+ # multiple option strings (e.g. -q --brief): zsh uses {-q,--brief} form
525
+ opts = action.option_strings
526
+ if len(opts) == 1:
527
+ return f"'{opts[0]}{descr_part}{val_part}'"
528
+ elif len(opts) == 2:
529
+ return "'(" + " ".join(opts) + ")'{" + ",".join(opts) + "}'" + descr_part + val_part + "'"
530
+ else:
531
+ # > 2 options: one entry per option
532
+ return " ".join(f"'{o}{descr_part}{val_part}'" for o in opts)
533
+
534
+ def _generate_zsh_completion(parser):
535
+ """argparse → zsh _wl() function + compdef _wl wl."""
536
+ sub_metas = _collect_sub_meta(parser)
537
+
538
+ lines = [
539
+ "#compdef wl",
540
+ "# wl zsh completion (auto-generated by `wl print-completion zsh`)",
541
+ "# Load: add `eval \"$(wl print-completion zsh)\"` to ~/.zshrc",
542
+ "",
543
+ _ZSH_HELPER_FUNCTIONS,
544
+ "_wl() {",
545
+ " local context state line",
546
+ " typeset -A opt_args",
547
+ "",
548
+ " _arguments -C \\",
549
+ ]
550
+
551
+ # global args
552
+ global_specs = []
553
+ for a in _completion_iter_actions(parser):
554
+ spec = _zsh_arg_spec(a, sub_cmd=None)
555
+ if spec:
556
+ global_specs.append(spec)
557
+ for spec in global_specs:
558
+ lines.append(f" {spec} \\")
559
+ lines.append(" '1: :->cmds' \\")
560
+ lines.append(" '*::arg:->args'")
561
+ lines.append("")
562
+ lines.append(' case "$state" in')
563
+ lines.append(' cmds)')
564
+ lines.append(' local -a subcmds')
565
+ lines.append(' subcmds=(')
566
+ for name, descr, _, aliases in sub_metas:
567
+ descr_safe = _zsh_escape(descr)
568
+ lines.append(f" '{name}:{descr_safe}'")
569
+ for alias in aliases:
570
+ alias_descr = _zsh_escape(f"{descr} (= {name})" if descr else f"alias of {name}")
571
+ lines.append(f" '{alias}:{alias_descr}'")
572
+ lines.append(' )')
573
+ lines.append(" _describe 'subcommand' subcmds")
574
+ lines.append(' ;;')
575
+ lines.append(' args)')
576
+ lines.append(' case $line[1] in')
577
+
578
+ for name, _, sub, aliases in sub_metas:
579
+ # zsh case pattern: name|alias1|alias2)
580
+ case_pattern = "|".join([name] + aliases)
581
+ lines.append(f' {case_pattern})')
582
+ lines.append(' _arguments \\')
583
+ sub_specs = []
584
+ for a in _completion_iter_actions(sub):
585
+ spec = _zsh_arg_spec(a, sub_cmd=name)
586
+ if spec:
587
+ sub_specs.append(spec)
588
+ # positional (single positional taking a node id)
589
+ positional_helper = None
590
+ if name in _FISH_POSITIONAL_NODE:
591
+ positional_helper = "__wl_list_nodes_zsh"
592
+ for i, spec in enumerate(sub_specs):
593
+ suffix = " \\" if (i < len(sub_specs) - 1 or positional_helper) else ""
594
+ lines.append(f" {spec}{suffix}")
595
+ if positional_helper:
596
+ lines.append(f" '*: :{positional_helper}'")
597
+ lines.append(' ;;')
598
+
599
+ lines.append(' esac')
600
+ lines.append(' ;;')
601
+ lines.append(' esac')
602
+ lines.append('}')
603
+ lines.append('compdef _wl wl')
604
+ return "\n".join(lines) + "\n"
605
+
606
+ def cmd_print_completion(args, con=None):
607
+ """Dump shell completion script. See per-shell header for how to load.
608
+
609
+ fish: add `wl print-completion fish | source` to ~/.config/fish/config.fish
610
+ bash: add `eval "$(wl print-completion bash)"` to ~/.bashrc
611
+ zsh: add `eval "$(wl print-completion zsh)"` to ~/.zshrc
612
+ """
613
+ from .cli import build_parser # lazy import — cli imports completion, so this side must be deferred
614
+ shell = args.shell
615
+ parser = build_parser()
616
+ if shell == "fish":
617
+ sys.stdout.write(_generate_fish_completion(parser))
618
+ elif shell == "bash":
619
+ sys.stdout.write(_generate_bash_completion(parser))
620
+ elif shell == "zsh":
621
+ sys.stdout.write(_generate_zsh_completion(parser))
622
+ else:
623
+ sys.exit(f"✗ shell '{shell}' not supported (fish / bash / zsh)")
624
+