Allow specifying completion for command line arguments that expect paths
This commit is contained in:
parent
f15a2f0c1f
commit
d4df3f67b6
24
kitty/cli.py
24
kitty/cli.py
@ -2,6 +2,7 @@
|
|||||||
# License: GPL v3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
|
# License: GPL v3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import (
|
from typing import (
|
||||||
@ -26,6 +27,7 @@ class OptionDict(TypedDict):
|
|||||||
type: str
|
type: str
|
||||||
default: Optional[str]
|
default: Optional[str]
|
||||||
condition: bool
|
condition: bool
|
||||||
|
completion: Dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
CONFIG_HELP = '''\
|
CONFIG_HELP = '''\
|
||||||
@ -172,7 +174,7 @@ def parse_option_spec(spec: Optional[str] = None) -> Tuple[OptionSpecSeq, Option
|
|||||||
mpat = re.compile('([a-z]+)=(.+)')
|
mpat = re.compile('([a-z]+)=(.+)')
|
||||||
current_cmd: OptionDict = {
|
current_cmd: OptionDict = {
|
||||||
'dest': '', 'aliases': frozenset(), 'help': '', 'choices': frozenset(),
|
'dest': '', 'aliases': frozenset(), 'help': '', 'choices': frozenset(),
|
||||||
'type': '', 'condition': False, 'default': None
|
'type': '', 'condition': False, 'default': None, 'completion': {},
|
||||||
}
|
}
|
||||||
empty_cmd = current_cmd
|
empty_cmd = current_cmd
|
||||||
|
|
||||||
@ -189,7 +191,7 @@ def parse_option_spec(spec: Optional[str] = None) -> Tuple[OptionSpecSeq, Option
|
|||||||
current_cmd = {
|
current_cmd = {
|
||||||
'dest': parts[0][2:].replace('-', '_'), 'aliases': frozenset(parts), 'help': '',
|
'dest': parts[0][2:].replace('-', '_'), 'aliases': frozenset(parts), 'help': '',
|
||||||
'choices': frozenset(), 'type': '',
|
'choices': frozenset(), 'type': '',
|
||||||
'default': None, 'condition': True
|
'default': None, 'condition': True, 'completion': {}
|
||||||
}
|
}
|
||||||
state = METADATA
|
state = METADATA
|
||||||
continue
|
continue
|
||||||
@ -212,6 +214,11 @@ def parse_option_spec(spec: Optional[str] = None) -> Tuple[OptionSpecSeq, Option
|
|||||||
current_cmd['dest'] = v
|
current_cmd['dest'] = v
|
||||||
elif k == 'condition':
|
elif k == 'condition':
|
||||||
current_cmd['condition'] = bool(eval(v))
|
current_cmd['condition'] = bool(eval(v))
|
||||||
|
elif k == 'completion':
|
||||||
|
cv = current_cmd['completion'] = {}
|
||||||
|
for x in shlex.split(v):
|
||||||
|
ck, vv = x.split(':', 1)
|
||||||
|
cv[ck] = vv
|
||||||
elif state is HELP:
|
elif state is HELP:
|
||||||
if line:
|
if line:
|
||||||
spc = '' if current_cmd['help'].endswith('\n') else ' '
|
spc = '' if current_cmd['help'].endswith('\n') else ' '
|
||||||
@ -444,6 +451,8 @@ def as_type_stub(seq: OptionSpecSeq, disabled: OptionSpecSeq, class_name: str, e
|
|||||||
t = 'typing.Optional[str]'
|
t = 'typing.Optional[str]'
|
||||||
elif otype == 'list':
|
elif otype == 'list':
|
||||||
t = 'typing.Sequence[str]'
|
t = 'typing.Sequence[str]'
|
||||||
|
elif otype == 'path':
|
||||||
|
t = 'str'
|
||||||
elif otype in ('choice', 'choices'):
|
elif otype in ('choice', 'choices'):
|
||||||
if opt['choices']:
|
if opt['choices']:
|
||||||
t = 'typing.Literal[{}]'.format(','.join(f'{x!r}' for x in opt['choices']))
|
t = 'typing.Literal[{}]'.format(','.join(f'{x!r}' for x in opt['choices']))
|
||||||
@ -526,6 +535,8 @@ class Options:
|
|||||||
raise SystemExit('{} is not a valid value for the {} option. Valid values are: {}'.format(
|
raise SystemExit('{} is not a valid value for the {} option. Valid values are: {}'.format(
|
||||||
val, emph(alias), ', '.join(choices)))
|
val, emph(alias), ', '.join(choices)))
|
||||||
self.values_map[name] = val
|
self.values_map[name] = val
|
||||||
|
elif typ == 'path':
|
||||||
|
self.values_map[name] = val
|
||||||
elif typ in nmap:
|
elif typ in nmap:
|
||||||
f = nmap[typ]
|
f = nmap[typ]
|
||||||
try:
|
try:
|
||||||
@ -616,6 +627,8 @@ Syntax: :italic:`name=value`. For example: :option:`{appname} -o` font_size=20
|
|||||||
|
|
||||||
--directory --working-directory -d
|
--directory --working-directory -d
|
||||||
default=.
|
default=.
|
||||||
|
type=path
|
||||||
|
completion=type:directory
|
||||||
Change to the specified directory when launching.
|
Change to the specified directory when launching.
|
||||||
|
|
||||||
|
|
||||||
@ -626,9 +639,12 @@ Detach from the controlling terminal, if any.
|
|||||||
|
|
||||||
|
|
||||||
--session
|
--session
|
||||||
|
type=path
|
||||||
|
completion=ext:session relative:conf group:"Session files"
|
||||||
Path to a file containing the startup :italic:`session` (tabs, windows, layout,
|
Path to a file containing the startup :italic:`session` (tabs, windows, layout,
|
||||||
programs). Use - to read from STDIN. See the :file:`README` file for details and
|
programs). Use - to read from STDIN. See the :file:`README` file for details and
|
||||||
an example.
|
an example. Environment variables are expanded, relative paths are resolved relative
|
||||||
|
to the kitty configuration directory.
|
||||||
|
|
||||||
|
|
||||||
--hold
|
--hold
|
||||||
@ -732,6 +748,8 @@ present in the main font.
|
|||||||
|
|
||||||
|
|
||||||
--watcher
|
--watcher
|
||||||
|
type=path
|
||||||
|
completion=ext:py relative:conf group:"Watcher files"
|
||||||
This option is deprecated in favor of the :opt:`watcher` option in
|
This option is deprecated in favor of the :opt:`watcher` option in
|
||||||
:file:`{conf_name}.conf` and should not be used.
|
:file:`{conf_name}.conf` and should not be used.
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ from .cli import (
|
|||||||
OptionDict, OptionSpecSeq, options_for_completion, parse_option_spec,
|
OptionDict, OptionSpecSeq, options_for_completion, parse_option_spec,
|
||||||
prettify
|
prettify
|
||||||
)
|
)
|
||||||
from .constants import shell_integration_dir
|
from .constants import shell_integration_dir, config_dir
|
||||||
from .fast_data_types import truncate_point_for_length, wcswidth
|
from .fast_data_types import truncate_point_for_length, wcswidth
|
||||||
from .rc.base import all_command_names, command_for_name
|
from .rc.base import all_command_names, command_for_name
|
||||||
from .shell import options_for_cmd
|
from .shell import options_for_cmd
|
||||||
@ -395,12 +395,6 @@ def complete_kitty_cli_arg(ans: Completions, opt: Optional[OptionDict], prefix:
|
|||||||
return x.lower().endswith('.conf')
|
return x.lower().endswith('.conf')
|
||||||
|
|
||||||
complete_files_and_dirs(ans, prefix, files_group_name='Config files', predicate=is_conf_file)
|
complete_files_and_dirs(ans, prefix, files_group_name='Config files', predicate=is_conf_file)
|
||||||
elif dest == 'session':
|
|
||||||
complete_files_and_dirs(ans, prefix, files_group_name='Session files')
|
|
||||||
elif dest == 'watcher':
|
|
||||||
complete_files_and_dirs(ans, prefix, files_group_name='Watcher files')
|
|
||||||
elif dest == 'directory':
|
|
||||||
complete_dirs(ans, prefix)
|
|
||||||
elif dest == 'listen_on':
|
elif dest == 'listen_on':
|
||||||
if ':' not in prefix:
|
if ':' not in prefix:
|
||||||
k = 'Address type'
|
k = 'Address type'
|
||||||
@ -564,9 +558,98 @@ def complete_files_and_dirs(
|
|||||||
ans.add_match_group(files_group_name, files, is_files=True)
|
ans.add_match_group(files_group_name, files, is_files=True)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_files_from_completion_spec(spec: Dict[str, str]) -> Callable[['os.DirEntry[str]', str], bool]:
|
||||||
|
|
||||||
|
if 'ext' in spec:
|
||||||
|
extensions = frozenset(os.extsep + x.lower() for x in spec['ext'].split(','))
|
||||||
|
else:
|
||||||
|
extensions = frozenset()
|
||||||
|
|
||||||
|
if 'mime' in spec:
|
||||||
|
from fnmatch import translate
|
||||||
|
import re
|
||||||
|
mimes = tuple(re.compile(translate(x)) for x in spec['mime'].split(','))
|
||||||
|
from .guess_mime_type import guess_type
|
||||||
|
else:
|
||||||
|
mimes = ()
|
||||||
|
|
||||||
|
if mimes or extensions:
|
||||||
|
def check_file(x: 'os.DirEntry[str]', result: str) -> bool:
|
||||||
|
if extensions:
|
||||||
|
q = result.lower()
|
||||||
|
for ext in extensions:
|
||||||
|
if q.endswith(ext):
|
||||||
|
return True
|
||||||
|
if mimes:
|
||||||
|
mq = guess_type(result)
|
||||||
|
if mq:
|
||||||
|
for mime in mimes:
|
||||||
|
if mime.match(mq):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
def check_file(x: 'os.DirEntry[str]', result: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return check_file
|
||||||
|
|
||||||
|
|
||||||
|
def complete_file_path(ans: Completions, spec: Dict[str, str], prefix: str, only_dirs: bool = False) -> None:
|
||||||
|
prefix = prefix.replace(r'\ ', ' ')
|
||||||
|
relative_to = spec.get('relative', '')
|
||||||
|
if relative_to:
|
||||||
|
if relative_to == 'conf':
|
||||||
|
relative_to = config_dir
|
||||||
|
else:
|
||||||
|
relative_to = os.getcwd()
|
||||||
|
src_dir = relative_to
|
||||||
|
check_against = prefix
|
||||||
|
prefix_result_with = prefix
|
||||||
|
files, dirs = [], []
|
||||||
|
if prefix:
|
||||||
|
expanded_prefix = os.path.expandvars(os.path.expanduser(prefix))
|
||||||
|
check_against = os.path.basename(expanded_prefix)
|
||||||
|
prefix_result_with = os.path.dirname(expanded_prefix).rstrip(os.sep) + os.sep
|
||||||
|
if os.path.isabs(expanded_prefix):
|
||||||
|
src_dir = os.path.dirname(expanded_prefix)
|
||||||
|
elif os.sep in expanded_prefix or (os.altsep and os.altsep in expanded_prefix):
|
||||||
|
src_dir = os.path.join(relative_to, os.path.dirname(expanded_prefix))
|
||||||
|
else:
|
||||||
|
prefix_result_with = ''
|
||||||
|
try:
|
||||||
|
items: Iterable['os.DirEntry[str]'] = os.scandir(src_dir)
|
||||||
|
except OSError:
|
||||||
|
items = ()
|
||||||
|
check_file = filter_files_from_completion_spec(spec)
|
||||||
|
for x in items:
|
||||||
|
if not x.name.startswith(check_against):
|
||||||
|
continue
|
||||||
|
result = prefix_result_with + x.name
|
||||||
|
if x.is_dir():
|
||||||
|
dirs.append(result.rstrip(os.sep) + os.sep)
|
||||||
|
else:
|
||||||
|
if check_file(x, result):
|
||||||
|
files.append(result)
|
||||||
|
if dirs:
|
||||||
|
ans.add_match_group('Directories', dirs, trailing_space=False, is_files=True)
|
||||||
|
if not only_dirs and files:
|
||||||
|
ans.add_match_group(spec.get('group') or 'Files', files, is_files=True)
|
||||||
|
|
||||||
|
|
||||||
|
def complete_path(ans: Completions, opt: OptionDict, prefix: str) -> None:
|
||||||
|
spec = opt['completion']
|
||||||
|
t = spec.get('type', 'file')
|
||||||
|
if t == 'file':
|
||||||
|
complete_file_path(ans, spec, prefix)
|
||||||
|
elif t == 'directory':
|
||||||
|
complete_file_path(ans, spec, prefix, only_dirs=True)
|
||||||
|
|
||||||
|
|
||||||
def complete_basic_option_args(ans: Completions, opt: OptionDict, prefix: str) -> None:
|
def complete_basic_option_args(ans: Completions, opt: OptionDict, prefix: str) -> None:
|
||||||
if opt['choices']:
|
if opt['choices']:
|
||||||
ans.add_match_group(f'Choices for {opt["dest"]}', tuple(k for k in opt['choices'] if k.startswith(prefix)))
|
ans.add_match_group(f'Choices for {opt["dest"]}', tuple(k for k in opt['choices'] if k.startswith(prefix)))
|
||||||
|
elif opt['type'] == 'path':
|
||||||
|
complete_path(ans, opt, prefix)
|
||||||
|
|
||||||
|
|
||||||
def complete_dirs(ans: Completions, prefix: str = '') -> None:
|
def complete_dirs(ans: Completions, prefix: str = '') -> None:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user