docopt-completion 0.1.0__tar.gz
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.
- docopt_completion-0.1.0/PKG-INFO +31 -0
- docopt_completion-0.1.0/docopt_completion/__init__.py +11 -0
- docopt_completion-0.1.0/docopt_completion/__main__.py +108 -0
- docopt_completion-0.1.0/docopt_completion/bash.py +43 -0
- docopt_completion-0.1.0/docopt_completion/bash.sh +138 -0
- docopt_completion-0.1.0/docopt_completion/intermediate.py +136 -0
- docopt_completion-0.1.0/docopt_completion/py.typed +0 -0
- docopt_completion-0.1.0/docopt_completion/register.py +51 -0
- docopt_completion-0.1.0/docopt_completion/script.py +76 -0
- docopt_completion-0.1.0/docopt_completion/tables.py +273 -0
- docopt_completion-0.1.0/docopt_completion/zsh.py +40 -0
- docopt_completion-0.1.0/docopt_completion/zsh.sh +119 -0
- docopt_completion-0.1.0/docs/README.pypi.md +6 -0
- docopt_completion-0.1.0/pyproject.toml +51 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: docopt-completion
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A completion for the docopt language
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Anders Ingemann
|
|
7
|
+
Author-email: anders@ingemann.de
|
|
8
|
+
Requires-Python: >=3.11.0,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Dist: docopt (>=0.6.2,<0.7.0)
|
|
18
|
+
Requires-Dist: docopt-parser (>=0.0.1,<0.0.2)
|
|
19
|
+
Requires-Dist: pyyaml (>=6.0,<7.0)
|
|
20
|
+
Requires-Dist: termcolor (>=1.1.0,<2.0.0)
|
|
21
|
+
Project-URL: Homepage, https://github.com/andsens/docopt.completion
|
|
22
|
+
Project-URL: Repository, https://github.com/andsens/docopt.completion
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
docopt.completion
|
|
26
|
+
=================
|
|
27
|
+
|
|
28
|
+
A completion script generator for [docopt](http://docopt.org/) compatible help texts.
|
|
29
|
+
|
|
30
|
+
The full documentation is available on [GitHub](https://github.com/andsens/docopt.completion#docoptcompletion).
|
|
31
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import typing as T
|
|
6
|
+
import docopt
|
|
7
|
+
import termcolor
|
|
8
|
+
import yaml
|
|
9
|
+
import docopt_parser as P
|
|
10
|
+
from docopt_completion import __doc__ as pkg_doc, \
|
|
11
|
+
__name__ as root_name, __version__
|
|
12
|
+
from docopt_completion import intermediate, tables, bash, zsh, register
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(root_name)
|
|
15
|
+
|
|
16
|
+
__doc__: str = T.cast(str, pkg_doc) + """
|
|
17
|
+
Usage:
|
|
18
|
+
docopt-completion intermediate [FILE]
|
|
19
|
+
docopt-completion bash [FILE]
|
|
20
|
+
docopt-completion zsh [FILE]
|
|
21
|
+
docopt-completion register [--dir=DIR] (bash|zsh) COMMAND
|
|
22
|
+
docopt-completion -h
|
|
23
|
+
docopt-completion --version
|
|
24
|
+
|
|
25
|
+
Reads a docopt help text on stdin, except for register. FILE is an intermediate
|
|
26
|
+
with annotations.
|
|
27
|
+
|
|
28
|
+
Commands:
|
|
29
|
+
intermediate Write the YAML intermediate to stdout, or update FILE in place
|
|
30
|
+
keeping its annotations
|
|
31
|
+
bash Write a bash completion script to stdout
|
|
32
|
+
zsh Write a zsh completion script to stdout, for a file named _<prog>
|
|
33
|
+
in $fpath or to be sourced after compinit
|
|
34
|
+
register Write the completion for COMMAND from its --help, which
|
|
35
|
+
regenerates itself when COMMAND changes. Without --dir the
|
|
36
|
+
script goes where bash-completion loads it from, or to
|
|
37
|
+
${XDG_DATA_HOME:-~/.local/share}/zsh/site-functions for zsh
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--help -h Show this help screen
|
|
41
|
+
--version Show the docopt.completion version
|
|
42
|
+
"""
|
|
43
|
+
Params = T.TypedDict('Params', {
|
|
44
|
+
'intermediate': bool, 'bash': bool, 'zsh': bool, 'register': bool,
|
|
45
|
+
'FILE': 'str | None', '--dir': 'str | None', 'COMMAND': 'str | None',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
def docopt_completion(params: Params):
|
|
49
|
+
try:
|
|
50
|
+
if params['register']:
|
|
51
|
+
shell = 'bash' if params['bash'] else 'zsh'
|
|
52
|
+
directory = params['--dir'] or register.default_dir(shell, os.environ)
|
|
53
|
+
path = register.register(shell, T.cast(str, params['COMMAND']), directory)
|
|
54
|
+
log.info(f'Wrote {path}')
|
|
55
|
+
if shell == 'zsh':
|
|
56
|
+
log.info(f'Add {directory} to $fpath before compinit if it isn\'t there already')
|
|
57
|
+
return
|
|
58
|
+
text = sys.stdin.read()
|
|
59
|
+
previous = None
|
|
60
|
+
if params['FILE'] is not None and os.path.exists(params['FILE']):
|
|
61
|
+
with open(params['FILE']) as handle:
|
|
62
|
+
previous = yaml.safe_load(handle)
|
|
63
|
+
result, orphans = intermediate.generate(text, previous)
|
|
64
|
+
for key in orphans:
|
|
65
|
+
log.warning(f'{key} no longer matches anything in the usage, dropping it')
|
|
66
|
+
if params['bash']:
|
|
67
|
+
sys.stdout.write(bash.generate(result['prog'], tables.compile(result)))
|
|
68
|
+
elif params['zsh']:
|
|
69
|
+
sys.stdout.write(zsh.generate(result['prog'], tables.compile(result)))
|
|
70
|
+
else:
|
|
71
|
+
output = yaml.safe_dump(result, sort_keys=False, default_flow_style=None, allow_unicode=True)
|
|
72
|
+
if params['FILE'] is None:
|
|
73
|
+
sys.stdout.write(output)
|
|
74
|
+
else:
|
|
75
|
+
with open(params['FILE'], 'w') as handle:
|
|
76
|
+
handle.write(output)
|
|
77
|
+
except P.DocoptError as error:
|
|
78
|
+
log.error(str(error))
|
|
79
|
+
sys.exit(error.exit_code)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def setup_logging():
|
|
83
|
+
level_colors = {
|
|
84
|
+
logging.ERROR: 'red',
|
|
85
|
+
logging.WARN: 'yellow',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class ColorFormatter(logging.Formatter):
|
|
89
|
+
|
|
90
|
+
def format(self, record: logging.LogRecord):
|
|
91
|
+
record.msg = termcolor.colored(str(record.msg), level_colors.get(record.levelno, None))
|
|
92
|
+
return super().format(record)
|
|
93
|
+
|
|
94
|
+
stderr = logging.StreamHandler(sys.stderr)
|
|
95
|
+
if os.isatty(2):
|
|
96
|
+
stderr.setFormatter(ColorFormatter())
|
|
97
|
+
log.setLevel(level=logging.INFO)
|
|
98
|
+
log.addHandler(stderr)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main():
|
|
102
|
+
setup_logging()
|
|
103
|
+
params = T.cast(Params, docopt.docopt(__doc__, version=__version__))
|
|
104
|
+
docopt_completion(params)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == '__main__':
|
|
108
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import shlex
|
|
3
|
+
from docopt_completion import tables as Tb, script
|
|
4
|
+
|
|
5
|
+
compgen = {
|
|
6
|
+
'files': 'compopt -o filenames; mapfile -t -O "${#values[@]}" values < <(compgen -f -- "$2")',
|
|
7
|
+
'dirs': 'compopt -o filenames; mapfile -t -O "${#values[@]}" values < <(compgen -d -- "$2")',
|
|
8
|
+
'hosts': 'mapfile -t -O "${#values[@]}" values < <(compgen -A hostname -- "$2")',
|
|
9
|
+
'users': 'mapfile -t -O "${#values[@]}" values < <(compgen -u -- "$2")',
|
|
10
|
+
'groups': 'mapfile -t -O "${#values[@]}" values < <(compgen -g -- "$2")',
|
|
11
|
+
'commands': 'mapfile -t -O "${#values[@]}" values < <(compgen -c -- "$2")',
|
|
12
|
+
'signals': 'mapfile -t -O "${#values[@]}" values < <(compgen -A signal -- "$2")',
|
|
13
|
+
'processes': 'mapfile -t -O "${#values[@]}" values < <(compgen -W "$(ps -e -o pid=)" -- "$2")',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def source_code(source: Tb.Source) -> str:
|
|
18
|
+
if isinstance(source, str):
|
|
19
|
+
return compgen[source]
|
|
20
|
+
if 'words' in source:
|
|
21
|
+
words = ' '.join(shlex.quote(str(word)) for word in source['words'])
|
|
22
|
+
return f'local candidates=({words}); for w in "${{candidates[@]}}"; do [[ $w = "$2"* ]] && values+=("$w"); done'
|
|
23
|
+
return f'while IFS= read -r w; do [[ $w = "$2"* ]] && values+=("$w"); done < <({source["command"]})'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def generate(prog: str, compiled: Tb.Tables, refresh: "script.Refresh | None" = None) -> str:
|
|
27
|
+
function = '_docopt_' + re.sub(r'[^A-Za-z0-9_]', '_', prog)
|
|
28
|
+
code = ''
|
|
29
|
+
if refresh is not None:
|
|
30
|
+
binary, path, regenerate = (script.quote(refresh[0]), script.quote(refresh[1]), shlex.join(refresh[2]))
|
|
31
|
+
code = f'''# Regenerate this script when {prog} changed since
|
|
32
|
+
local bin
|
|
33
|
+
if [[ -z ${{_docopt_refreshed-}} ]] && bin=$(type -P {shlex.quote(prog)}) && bin=$(realpath -- "$bin") \\
|
|
34
|
+
&& [[ $bin != {binary} || $bin -nt {path} || $bin -ot {path} ]]; then
|
|
35
|
+
local _docopt_refreshed=1
|
|
36
|
+
if {regenerate} >/dev/null 2>&1; then
|
|
37
|
+
# shellcheck disable=SC1090
|
|
38
|
+
source {path}
|
|
39
|
+
{function} "$@"
|
|
40
|
+
return
|
|
41
|
+
fi
|
|
42
|
+
fi'''
|
|
43
|
+
return script.render('bash.sh', prog, function, compiled, source_code, 0, False, code)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# bash completion for "PROG", generated by docopt-completion
|
|
2
|
+
|
|
3
|
+
FN() {
|
|
4
|
+
"REFRESH"
|
|
5
|
+
"TABLES"
|
|
6
|
+
# Rejoin what COMP_WORDBREAKS split (--opt=value, host:port): pieces with no whitespace between them
|
|
7
|
+
local line=${COMP_LINE:0:COMP_POINT} words=() i w
|
|
8
|
+
line=${line#*"${COMP_WORDS[0]}"}
|
|
9
|
+
for ((i=1; i<=COMP_CWORD; i++)); do
|
|
10
|
+
w=${COMP_WORDS[i]}
|
|
11
|
+
if [[ -n $w && $line = "$w"* && ${#words[@]} -gt 0 ]]; then
|
|
12
|
+
words[${#words[@]}-1]+=$w
|
|
13
|
+
else
|
|
14
|
+
words+=("$w")
|
|
15
|
+
fi
|
|
16
|
+
line=${line#*"$w"}
|
|
17
|
+
done
|
|
18
|
+
local cur=${words[${#words[@]}-1]} piece=${COMP_WORDS[COMP_CWORD]}
|
|
19
|
+
# Readline replaces only what follows the last word break, strip the rest from the replies
|
|
20
|
+
for ((i=0; i<${#piece}; i++)); do
|
|
21
|
+
[[ $COMP_WORDBREAKS = *"${piece:i:1}"* ]] || break
|
|
22
|
+
done
|
|
23
|
+
[[ $i -eq ${#piece} ]] && piece=''
|
|
24
|
+
local strip=$((${#cur} - ${#piece}))
|
|
25
|
+
unset 'words[${#words[@]}-1]'
|
|
26
|
+
|
|
27
|
+
local state=0 expect=-1 positional=false used=' ' word opt pos
|
|
28
|
+
for word in "${words[@]}"; do
|
|
29
|
+
if [[ $state = -1 ]]; then
|
|
30
|
+
return 0
|
|
31
|
+
elif [[ $expect != -1 ]]; then
|
|
32
|
+
expect=-1
|
|
33
|
+
elif ! $positional && [[ $word = -- ]]; then
|
|
34
|
+
positional=true
|
|
35
|
+
state=${st_sep[state]}
|
|
36
|
+
elif ! $positional && [[ $word = --* ]]; then
|
|
37
|
+
if HELPERS_find "${word%%=*}" true; then
|
|
38
|
+
used+="$opt "
|
|
39
|
+
[[ ${opt_arg[opt]} = 1 && $word != *=* ]] && expect=$opt
|
|
40
|
+
fi
|
|
41
|
+
elif ! $positional && [[ $word = -?* ]]; then
|
|
42
|
+
for ((pos=1; pos<${#word}; pos++)); do
|
|
43
|
+
if HELPERS_find "-${word:pos:1}" false; then
|
|
44
|
+
used+="$opt "
|
|
45
|
+
if [[ ${opt_arg[opt]} = 1 ]]; then
|
|
46
|
+
[[ $pos -eq $((${#word}-1)) ]] && expect=$opt
|
|
47
|
+
break
|
|
48
|
+
fi
|
|
49
|
+
fi
|
|
50
|
+
done
|
|
51
|
+
else
|
|
52
|
+
pos=${st_other[state]}
|
|
53
|
+
for i in "${!cmd_state[@]}"; do
|
|
54
|
+
if [[ ${cmd_state[i]} = "$state" && ${cmd_name[i]} = "$word" ]]; then
|
|
55
|
+
pos=${cmd_next[i]}
|
|
56
|
+
break
|
|
57
|
+
fi
|
|
58
|
+
done
|
|
59
|
+
state=$pos
|
|
60
|
+
fi
|
|
61
|
+
done
|
|
62
|
+
[[ $state = -1 ]] && return 0
|
|
63
|
+
|
|
64
|
+
local items=() values=() src=-1 prefix=''
|
|
65
|
+
if [[ $expect != -1 ]]; then
|
|
66
|
+
src=${opt_src[expect]}
|
|
67
|
+
elif ! $positional && [[ $cur = --*=* ]]; then
|
|
68
|
+
HELPERS_find "${cur%%=*}" true && [[ ${opt_arg[opt]} = 1 ]] || return 0
|
|
69
|
+
src=${opt_src[opt]}
|
|
70
|
+
prefix=${cur%%=*}=
|
|
71
|
+
else
|
|
72
|
+
local options=() commands=()
|
|
73
|
+
if ! $positional; then
|
|
74
|
+
for i in ${st_opts[state]}; do
|
|
75
|
+
# shellcheck disable=SC2206
|
|
76
|
+
[[ $used = *" $i "* && ${opt_rep[i]} = 0 ]] || options+=(${opt_names[i]})
|
|
77
|
+
done
|
|
78
|
+
fi
|
|
79
|
+
for i in "${!cmd_state[@]}"; do
|
|
80
|
+
[[ ${cmd_state[i]} = "$state" ]] && commands+=("${cmd_name[i]}")
|
|
81
|
+
done
|
|
82
|
+
if ! $positional && [[ $cur = -* ]]; then
|
|
83
|
+
items=("${options[@]}")
|
|
84
|
+
elif [[ ${#commands[@]} -eq 0 && ${st_other[state]} = -1 ]]; then
|
|
85
|
+
items=("${options[@]}")
|
|
86
|
+
else
|
|
87
|
+
items=("${commands[@]}")
|
|
88
|
+
src=${st_src[state]}
|
|
89
|
+
fi
|
|
90
|
+
fi
|
|
91
|
+
for w in "${items[@]}"; do
|
|
92
|
+
[[ $w = "$cur"* ]] && COMPREPLY+=("${w:strip}")
|
|
93
|
+
done
|
|
94
|
+
[[ $src = -1 ]] && return 0
|
|
95
|
+
HELPERS_source "$src" "${cur#"$prefix"}"
|
|
96
|
+
for w in "${values[@]}"; do
|
|
97
|
+
w=$prefix$w
|
|
98
|
+
COMPREPLY+=("${w:strip}")
|
|
99
|
+
done
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Sets $opt to the option named $1, matching long options by prefix when $2 is true
|
|
103
|
+
HELPERS_find() {
|
|
104
|
+
local i n matches=() first=''
|
|
105
|
+
for i in "${!opt_names[@]}"; do
|
|
106
|
+
for n in ${opt_names[i]}; do
|
|
107
|
+
[[ $n = "$1" ]] && matches+=("$i")
|
|
108
|
+
done
|
|
109
|
+
done
|
|
110
|
+
if [[ ${#matches[@]} -eq 0 && $2 = true ]]; then
|
|
111
|
+
for i in "${!opt_names[@]}"; do
|
|
112
|
+
n=${opt_names[i]%% *}
|
|
113
|
+
if [[ $n = "$1"* ]]; then
|
|
114
|
+
[[ -n $first && $first != "$n" ]] && return 1
|
|
115
|
+
first=$n
|
|
116
|
+
matches+=("$i")
|
|
117
|
+
fi
|
|
118
|
+
done
|
|
119
|
+
fi
|
|
120
|
+
[[ ${#matches[@]} -gt 0 ]] || return 1
|
|
121
|
+
opt=${matches[0]}
|
|
122
|
+
for i in "${matches[@]}"; do
|
|
123
|
+
if [[ " ${st_opts[state]} " = *" $i "* ]]; then
|
|
124
|
+
opt=$i
|
|
125
|
+
return 0
|
|
126
|
+
fi
|
|
127
|
+
done
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Adds the candidates of source set $1 that start with $2 to $values
|
|
131
|
+
HELPERS_source() {
|
|
132
|
+
local w
|
|
133
|
+
case $1 in
|
|
134
|
+
"SOURCES"
|
|
135
|
+
esac
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
complete -F FN "PROG"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The intermediate: a YAML document describing what to complete, generated from a docopt help text.
|
|
3
|
+
|
|
4
|
+
`usage` mirrors the usage section as nested groups whose leaves are keys into `nodes`.
|
|
5
|
+
`nodes` holds what was parsed (type, short, argument, default) and the annotations
|
|
6
|
+
(`description`, `complete`), which the user may edit and which survive regeneration.
|
|
7
|
+
|
|
8
|
+
A node key is the leaf's identifier (`<x>`, `--speed`, `move`). Adding a key that is qualified
|
|
9
|
+
by the commands preceding the leaf (`shoot <x>`) or by its occurrence (`<x>#2`) splits that
|
|
10
|
+
occurrence off into its own node on the next regeneration.
|
|
11
|
+
"""
|
|
12
|
+
import re
|
|
13
|
+
import typing as T
|
|
14
|
+
import docopt_parser as P
|
|
15
|
+
|
|
16
|
+
Usage = T.Union[str, T.Dict[str, T.List["Usage"]]]
|
|
17
|
+
Node = T.Dict[str, T.Any]
|
|
18
|
+
Intermediate = T.Dict[str, T.Any]
|
|
19
|
+
|
|
20
|
+
annotations = ('description', 'complete')
|
|
21
|
+
|
|
22
|
+
group_names: T.Dict[type, str] = {
|
|
23
|
+
P.Choice: 'choice', P.Sequence: 'sequence', P.Optional: 'optional', P.Repeatable: 'repeatable',
|
|
24
|
+
}
|
|
25
|
+
leaf_types: T.Dict[type, str] = {
|
|
26
|
+
P.Command: 'command', P.Argument: 'argument', P.ArgumentSeparator: 'separator', P.Option: 'option',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Last word of a placeholder name -> builtin completion source
|
|
30
|
+
placeholder_sources = {
|
|
31
|
+
'file': 'files', 'path': 'files',
|
|
32
|
+
'dir': 'dirs', 'directory': 'dirs',
|
|
33
|
+
'host': 'hosts', 'hostname': 'hosts',
|
|
34
|
+
'user': 'users',
|
|
35
|
+
'group': 'groups',
|
|
36
|
+
'cmd': 'commands', 'command': 'commands',
|
|
37
|
+
'signal': 'signals', 'sig': 'signals',
|
|
38
|
+
'pid': 'processes',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate(text: str, previous: "Intermediate | None" = None) -> T.Tuple[Intermediate, T.List[str]]:
|
|
43
|
+
"""
|
|
44
|
+
Returns the intermediate for `text` and the keys of `previous` that no longer match a node.
|
|
45
|
+
Annotations in `previous` are kept, missing ones are filled with defaults.
|
|
46
|
+
"""
|
|
47
|
+
root = P.parse(text)
|
|
48
|
+
prog = P.get_prog(text).elm
|
|
49
|
+
old_nodes: T.Dict[str, Node] = previous['nodes'] if previous else {}
|
|
50
|
+
nodes: T.Dict[str, Node] = {}
|
|
51
|
+
occurrences: T.Dict[str, int] = {}
|
|
52
|
+
|
|
53
|
+
def resolve_key(ident: str, commands: T.List[str]) -> str:
|
|
54
|
+
occurrences[ident] = occurrences.get(ident, 0) + 1
|
|
55
|
+
if (key := f'{ident}#{occurrences[ident]}') in old_nodes:
|
|
56
|
+
return key
|
|
57
|
+
best, best_len = ident, 0
|
|
58
|
+
for key in old_nodes:
|
|
59
|
+
*qualifiers, last = key.split(' ')
|
|
60
|
+
if last == ident and len(qualifiers) > best_len and is_subsequence(qualifiers, commands):
|
|
61
|
+
best, best_len = key, len(qualifiers)
|
|
62
|
+
return best
|
|
63
|
+
|
|
64
|
+
def walk(node: P.Node, commands: T.List[str]) -> Usage:
|
|
65
|
+
if isinstance(node, P.Group):
|
|
66
|
+
items: T.List[Usage] = []
|
|
67
|
+
preceding = list(commands)
|
|
68
|
+
for item in node.items:
|
|
69
|
+
items.append(walk(item, commands if isinstance(node, P.Choice) else preceding))
|
|
70
|
+
if isinstance(item, P.Command):
|
|
71
|
+
preceding.append(item.ident)
|
|
72
|
+
return {group_names[type(node)]: items}
|
|
73
|
+
leaf = T.cast(P.Leaf, node)
|
|
74
|
+
ident = leaf.definition.ident if isinstance(leaf, P.Option) else leaf.ident
|
|
75
|
+
key = resolve_key(ident, commands)
|
|
76
|
+
if key not in nodes:
|
|
77
|
+
nodes[key] = node_entry(leaf, old_nodes.get(key))
|
|
78
|
+
return key
|
|
79
|
+
|
|
80
|
+
usage = walk(root, [])
|
|
81
|
+
return {'prog': prog, 'usage': usage, 'nodes': nodes}, [key for key in old_nodes if key not in nodes]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def node_entry(leaf: P.Leaf, old: "Node | None") -> Node:
|
|
85
|
+
entry: Node = {'type': leaf_types[type(leaf)]}
|
|
86
|
+
defaults: Node = {}
|
|
87
|
+
if isinstance(leaf, P.Option):
|
|
88
|
+
definition = leaf.definition
|
|
89
|
+
if definition.short_alias and definition.short_alias != definition.ident:
|
|
90
|
+
entry['short'] = definition.short_alias
|
|
91
|
+
if leaf.doc:
|
|
92
|
+
description = re.sub(r'\s+', ' ', re.sub(r'\[default: [^]]*\]', '', leaf.doc))
|
|
93
|
+
defaults['description'] = re.sub(r' ([.,;:])', r'\1', description).strip()
|
|
94
|
+
if leaf.argname:
|
|
95
|
+
entry['argument'] = leaf.argname.elm
|
|
96
|
+
default = leaf.default
|
|
97
|
+
words = default if isinstance(default, list) else [default] if isinstance(default, str) else []
|
|
98
|
+
if words:
|
|
99
|
+
entry['default'] = list(words)
|
|
100
|
+
defaults['complete'] = complete_default(leaf.argname.elm, words)
|
|
101
|
+
elif isinstance(leaf, P.Argument):
|
|
102
|
+
defaults['complete'] = complete_default(leaf.ident, [])
|
|
103
|
+
for key in annotations:
|
|
104
|
+
if old is not None and key in old:
|
|
105
|
+
entry[key] = old[key]
|
|
106
|
+
elif key in defaults:
|
|
107
|
+
entry[key] = defaults[key]
|
|
108
|
+
return entry
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def complete_default(placeholder: str, default: T.List[str]) -> T.Any:
|
|
112
|
+
sources: T.List[T.Any] = []
|
|
113
|
+
if default:
|
|
114
|
+
sources.append({'words': default})
|
|
115
|
+
if (source := placeholder_sources.get(placeholder_word(placeholder))) is not None:
|
|
116
|
+
sources.append(source)
|
|
117
|
+
if not sources:
|
|
118
|
+
return 'none'
|
|
119
|
+
return sources[0] if len(sources) == 1 else sources
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def placeholder_word(placeholder: str) -> str:
|
|
123
|
+
"""The last word of a placeholder, lowercased and singular: <config-files> -> file"""
|
|
124
|
+
words = [w for w in re.split(r'[^A-Za-z0-9]+|(?<=[a-z])(?=[A-Z])', placeholder) if w]
|
|
125
|
+
word = words[-1].lower() if words else ''
|
|
126
|
+
if word not in placeholder_sources:
|
|
127
|
+
if word.endswith('ies') and word[:-3] + 'y' in placeholder_sources:
|
|
128
|
+
return word[:-3] + 'y'
|
|
129
|
+
if word.endswith('s') and word[:-1] in placeholder_sources:
|
|
130
|
+
return word[:-1]
|
|
131
|
+
return word
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_subsequence(needle: T.List[str], haystack: T.List[str]) -> bool:
|
|
135
|
+
it = iter(haystack)
|
|
136
|
+
return all(item in it for item in needle)
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Completion for a docopt program that has none, generated from its `--help`. The script regenerates
|
|
3
|
+
itself when the program's resolved path or modification time changes.
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import typing as T
|
|
9
|
+
import docopt_parser as P
|
|
10
|
+
from docopt_completion import intermediate, tables, bash, zsh
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotDocoptError(P.DocoptError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def default_dir(shell: str, env: T.Mapping[str, str]) -> str:
|
|
18
|
+
"""Where bash-completion lazy-loads user completions from; zsh has no such place"""
|
|
19
|
+
data = env.get('XDG_DATA_HOME') or os.path.join(env['HOME'], '.local', 'share')
|
|
20
|
+
if shell == 'bash':
|
|
21
|
+
user_dir = env.get('BASH_COMPLETION_USER_DIR', '').split(':')[0] or os.path.join(data, 'bash-completion')
|
|
22
|
+
return os.path.join(user_dir, 'completions')
|
|
23
|
+
return os.path.join(data, 'zsh', 'site-functions')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register(shell: str, command: str, directory: str) -> str:
|
|
27
|
+
"""Writes the completion script for `command` to `directory` and returns its path"""
|
|
28
|
+
found = shutil.which(command)
|
|
29
|
+
if found is None:
|
|
30
|
+
raise P.DocoptError(f'{command} not found')
|
|
31
|
+
binary = os.path.realpath(found)
|
|
32
|
+
try:
|
|
33
|
+
text = subprocess.run([found, '--help'], capture_output=True, text=True, timeout=10, check=True).stdout
|
|
34
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
|
35
|
+
raise P.DocoptError(f'`{command} --help` failed: {error}') from error
|
|
36
|
+
result, _ = intermediate.generate(text)
|
|
37
|
+
for key, node in T.cast(T.Dict[str, T.Dict[str, T.Any]], result['nodes']).items():
|
|
38
|
+
if node['type'] == 'argument' and intermediate.placeholder_word(key) in ('option', 'options'):
|
|
39
|
+
raise NotDocoptError(f'The help of {command} looks GNU style ({key}), not docopt')
|
|
40
|
+
compiled = tables.compile(result)
|
|
41
|
+
name = os.path.basename(command)
|
|
42
|
+
path = os.path.join(directory, f'{name}.bash' if shell == 'bash' else f'_{name}')
|
|
43
|
+
refresh = (binary, path, ['docopt-completion', 'register', f'--dir={directory}', shell, command])
|
|
44
|
+
code = (bash if shell == 'bash' else zsh).generate(name, compiled, refresh)
|
|
45
|
+
os.makedirs(directory, exist_ok=True)
|
|
46
|
+
with open(path, 'w') as handle:
|
|
47
|
+
handle.write(code)
|
|
48
|
+
# The script carries the binary's mtime, a mismatch means the binary changed
|
|
49
|
+
stat = os.stat(binary)
|
|
50
|
+
os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns))
|
|
51
|
+
return path
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shlex
|
|
4
|
+
import typing as T
|
|
5
|
+
from docopt_completion import tables as Tb
|
|
6
|
+
|
|
7
|
+
# The resolved binary, the path of the script and the command that regenerates it
|
|
8
|
+
Refresh = T.Tuple[str, str, T.List[str]]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def quote(value: str) -> str:
|
|
12
|
+
"""Always quoted, unlike shlex.quote: unquoted on the right of [[ x = y ]] is a pattern"""
|
|
13
|
+
return "'" + value.replace("'", "'\\''") + "'"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render(template: str, prog: str, function: str, compiled: Tb.Tables,
|
|
17
|
+
source_code: T.Callable[[Tb.Source], str], base: int, descriptions: bool, refresh: str = '') -> str:
|
|
18
|
+
"""
|
|
19
|
+
Fill a shell template with the tables as arrays. Indexes start at `base`, `base - 1` is none.
|
|
20
|
+
`function` is the completion function, its helpers are prefixed with `_docopt_<prog>`.
|
|
21
|
+
`refresh` is code to run first in the completion function.
|
|
22
|
+
"""
|
|
23
|
+
source_sets: T.List[T.List[Tb.Source]] = []
|
|
24
|
+
|
|
25
|
+
def source_set(sources: T.List[Tb.Source]) -> int:
|
|
26
|
+
if not sources:
|
|
27
|
+
return base - 1
|
|
28
|
+
if sources not in source_sets:
|
|
29
|
+
source_sets.append(sources)
|
|
30
|
+
return source_sets.index(sources) + base
|
|
31
|
+
|
|
32
|
+
def index(value: "int | None") -> int:
|
|
33
|
+
return base - 1 if value is None else value + base
|
|
34
|
+
|
|
35
|
+
def array(name: str, values: T.Iterable[T.Any]) -> str:
|
|
36
|
+
return f'local -a {name}=(' + ' '.join(shlex.quote(str(value)) for value in values) + ')'
|
|
37
|
+
|
|
38
|
+
options = compiled.options
|
|
39
|
+
states = compiled.states
|
|
40
|
+
transitions = [(i, name, target, description) for i, state in enumerate(states)
|
|
41
|
+
for name, (target, description) in state.commands.items()]
|
|
42
|
+
table_lines = [
|
|
43
|
+
array('opt_names', (' '.join(o.names) for o in options)),
|
|
44
|
+
array('opt_arg', (int(o.argument) for o in options)),
|
|
45
|
+
array('opt_rep', (int(o.repeatable) for o in options)),
|
|
46
|
+
array('opt_src', (source_set(o.complete) for o in options)),
|
|
47
|
+
array('cmd_state', (index(t[0]) for t in transitions)),
|
|
48
|
+
array('cmd_name', (t[1] for t in transitions)),
|
|
49
|
+
array('cmd_next', (index(t[2]) for t in transitions)),
|
|
50
|
+
array('st_sep', (index(s.separator) for s in states)),
|
|
51
|
+
array('st_other', (index(s.other) for s in states)),
|
|
52
|
+
array('st_src', (source_set(s.complete) for s in states)),
|
|
53
|
+
array('st_opts', (' '.join(str(index(o)) for o in s.options) for s in states)),
|
|
54
|
+
]
|
|
55
|
+
if descriptions:
|
|
56
|
+
table_lines += [
|
|
57
|
+
array('opt_desc', (o.description or '' for o in options)),
|
|
58
|
+
array('cmd_desc', (t[3] or '' for t in transitions)),
|
|
59
|
+
]
|
|
60
|
+
source_lines: T.List[str] = []
|
|
61
|
+
for i, sources in enumerate(source_sets):
|
|
62
|
+
source_lines.append(f'{i + base})')
|
|
63
|
+
source_lines += [f' {source_code(source)}' for source in sources]
|
|
64
|
+
source_lines.append(' ;;')
|
|
65
|
+
|
|
66
|
+
with open(os.path.join(os.path.dirname(__file__), template)) as handle:
|
|
67
|
+
code = handle.read()
|
|
68
|
+
return (
|
|
69
|
+
code
|
|
70
|
+
.replace('HELPERS', '_docopt_' + re.sub(r'[^A-Za-z0-9_]', '_', prog))
|
|
71
|
+
.replace('FN', function)
|
|
72
|
+
.replace('"PROG"', shlex.quote(prog))
|
|
73
|
+
.replace('"TABLES"', '\n '.join(table_lines))
|
|
74
|
+
.replace('"SOURCES"', '\n '.join(source_lines))
|
|
75
|
+
.replace(' "REFRESH"\n', ''.join(f' {line}\n' for line in refresh.splitlines()))
|
|
76
|
+
)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Match tables compiled from the intermediate, the input for the shell generators.
|
|
3
|
+
|
|
4
|
+
The positional leaves (commands, arguments, `--`) form an automaton that is compiled to a DFA
|
|
5
|
+
over the command names, `--` and any other word. Options take no part in the ordering, docopt
|
|
6
|
+
matches them anywhere. Instead every state lists the options that lie on some path through it.
|
|
7
|
+
|
|
8
|
+
`complete()` is the reference for what the shell runtimes do with the tables.
|
|
9
|
+
"""
|
|
10
|
+
import typing as T
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from docopt_parser import DocoptError
|
|
13
|
+
|
|
14
|
+
Source = T.Union[str, T.Dict[str, T.Any]]
|
|
15
|
+
builtin_sources = ('files', 'dirs', 'hosts', 'users', 'groups', 'commands', 'signals', 'processes')
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Option:
|
|
19
|
+
names: T.List[str]
|
|
20
|
+
argument: bool
|
|
21
|
+
repeatable: bool
|
|
22
|
+
description: "str | None"
|
|
23
|
+
complete: T.List[Source]
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class State:
|
|
27
|
+
# command name -> (next state, description)
|
|
28
|
+
commands: T.Dict[str, T.Tuple[int, "str | None"]] = field(default_factory=dict[str, T.Tuple[int, "str | None"]])
|
|
29
|
+
# next state for `--` and for any other word
|
|
30
|
+
separator: "int | None" = None
|
|
31
|
+
other: "int | None" = None
|
|
32
|
+
# completion sources of the arguments that `other` consumes
|
|
33
|
+
complete: T.List[Source] = field(default_factory=list[Source])
|
|
34
|
+
# indexes into Tables.options
|
|
35
|
+
options: T.List[int] = field(default_factory=list[int])
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Tables:
|
|
39
|
+
options: T.List[Option]
|
|
40
|
+
states: T.List[State]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ident(key: str) -> str:
|
|
44
|
+
"""`shoot <x>#2` -> `<x>`"""
|
|
45
|
+
return key.split(' ')[-1].split('#')[0]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sources(key: str, complete: T.Any) -> T.List[Source]:
|
|
49
|
+
items = T.cast(T.List[T.Any], complete if isinstance(complete, list) else [complete])
|
|
50
|
+
result: T.List[Source] = []
|
|
51
|
+
for item in items:
|
|
52
|
+
if item == 'none':
|
|
53
|
+
continue
|
|
54
|
+
if item in builtin_sources:
|
|
55
|
+
result.append(T.cast(str, item))
|
|
56
|
+
continue
|
|
57
|
+
if isinstance(item, dict):
|
|
58
|
+
source = T.cast(T.Dict[str, T.Any], item)
|
|
59
|
+
if len(source) == 1 and (isinstance(source.get('words'), list) or isinstance(source.get('command'), str)):
|
|
60
|
+
result.append(source)
|
|
61
|
+
continue
|
|
62
|
+
raise DocoptError(f'{key}: unknown completion source {item!r}')
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
Label = T.Tuple[str, str] # (node type, key)
|
|
67
|
+
# An NFA state and the options passed on the way to it
|
|
68
|
+
Item = T.Tuple[int, T.FrozenSet[str]]
|
|
69
|
+
|
|
70
|
+
def compile(intermediate: T.Dict[str, T.Any]) -> Tables:
|
|
71
|
+
nodes: T.Dict[str, T.Dict[str, T.Any]] = intermediate['nodes']
|
|
72
|
+
edges: T.List[T.List[T.Tuple["Label | None", int]]] = []
|
|
73
|
+
repeatable_options: T.Set[str] = set()
|
|
74
|
+
|
|
75
|
+
def new_state() -> int:
|
|
76
|
+
edges.append([])
|
|
77
|
+
return len(edges) - 1
|
|
78
|
+
|
|
79
|
+
def build(usage: T.Any, start: int, repeated: bool = False) -> int:
|
|
80
|
+
if isinstance(usage, str):
|
|
81
|
+
end = new_state()
|
|
82
|
+
edges[start].append(((nodes[usage]['type'], usage), end))
|
|
83
|
+
if repeated and nodes[usage]['type'] == 'option':
|
|
84
|
+
repeatable_options.add(usage)
|
|
85
|
+
return end
|
|
86
|
+
[(kind, items)] = T.cast(T.Dict[str, T.List[T.Any]], usage).items()
|
|
87
|
+
if kind == 'choice':
|
|
88
|
+
end = new_state()
|
|
89
|
+
for item in items:
|
|
90
|
+
edges[build(item, start, repeated)].append((None, end))
|
|
91
|
+
return end
|
|
92
|
+
if kind == 'optional':
|
|
93
|
+
# docopt makes every item of [a b] optional on its own
|
|
94
|
+
for item in items:
|
|
95
|
+
end = new_state()
|
|
96
|
+
edges[start].append((None, end))
|
|
97
|
+
edges[build(item, start, repeated)].append((None, end))
|
|
98
|
+
start = end
|
|
99
|
+
return start
|
|
100
|
+
if kind == 'repeatable':
|
|
101
|
+
loop = new_state()
|
|
102
|
+
edges[start].append((None, loop))
|
|
103
|
+
end = build({'sequence': items}, loop, True)
|
|
104
|
+
edges[end].append((None, loop))
|
|
105
|
+
return end
|
|
106
|
+
for item in items:
|
|
107
|
+
start = build(item, start, repeated)
|
|
108
|
+
return start
|
|
109
|
+
|
|
110
|
+
start = new_state()
|
|
111
|
+
accept = build(intermediate['usage'], start)
|
|
112
|
+
|
|
113
|
+
# States reachable from each state
|
|
114
|
+
reach: T.List[T.Set[int]] = []
|
|
115
|
+
for state in range(len(edges)):
|
|
116
|
+
seen = {state}
|
|
117
|
+
todo = [state]
|
|
118
|
+
while todo:
|
|
119
|
+
for _, target in edges[todo.pop()]:
|
|
120
|
+
if target not in seen:
|
|
121
|
+
seen.add(target)
|
|
122
|
+
todo.append(target)
|
|
123
|
+
reach.append(seen)
|
|
124
|
+
option_edges = [
|
|
125
|
+
(source, label[1], target) for source in range(len(edges))
|
|
126
|
+
for label, target in edges[source] if label is not None and label[0] == 'option'
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
def after(state: int) -> T.Set[str]:
|
|
130
|
+
"""Options on some path from `state` to the end"""
|
|
131
|
+
return {key for source, key, target in option_edges if source in reach[state] and accept in reach[target]}
|
|
132
|
+
|
|
133
|
+
def closure(items: T.Iterable["Item"]) -> T.FrozenSet["Item"]:
|
|
134
|
+
passed_by: T.Dict[int, T.FrozenSet[str]] = {}
|
|
135
|
+
todo = list(items)
|
|
136
|
+
while todo:
|
|
137
|
+
state, passed = todo.pop()
|
|
138
|
+
if state in passed_by and passed <= passed_by[state]:
|
|
139
|
+
continue
|
|
140
|
+
passed_by[state] = passed = passed_by.get(state, frozenset()) | passed
|
|
141
|
+
for label, target in edges[state]:
|
|
142
|
+
if label is None:
|
|
143
|
+
todo.append((target, passed))
|
|
144
|
+
elif label[0] == 'option':
|
|
145
|
+
todo.append((target, passed | {label[1]}))
|
|
146
|
+
return frozenset(passed_by.items())
|
|
147
|
+
|
|
148
|
+
option_keys = [key for key, node in nodes.items() if node['type'] == 'option']
|
|
149
|
+
options = [
|
|
150
|
+
Option(
|
|
151
|
+
names=[ident(key)] + ([nodes[key]['short']] if 'short' in nodes[key] else []),
|
|
152
|
+
argument='argument' in nodes[key],
|
|
153
|
+
repeatable=key in repeatable_options,
|
|
154
|
+
description=nodes[key].get('description'),
|
|
155
|
+
complete=sources(key, nodes[key].get('complete', 'none')),
|
|
156
|
+
) for key in option_keys
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
dfa: T.Dict[T.FrozenSet[Item], int] = {}
|
|
160
|
+
states: T.List[State] = []
|
|
161
|
+
|
|
162
|
+
def dfa_state(items: T.FrozenSet[Item]) -> "int | None":
|
|
163
|
+
if not items:
|
|
164
|
+
return None
|
|
165
|
+
if items in dfa:
|
|
166
|
+
return dfa[items]
|
|
167
|
+
index = dfa[items] = len(states)
|
|
168
|
+
state = State()
|
|
169
|
+
states.append(state)
|
|
170
|
+
outgoing = [
|
|
171
|
+
(label, (target, passed)) for s, passed in items
|
|
172
|
+
for label, target in edges[s] if label is not None and label[0] != 'option'
|
|
173
|
+
]
|
|
174
|
+
arguments = {target for (kind, _), target in outgoing if kind == 'argument'}
|
|
175
|
+
for (kind, key), _ in outgoing:
|
|
176
|
+
if kind == 'command':
|
|
177
|
+
name = ident(key)
|
|
178
|
+
targets = {t for (k, other_key), t in outgoing if k == 'command' and ident(other_key) == name}
|
|
179
|
+
next_state = T.cast(int, dfa_state(closure(targets | arguments)))
|
|
180
|
+
description = state.commands.get(name, (0, None))[1] or nodes[key].get('description')
|
|
181
|
+
state.commands[name] = (next_state, description)
|
|
182
|
+
elif kind == 'argument':
|
|
183
|
+
for source in sources(key, nodes[key].get('complete', 'none')):
|
|
184
|
+
if source not in state.complete:
|
|
185
|
+
state.complete.append(source)
|
|
186
|
+
separators = {target for (kind, _), target in outgoing if kind == 'separator'}
|
|
187
|
+
state.separator = dfa_state(closure(separators | arguments))
|
|
188
|
+
state.other = dfa_state(closure(arguments))
|
|
189
|
+
in_scope = set[str]().union(*(passed | after(s) for s, passed in items))
|
|
190
|
+
state.options = [i for i, key in enumerate(option_keys) if key in in_scope]
|
|
191
|
+
return index
|
|
192
|
+
|
|
193
|
+
dfa_state(closure([(start, frozenset())]))
|
|
194
|
+
return Tables(options, states)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class Completion:
|
|
199
|
+
# Literal candidates with their descriptions
|
|
200
|
+
words: T.List[T.Tuple[str, "str | None"]] = field(default_factory=list[T.Tuple[str, "str | None"]])
|
|
201
|
+
# Sources for the shell to expand
|
|
202
|
+
sources: T.List[Source] = field(default_factory=list[Source])
|
|
203
|
+
# Prepended to every candidate, e.g. `--speed=`
|
|
204
|
+
prefix: str = ''
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def complete(tables: Tables, words: T.List[str], cur: str) -> Completion:
|
|
208
|
+
"""Candidates for `cur` after `words` (without the program name). Not filtered by `cur`."""
|
|
209
|
+
state: "int | None" = 0
|
|
210
|
+
used: T.Set[int] = set()
|
|
211
|
+
expect: "Option | None" = None
|
|
212
|
+
positional_only = False
|
|
213
|
+
|
|
214
|
+
def find(name: str, prefix: bool) -> "int | None":
|
|
215
|
+
in_scope = tables.states[state].options if state is not None else []
|
|
216
|
+
matches = [i for i, o in enumerate(tables.options) if name in o.names]
|
|
217
|
+
if not matches and prefix:
|
|
218
|
+
matches = [i for i, o in enumerate(tables.options) if any(n.startswith(name) for n in o.names[:1])]
|
|
219
|
+
if len({tables.options[i].names[0] for i in matches}) > 1:
|
|
220
|
+
return None
|
|
221
|
+
return next((i for i in matches if i in in_scope), next(iter(matches), None))
|
|
222
|
+
|
|
223
|
+
for word in words:
|
|
224
|
+
if state is None:
|
|
225
|
+
return Completion()
|
|
226
|
+
if expect is not None:
|
|
227
|
+
expect = None
|
|
228
|
+
elif not positional_only and word == '--':
|
|
229
|
+
positional_only = True
|
|
230
|
+
state = tables.states[state].separator
|
|
231
|
+
elif not positional_only and word.startswith('--'):
|
|
232
|
+
name, eq, _ = word.partition('=')
|
|
233
|
+
if (i := find(name, True)) is not None:
|
|
234
|
+
used.add(i)
|
|
235
|
+
if tables.options[i].argument and not eq:
|
|
236
|
+
expect = tables.options[i]
|
|
237
|
+
elif not positional_only and word.startswith('-') and word != '-':
|
|
238
|
+
for pos in range(1, len(word)):
|
|
239
|
+
if (i := find('-' + word[pos], False)) is not None:
|
|
240
|
+
used.add(i)
|
|
241
|
+
if tables.options[i].argument:
|
|
242
|
+
if pos == len(word) - 1:
|
|
243
|
+
expect = tables.options[i]
|
|
244
|
+
break
|
|
245
|
+
else:
|
|
246
|
+
current = tables.states[state]
|
|
247
|
+
state = current.commands[word][0] if word in current.commands else current.other
|
|
248
|
+
|
|
249
|
+
if state is None:
|
|
250
|
+
return Completion()
|
|
251
|
+
if expect is not None:
|
|
252
|
+
return Completion(sources=expect.complete)
|
|
253
|
+
if not positional_only and cur.startswith('--') and '=' in cur:
|
|
254
|
+
name = cur.partition('=')[0]
|
|
255
|
+
if (i := find(name, True)) is not None and tables.options[i].argument:
|
|
256
|
+
return Completion(sources=tables.options[i].complete, prefix=name + '=')
|
|
257
|
+
return Completion()
|
|
258
|
+
current = tables.states[state]
|
|
259
|
+
options = Completion(words=[
|
|
260
|
+
(name, tables.options[i].description)
|
|
261
|
+
for i in current.options if i not in used or tables.options[i].repeatable
|
|
262
|
+
for name in tables.options[i].names
|
|
263
|
+
])
|
|
264
|
+
if positional_only:
|
|
265
|
+
options = Completion()
|
|
266
|
+
if cur.startswith('-') and not positional_only:
|
|
267
|
+
return options
|
|
268
|
+
if not current.commands and current.other is None:
|
|
269
|
+
return options
|
|
270
|
+
return Completion(
|
|
271
|
+
words=[(name, description) for name, (_, description) in current.commands.items()],
|
|
272
|
+
sources=current.complete,
|
|
273
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
from docopt_completion import tables as Tb, script
|
|
3
|
+
|
|
4
|
+
compsys = {
|
|
5
|
+
'files': '_files',
|
|
6
|
+
'dirs': '_files -/',
|
|
7
|
+
'hosts': '_hosts',
|
|
8
|
+
'users': '_users',
|
|
9
|
+
'groups': '_groups',
|
|
10
|
+
'commands': '_command_names -e',
|
|
11
|
+
'signals': '_signals',
|
|
12
|
+
'processes': '_pids',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def source_code(source: Tb.Source) -> str:
|
|
17
|
+
if isinstance(source, str):
|
|
18
|
+
return f'{compsys[source]} && ret=0'
|
|
19
|
+
if 'words' in source:
|
|
20
|
+
return 'compadd -- ' + ' '.join(shlex.quote(str(word)) for word in source['words']) + ' && ret=0'
|
|
21
|
+
return f'compadd -- ${{(f)"$({source["command"]})"}} && ret=0'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate(prog: str, compiled: Tb.Tables, refresh: "script.Refresh | None" = None) -> str:
|
|
25
|
+
# Named like the file compinit autoloads (_<prog>), so that the first run replaces the autoload stub
|
|
26
|
+
function = '_' + prog
|
|
27
|
+
code = ''
|
|
28
|
+
if refresh is not None:
|
|
29
|
+
binary, path, regenerate = (script.quote(refresh[0]), script.quote(refresh[1]), shlex.join(refresh[2]))
|
|
30
|
+
code = f'''# Regenerate this script when {prog} changed since
|
|
31
|
+
local bin=${{commands[{prog}]:A}}
|
|
32
|
+
if [[ -z $_docopt_refreshed && -n $bin && ( $bin != {binary} || $bin -nt {path} || $bin -ot {path} ) ]]; then
|
|
33
|
+
local _docopt_refreshed=1
|
|
34
|
+
if {regenerate} >/dev/null 2>&1; then
|
|
35
|
+
source {path}
|
|
36
|
+
{function} "$@"
|
|
37
|
+
return
|
|
38
|
+
fi
|
|
39
|
+
fi'''
|
|
40
|
+
return script.render('zsh.sh', prog, function, compiled, source_code, 1, True, code)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#compdef "PROG"
|
|
2
|
+
# zsh completion for "PROG", generated by docopt-completion
|
|
3
|
+
|
|
4
|
+
FN() {
|
|
5
|
+
"REFRESH"
|
|
6
|
+
"TABLES"
|
|
7
|
+
local state=1 expect=0 positional=0 word opt pos i n
|
|
8
|
+
local -a used
|
|
9
|
+
for word in "${(@Q)words[2,CURRENT-1]}"; do
|
|
10
|
+
if (( ! state )); then
|
|
11
|
+
return 1
|
|
12
|
+
elif (( expect )); then
|
|
13
|
+
expect=0
|
|
14
|
+
elif (( ! positional )) && [[ $word = -- ]]; then
|
|
15
|
+
positional=1
|
|
16
|
+
state=$st_sep[state]
|
|
17
|
+
elif (( ! positional )) && [[ $word = --* ]]; then
|
|
18
|
+
if HELPERS_find "${word%%=*}" 1; then
|
|
19
|
+
used+=($opt)
|
|
20
|
+
(( opt_arg[opt] )) && [[ $word != *=* ]] && expect=$opt
|
|
21
|
+
fi
|
|
22
|
+
elif (( ! positional )) && [[ $word = -?* ]]; then
|
|
23
|
+
for (( pos=2; pos<=$#word; pos++ )); do
|
|
24
|
+
if HELPERS_find "-$word[pos]" 0; then
|
|
25
|
+
used+=($opt)
|
|
26
|
+
if (( opt_arg[opt] )); then
|
|
27
|
+
(( pos == $#word )) && expect=$opt
|
|
28
|
+
break
|
|
29
|
+
fi
|
|
30
|
+
fi
|
|
31
|
+
done
|
|
32
|
+
else
|
|
33
|
+
pos=$st_other[state]
|
|
34
|
+
for (( i=1; i<=$#cmd_state; i++ )); do
|
|
35
|
+
if (( cmd_state[i] == state )) && [[ $cmd_name[i] = "$word" ]]; then
|
|
36
|
+
pos=$cmd_next[i]
|
|
37
|
+
break
|
|
38
|
+
fi
|
|
39
|
+
done
|
|
40
|
+
state=$pos
|
|
41
|
+
fi
|
|
42
|
+
done
|
|
43
|
+
(( state )) || return 1
|
|
44
|
+
|
|
45
|
+
if (( expect )); then
|
|
46
|
+
HELPERS_source $opt_src[expect]
|
|
47
|
+
return
|
|
48
|
+
elif (( ! positional )) && [[ $PREFIX = --*=* ]]; then
|
|
49
|
+
HELPERS_find "${PREFIX%%=*}" 1 && (( opt_arg[opt] )) || return 1
|
|
50
|
+
compset -P '*='
|
|
51
|
+
HELPERS_source $opt_src[opt]
|
|
52
|
+
return
|
|
53
|
+
fi
|
|
54
|
+
local -a options commands
|
|
55
|
+
if (( ! positional )); then
|
|
56
|
+
for i in ${=st_opts[state]}; do
|
|
57
|
+
(( $used[(Ie)$i] && ! opt_rep[i] )) && continue
|
|
58
|
+
for n in ${=opt_names[i]}; do
|
|
59
|
+
options+=("${n//:/\\:}${opt_desc[i]:+:$opt_desc[i]}")
|
|
60
|
+
done
|
|
61
|
+
done
|
|
62
|
+
fi
|
|
63
|
+
for (( i=1; i<=$#cmd_state; i++ )); do
|
|
64
|
+
(( cmd_state[i] == state )) && commands+=("${cmd_name[i]//:/\\:}${cmd_desc[i]:+:$cmd_desc[i]}")
|
|
65
|
+
done
|
|
66
|
+
if (( ! positional )) && [[ $PREFIX = -* ]] || (( ! $#commands && ! st_other[state] )); then
|
|
67
|
+
(( $#options )) && _describe -t options option options
|
|
68
|
+
return
|
|
69
|
+
fi
|
|
70
|
+
local ret=1
|
|
71
|
+
(( $#commands )) && _describe -t commands command commands && ret=0
|
|
72
|
+
HELPERS_source $st_src[state] && ret=0
|
|
73
|
+
return ret
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Sets $opt to the option named $1, matching long options by prefix when $2 is 1
|
|
77
|
+
HELPERS_find() {
|
|
78
|
+
local i n first
|
|
79
|
+
local -a matches
|
|
80
|
+
for (( i=1; i<=$#opt_names; i++ )); do
|
|
81
|
+
for n in ${=opt_names[i]}; do
|
|
82
|
+
[[ $n = "$1" ]] && matches+=($i)
|
|
83
|
+
done
|
|
84
|
+
done
|
|
85
|
+
if (( ! $#matches && $2 )); then
|
|
86
|
+
for (( i=1; i<=$#opt_names; i++ )); do
|
|
87
|
+
n=${${=opt_names[i]}[1]}
|
|
88
|
+
if [[ $n = "$1"* ]]; then
|
|
89
|
+
[[ -n $first && $first != "$n" ]] && return 1
|
|
90
|
+
first=$n
|
|
91
|
+
matches+=($i)
|
|
92
|
+
fi
|
|
93
|
+
done
|
|
94
|
+
fi
|
|
95
|
+
(( $#matches )) || return 1
|
|
96
|
+
opt=$matches[1]
|
|
97
|
+
for i in $matches; do
|
|
98
|
+
if (( ${${=st_opts[state]}[(Ie)$i]} )); then
|
|
99
|
+
opt=$i
|
|
100
|
+
return 0
|
|
101
|
+
fi
|
|
102
|
+
done
|
|
103
|
+
return 0
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Adds the candidates of source set $1
|
|
107
|
+
HELPERS_source() {
|
|
108
|
+
local ret=1
|
|
109
|
+
case $1 in
|
|
110
|
+
"SOURCES"
|
|
111
|
+
esac
|
|
112
|
+
return ret
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if [[ $zsh_eval_context[-1] = loadautofunc ]]; then
|
|
116
|
+
FN "$@"
|
|
117
|
+
else
|
|
118
|
+
compdef FN "PROG"
|
|
119
|
+
fi
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "docopt-completion"
|
|
3
|
+
description = "A completion for the docopt language"
|
|
4
|
+
version = "0.1.0"
|
|
5
|
+
authors = ["Anders Ingemann <anders@ingemann.de>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "docs/README.pypi.md"
|
|
8
|
+
homepage = "https://github.com/andsens/docopt.completion"
|
|
9
|
+
repository = "https://github.com/andsens/docopt.completion"
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
]
|
|
16
|
+
packages = [
|
|
17
|
+
{ include = "docopt_completion" },
|
|
18
|
+
{ include = "docopt_completion/py.typed" },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.flake8]
|
|
22
|
+
max-line-length = 120
|
|
23
|
+
ignore = "E111,E114,E121,W503,E302,E303,E305"
|
|
24
|
+
|
|
25
|
+
[tool.poetry.dependencies]
|
|
26
|
+
python = ">=3.11.0,<4.0"
|
|
27
|
+
docopt = "^0.6.2"
|
|
28
|
+
termcolor = "^1.1.0"
|
|
29
|
+
pyyaml = "^6.0"
|
|
30
|
+
docopt-parser = "^0.0.1"
|
|
31
|
+
|
|
32
|
+
[tool.poetry.dev-dependencies]
|
|
33
|
+
flake8 = "^6.0.0"
|
|
34
|
+
flake8-pyproject = "^1.1.0.post0"
|
|
35
|
+
pytest = "^7.1.1"
|
|
36
|
+
pyright = "^1.1.238"
|
|
37
|
+
types-docopt = "^0.6.11"
|
|
38
|
+
|
|
39
|
+
[tool.poetry.scripts]
|
|
40
|
+
docopt-completion = "docopt_completion.__main__:main"
|
|
41
|
+
|
|
42
|
+
[tool.pyright]
|
|
43
|
+
include = ["docopt_completion", "tests"]
|
|
44
|
+
typeCheckingMode = "strict"
|
|
45
|
+
exclude = [ ".venv" ]
|
|
46
|
+
venvPath = "."
|
|
47
|
+
venv = ".venv"
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["poetry_core>=1.0.0"]
|
|
51
|
+
build-backend = "poetry.core.masonry.api"
|