Skip to content

Commit cf19980

Browse files
authored
Merge pull request #73 from skshetry/zsh-subcommand
2 parents c7a390a + 6a97506 commit cf19980

File tree

2 files changed

+131
-81
lines changed

2 files changed

+131
-81
lines changed

shtab/__init__.py

Lines changed: 128 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
)
1717
from collections import defaultdict
1818
from functools import total_ordering
19+
from itertools import starmap
1920
from string import Template
2021
from typing import Any, Dict, List
2122
from typing import Optional as Opt
@@ -452,9 +453,8 @@ def complete_zsh(parser, root_prefix=None, preamble="", choice_functions=None):
452453
453454
See `complete` for arguments.
454455
"""
455-
root_prefix = wordify("_shtab_" + (root_prefix or parser.prog))
456-
root_arguments = []
457-
subcommands = {} # {cmd: {"help": help, "arguments": [arguments]}}
456+
prog = parser.prog
457+
root_prefix = wordify("_shtab_" + (root_prefix or prog))
458458

459459
choice_type2fn = {k: v["zsh"] for k, v in CHOICE_FUNCTIONS.items()}
460460
if choice_functions:
@@ -486,47 +486,123 @@ def format_positional(opt):
486486
"({})".format(" ".join(map(str, opt.choices)))) if opt.choices else "",
487487
)
488488

489-
for sub in parser._get_positional_actions():
490-
if not sub.choices or not isinstance(sub.choices, dict):
491-
# positional argument
492-
opt = sub
493-
if opt.help != SUPPRESS:
494-
root_arguments.append(format_positional(opt))
495-
else: # subparser
496-
log.debug("choices:{}:{}".format(root_prefix, sorted(sub.choices)))
497-
public_cmds = get_public_subcommands(sub)
498-
for cmd, subparser in sub.choices.items():
499-
if cmd not in public_cmds:
500-
log.debug("skip:subcommand:%s", cmd)
501-
continue
502-
log.debug("subcommand:%s", cmd)
503-
504-
# optionals
505-
arguments = [
506-
format_optional(opt) for opt in subparser._get_optional_actions()
507-
if opt.help != SUPPRESS]
508-
509-
# subcommand positionals
510-
subsubs = sum(
511-
(list(opt.choices) for opt in subparser._get_positional_actions()
512-
if isinstance(opt.choices, dict)),
513-
[],
514-
)
515-
if subsubs:
516-
arguments.append('"1:Sub command:({})"'.format(" ".join(subsubs)))
517-
518-
# positionals
519-
arguments.extend(
520-
format_positional(opt) for opt in subparser._get_positional_actions()
521-
if not isinstance(opt.choices, dict) if opt.help != SUPPRESS)
522-
523-
subcommands[cmd] = {
524-
"help": (subparser.description or "").strip().split("\n")[0],
525-
"arguments": arguments}
526-
log.debug("subcommands:%s:%s", cmd, subcommands[cmd])
489+
# {cmd: {"help": help, "arguments": [arguments]}}
490+
all_commands = {
491+
root_prefix: {
492+
"cmd": prog, "arguments": [
493+
format_optional(opt) for opt in parser._get_optional_actions()
494+
if opt.help != SUPPRESS], "help": (parser.description
495+
or "").strip().split("\n")[0], "commands": [],
496+
"paths": []}}
497+
498+
def recurse(parser, prefix, paths=None):
499+
paths = paths or []
500+
subcmds = []
501+
for sub in parser._get_positional_actions():
502+
if sub.help == SUPPRESS or not sub.choices:
503+
continue
504+
if not sub.choices or not isinstance(sub.choices, dict):
505+
# positional argument
506+
all_commands[prefix]["arguments"].append(format_positional(sub))
507+
else: # subparser
508+
log.debug("choices:{}:{}".format(prefix, sorted(sub.choices)))
509+
public_cmds = get_public_subcommands(sub)
510+
for cmd, subparser in sub.choices.items():
511+
if cmd not in public_cmds:
512+
log.debug("skip:subcommand:%s", cmd)
513+
continue
514+
log.debug("subcommand:%s", cmd)
515+
516+
# optionals
517+
arguments = [
518+
format_optional(opt) for opt in subparser._get_optional_actions()
519+
if opt.help != SUPPRESS]
520+
521+
# positionals
522+
arguments.extend(
523+
format_positional(opt) for opt in subparser._get_positional_actions()
524+
if not isinstance(opt.choices, dict) if opt.help != SUPPRESS)
525+
526+
new_pref = prefix + "_" + wordify(cmd)
527+
options = all_commands[new_pref] = {
528+
"cmd": cmd, "help": (subparser.description or "").strip().split("\n")[0],
529+
"arguments": arguments, "paths": [*paths, cmd]}
530+
new_subcmds = recurse(subparser, new_pref, [*paths, cmd])
531+
options["commands"] = {
532+
all_commands[pref]["cmd"]: all_commands[pref]
533+
for pref in new_subcmds if pref in all_commands}
534+
subcmds.extend([*new_subcmds, new_pref])
535+
log.debug("subcommands:%s:%s", cmd, options)
536+
return subcmds
537+
538+
recurse(parser, root_prefix)
539+
all_commands[root_prefix]["commands"] = {
540+
options["cmd"]: options
541+
for prefix, options in sorted(all_commands.items())
542+
if len(options.get("paths", [])) < 2 and prefix != root_prefix}
543+
subcommands = {
544+
prefix: options
545+
for prefix, options in all_commands.items() if options.get("commands")}
546+
subcommands.setdefault(root_prefix, all_commands[root_prefix])
547+
log.debug("subcommands:%s:%s", root_prefix, sorted(all_commands))
548+
549+
def command_case(prefix, options):
550+
name = options["cmd"]
551+
commands = options["commands"]
552+
case_fmt_on_no_sub = """{name}) _arguments -C ${prefix}_{name}_options ;;"""
553+
case_fmt_on_sub = """{name}) {prefix}_{name} ;;"""
554+
555+
cases = []
556+
for _, options in sorted(commands.items()):
557+
fmt = case_fmt_on_sub if options.get("commands") else case_fmt_on_no_sub
558+
cases.append(fmt.format(name=options["cmd"], prefix=prefix))
559+
cases = "\n\t".expandtabs(8).join(cases)
560+
561+
return """\
562+
{prefix}() {{
563+
local context state line curcontext="$curcontext"
564+
565+
_arguments -C ${prefix}_options \\
566+
': :{prefix}_commands' \\
567+
'*::: :->{name}'
568+
569+
case $state in
570+
{name})
571+
words=($line[1] "${{words[@]}}")
572+
(( CURRENT += 1 ))
573+
curcontext="${{curcontext%:*:*}}:{prefix}-$line[1]:"
574+
case $line[1] in
575+
{cases}
576+
esac
577+
esac
578+
}}
579+
""".format(prefix=prefix, name=name, cases=cases)
580+
581+
def command_option(prefix, options):
582+
return """\
583+
{prefix}_options=(
584+
{arguments}
585+
)
586+
""".format(prefix=prefix, arguments="\n ".join(options["arguments"]))
587+
588+
def command_list(prefix, options):
589+
name = " ".join([prog, *options["paths"]])
590+
commands = "\n ".join('"{}:{}"'.format(cmd, escape_zsh(opt["help"]))
591+
for cmd, opt in sorted(options["commands"].items()))
592+
return """
593+
{prefix}_commands() {{
594+
local _commands=(
595+
{commands}
596+
)
597+
_describe '{name} commands' _commands
598+
}}""".format(prefix=prefix, name=name, commands=commands)
527599

528-
log.debug("subcommands:%s:%s", root_prefix, sorted(subcommands))
600+
preamble = """\
601+
# Custom Preamble
602+
{}
529603
604+
# End Custom Preamble
605+
""".format(preamble.rstrip()) if preamble else ""
530606
# References:
531607
# - https://github.com/zsh-users/zsh-completions
532608
# - http://zsh.sourceforge.net/Doc/Release/Completion-System.html
@@ -538,49 +614,21 @@ def format_positional(opt):
538614
539615
# AUTOMATCALLY GENERATED by `shtab`
540616
541-
${root_prefix}_options_=(
542-
${root_options}
543-
)
617+
${command_commands}
544618
545-
${root_prefix}_commands_() {
546-
local _commands=(
547-
${commands}
548-
)
619+
${command_options}
549620
550-
_describe '${prog} commands' _commands
551-
}
552-
${subcommands}
621+
${command_cases}
553622
${preamble}
554-
typeset -A opt_args
555-
local context state line curcontext="$curcontext"
556623
557-
_arguments \\
558-
$$${root_prefix}_options_ \\
559-
${root_arguments} \\
560-
': :${root_prefix}_commands_' \\
561-
'*::args:->args'
562-
563-
case $words[1] in
564-
${commands_case}
565-
esac""").safe_substitute(
624+
typeset -A opt_args
625+
${root_prefix} "$@\"""").safe_substitute(
626+
prog=prog,
566627
root_prefix=root_prefix,
567-
prog=parser.prog,
568-
commands="\n ".join('"{}:{}"'.format(cmd, escape_zsh(subcommands[cmd]["help"]))
569-
for cmd in sorted(subcommands)),
570-
root_arguments=" \\\n ".join(root_arguments),
571-
root_options="\n ".join(
572-
format_optional(opt) for opt in parser._get_optional_actions()
573-
if opt.help != SUPPRESS),
574-
commands_case="\n ".join("{cmd_orig}) _arguments ${root_prefix}_{cmd} ;;".format(
575-
cmd_orig=cmd, cmd=wordify(cmd), root_prefix=root_prefix)
576-
for cmd in sorted(subcommands)),
577-
subcommands="\n".join("""
578-
{root_prefix}_{cmd}=(
579-
{arguments}
580-
)""".format(root_prefix=root_prefix, cmd=wordify(cmd), arguments="\n ".join(
581-
subcommands[cmd]["arguments"])) for cmd in sorted(subcommands)),
582-
preamble=("\n# Custom Preamble\n" + preamble +
583-
"\n# End Custom Preamble\n" if preamble else ""),
628+
command_cases="\n".join(starmap(command_case, sorted(subcommands.items()))),
629+
command_commands="\n".join(starmap(command_list, sorted(subcommands.items()))),
630+
command_options="\n".join(starmap(command_option, sorted(all_commands.items()))),
631+
preamble=preamble,
584632
)
585633

586634

tests/test_shtab.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ def test_prog_scripts(shell, caplog, capsys):
9090
if shell == "bash":
9191
assert script_py == ["complete -o filenames -F _shtab_shtab script.py"]
9292
elif shell == "zsh":
93-
assert script_py == ["#compdef script.py", "_describe 'script.py commands' _commands"]
93+
assert script_py == [
94+
"#compdef script.py", "_describe 'script.py commands' _commands",
95+
"'*::: :->script.py'", "script.py)"]
9496
elif shell == "tcsh":
9597
assert script_py == ["complete script.py \\"]
9698
else:

0 commit comments

Comments
 (0)