From d4df3f67b650ceec05c7c0137e7e0dc4a5c26389 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 9 Aug 2022 14:11:20 +0530 Subject: [PATCH] Allow specifying completion for command line arguments that expect paths --- kitty/cli.py | 24 ++++++++++-- kitty/complete.py | 97 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/kitty/cli.py b/kitty/cli.py index 825e09b12..119b92680 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -2,6 +2,7 @@ # License: GPL v3 Copyright: 2017, Kovid Goyal import re +import shlex import sys from collections import deque from typing import ( @@ -26,6 +27,7 @@ class OptionDict(TypedDict): type: str default: Optional[str] condition: bool + completion: Dict[str, str] CONFIG_HELP = '''\ @@ -172,7 +174,7 @@ def parse_option_spec(spec: Optional[str] = None) -> Tuple[OptionSpecSeq, Option mpat = re.compile('([a-z]+)=(.+)') current_cmd: OptionDict = { 'dest': '', 'aliases': frozenset(), 'help': '', 'choices': frozenset(), - 'type': '', 'condition': False, 'default': None + 'type': '', 'condition': False, 'default': None, 'completion': {}, } empty_cmd = current_cmd @@ -189,7 +191,7 @@ def parse_option_spec(spec: Optional[str] = None) -> Tuple[OptionSpecSeq, Option current_cmd = { 'dest': parts[0][2:].replace('-', '_'), 'aliases': frozenset(parts), 'help': '', 'choices': frozenset(), 'type': '', - 'default': None, 'condition': True + 'default': None, 'condition': True, 'completion': {} } state = METADATA continue @@ -212,6 +214,11 @@ def parse_option_spec(spec: Optional[str] = None) -> Tuple[OptionSpecSeq, Option current_cmd['dest'] = v elif k == 'condition': 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: if line: 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]' elif otype == 'list': t = 'typing.Sequence[str]' + elif otype == 'path': + t = 'str' elif otype in ('choice', 'choices'): if 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( val, emph(alias), ', '.join(choices))) self.values_map[name] = val + elif typ == 'path': + self.values_map[name] = val elif typ in nmap: f = nmap[typ] try: @@ -616,6 +627,8 @@ Syntax: :italic:`name=value`. For example: :option:`{appname} -o` font_size=20 --directory --working-directory -d default=. +type=path +completion=type:directory Change to the specified directory when launching. @@ -626,9 +639,12 @@ Detach from the controlling terminal, if any. --session +type=path +completion=ext:session relative:conf group:"Session files" 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 -an example. +an example. Environment variables are expanded, relative paths are resolved relative +to the kitty configuration directory. --hold @@ -732,6 +748,8 @@ present in the main font. --watcher +type=path +completion=ext:py relative:conf group:"Watcher files" This option is deprecated in favor of the :opt:`watcher` option in :file:`{conf_name}.conf` and should not be used. diff --git a/kitty/complete.py b/kitty/complete.py index c7105233c..eba2c5044 100644 --- a/kitty/complete.py +++ b/kitty/complete.py @@ -17,7 +17,7 @@ from .cli import ( OptionDict, OptionSpecSeq, options_for_completion, parse_option_spec, 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 .rc.base import all_command_names, command_for_name 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') 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': if ':' not in prefix: k = 'Address type' @@ -564,9 +558,98 @@ def complete_files_and_dirs( 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: if opt['choices']: 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: