From 26d4f5bcc983969567b60c8c3f136869aa2bab96 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 16 Sep 2022 15:05:20 +0530 Subject: [PATCH] Use the new completionspec for rc cmds --- kitty/cli.py | 65 ++++++++++++++++++++++++++++----- kitty/complete.py | 52 ++++++++++---------------- kitty/rc/base.py | 25 ++++--------- kitty/rc/goto_layout.py | 5 ++- kitty/rc/set_colors.py | 4 +- kitty/rc/set_enabled_layouts.py | 4 +- 6 files changed, 91 insertions(+), 64 deletions(-) diff --git a/kitty/cli.py b/kitty/cli.py index 261ed5536..28697687e 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -7,6 +7,7 @@ import shlex import socket import sys from collections import deque +from enum import Enum, auto from typing import ( Any, Callable, Dict, FrozenSet, Iterator, List, Match, Optional, Sequence, Tuple, Type, TypeVar, Union, cast @@ -15,8 +16,8 @@ from typing import ( from .cli_stub import CLIOptions from .conf.utils import resolve_config from .constants import ( - appname, clear_handled_signals, config_dir, defconf, is_macos, str_version, - website_url, default_pager_for_help + appname, clear_handled_signals, config_dir, default_pager_for_help, + defconf, is_macos, str_version, website_url ) from .fast_data_types import wcswidth from .options.types import Options as KittyOpts @@ -24,6 +25,55 @@ from .types import run_once from .typing import BadLineType, TypedDict +class CompletionType(Enum): + file = auto() + directory = auto() + keyword = auto() + none = auto() + + +class CompletionRelativeTo(Enum): + cwd = auto() + config_dir = auto() + + +class CompletionSpec: + + type: CompletionType = CompletionType.none + kwds: Sequence[str] = () + extensions: Sequence[str] = () + mime_patterns: Sequence[str] = () + group: str = '' + relative_to: CompletionRelativeTo = CompletionRelativeTo.cwd + + @staticmethod + def from_string(raw: str) -> 'CompletionSpec': + self = CompletionSpec() + for x in shlex.split(raw): + ck, vv = x.split(':', 1) + if ck == 'type': + self.type = getattr(CompletionType, vv) + elif ck == 'kwds': + self.kwds = tuple(vv.split(',')) + elif ck == 'ext': + self.extensions = tuple(vv.split(',')) + elif ck == 'group': + self.group = vv + elif ck == 'mime': + self.mime_patterns = tuple(vv.split(',')) + elif ck == 'relative': + if vv == 'conf': + self.relative_to = CompletionRelativeTo.config_dir + else: + raise ValueError(f'Unknown completion relative to value: {vv}') + else: + raise KeyError(f'Unknown completion property: {ck}') + return self + + def as_go_code(self, go_name: str) -> Iterator[str]: + pass + + class OptionDict(TypedDict): dest: str name: str @@ -33,7 +83,7 @@ class OptionDict(TypedDict): type: str default: Optional[str] condition: bool - completion: Dict[str, str] + completion: CompletionSpec def serialize_as_go_string(x: str) -> str: @@ -343,7 +393,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, 'completion': {}, 'name': '' + 'type': '', 'condition': False, 'default': None, 'completion': CompletionSpec(), 'name': '' } empty_cmd = current_cmd @@ -364,7 +414,7 @@ def parse_option_spec(spec: Optional[str] = None) -> Tuple[OptionSpecSeq, Option current_cmd = { 'dest': defdest, 'aliases': frozenset(parts), 'help': '', 'choices': frozenset(), 'type': '', 'name': defdest, - 'default': None, 'condition': True, 'completion': {} + 'default': None, 'condition': True, 'completion': CompletionSpec(), } state = METADATA continue @@ -391,10 +441,7 @@ def parse_option_spec(spec: Optional[str] = None) -> Tuple[OptionSpecSeq, Option 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 + current_cmd['completion'] = CompletionSpec.from_string(v) elif state is HELP: if line: current_indent = indent_of_line(line) diff --git a/kitty/complete.py b/kitty/complete.py index 17422a836..dd53f9ebd 100644 --- a/kitty/complete.py +++ b/kitty/complete.py @@ -14,12 +14,13 @@ from kittens.runner import ( ) from .cli import ( - OptionDict, options_for_completion, parse_option_spec, prettify + CompletionSpec, CompletionType, OptionDict, options_for_completion, + parse_option_spec, prettify ) -from .remote_control import global_options_spec from .constants import config_dir, shell_integration_dir 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 +from .remote_control import global_options_spec from .shell import options_for_cmd from .types import run_once from .utils import screen_size_function @@ -491,19 +492,7 @@ def global_options_for_remote_cmd() -> Dict[str, OptionDict]: def complete_remote_command(ans: Completions, cmd_name: str, words: Sequence[str], new_word: bool) -> None: aliases, alias_map = options_for_cmd(cmd_name) - try: - args_completion = command_for_name(cmd_name).args.completion - except KeyError: - return args_completer: CompleteArgsFunc = basic_option_arg_completer - if args_completion: - if 'files' in args_completion: - title, matchers = args_completion['files'] - if isinstance(matchers, tuple): - args_completer = remote_files_completer(title, matchers) - elif 'names' in args_completion: - title, q = args_completion['names'] - args_completer = remote_args_completer(title, q() if callable(q) else q) complete_alias_map(ans, words, new_word, alias_map, complete_args=args_completer) @@ -566,17 +555,17 @@ 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]: +def filter_files_from_completion_spec(spec: CompletionSpec) -> Callable[['os.DirEntry[str]', str], bool]: - if 'ext' in spec: - extensions = frozenset(os.extsep + x.lower() for x in spec['ext'].split(',')) + if spec.extensions: + extensions = frozenset(os.extsep + x.lower() for x in spec.extensions) else: extensions = frozenset() - if 'mime' in spec: + if spec.mime_patterns: import re from fnmatch import translate - mimes = tuple(re.compile(translate(x)) for x in spec['mime'].split(',')) + mimes = tuple(re.compile(translate(x)) for x in spec.mime_patterns) from .guess_mime_type import guess_type else: mimes = () @@ -602,14 +591,12 @@ def filter_files_from_completion_spec(spec: Dict[str, str]) -> Callable[['os.Dir return check_file -def complete_file_path(ans: Completions, spec: Dict[str, str], prefix: str, only_dirs: bool = False) -> None: +def complete_file_path(ans: Completions, spec: CompletionSpec, 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: + if spec.relative_to is spec.relative_to.__class__.cwd: relative_to = os.getcwd() + else: + relative_to = config_dir src_dir = relative_to check_against = prefix prefix_result_with = prefix @@ -641,26 +628,25 @@ def complete_file_path(ans: Completions, spec: Dict[str, str], prefix: str, only 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) + ans.add_match_group(spec.group or 'Files', files, is_files=True) def complete_path(ans: Completions, opt: OptionDict, prefix: str) -> None: spec = opt['completion'] - t = spec['type'] - if 'kwds' in spec: - kwds = [x for x in spec['kwds'].split(',') if x.startswith(prefix)] + if spec.kwds: + kwds = [x for x in spec.kwds if x.startswith(prefix)] if kwds: ans.add_match_group('Keywords', kwds) - if t == 'file': + if spec.type is CompletionType.file: complete_file_path(ans, spec, prefix) - elif t == 'directory': + elif spec.type is CompletionType.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['completion'].get('type') in ('file', 'directory'): + elif opt['completion'].type is not CompletionType.none: complete_path(ans, opt, prefix) diff --git a/kitty/rc/base.py b/kitty/rc/base.py index 2aa91432d..4bd7a41a9 100644 --- a/kitty/rc/base.py +++ b/kitty/rc/base.py @@ -6,10 +6,10 @@ from contextlib import suppress from dataclasses import dataclass from typing import ( TYPE_CHECKING, Any, Callable, Dict, FrozenSet, Iterable, Iterator, - List, NoReturn, Optional, Set, Tuple, Type, Union, cast, Mapping + List, NoReturn, Optional, Set, Tuple, Type, Union, cast ) -from kitty.cli import get_defaults_from_seq, parse_args, parse_option_spec +from kitty.cli import get_defaults_from_seq, parse_args, parse_option_spec, CompletionSpec from kitty.cli_stub import RCOptions as R from kitty.constants import appname, list_kitty_resources, running_in_kitty from kitty.types import AsyncResponse @@ -88,7 +88,8 @@ CmdGenerator = Iterator[CmdReturnType] PayloadType = Optional[Union[CmdReturnType, CmdGenerator]] PayloadGetType = PayloadGetter ArgsType = List[str] -ImageCompletion: Dict[str, Tuple[str, Tuple[str, ...]]] = {'files': ('Images', ('*.png', '*.jpg', '*.jpeg', '*.webp', '*.gif', '*.bmp', '*.tiff'))} +ImageCompletion = CompletionSpec.from_string('type:file group:"Images"') +ImageCompletion.extensions = 'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'tiff' MATCH_WINDOW_OPTION = '''\ @@ -184,7 +185,7 @@ class ArgsHandling: json_field: str = '' count: Optional[int] = None spec: str = '' - completion: Optional[Mapping[str, Tuple[str, Union[Callable[[], Iterable[str]], Tuple[str, ...]]]]] = None + completion: CompletionSpec = CompletionSpec() value_if_unspecified: Tuple[str, ...] = () minimum_count: int = -1 first_rest: Optional[Tuple[str, str]] = None @@ -198,24 +199,11 @@ class ArgsHandling: return self.count def as_go_completion_code(self, go_name: str) -> Iterator[str]: - from ..cli import serialize_as_go_string c = self.args_count if c is not None: yield f'{go_name}.Stop_processing_at_arg = {c}' if self.completion: - for k, v in self.completion.items(): - if k == 'files': - title, pats = v - assert isinstance(pats, tuple) - gpats = (f'"{serialize_as_go_string(x)}"' for x in pats) - yield f'{go_name}.Completion_for_arg = fnmatch_completer("{serialize_as_go_string(title)}", {", ".join(gpats)})' - elif k == 'names': - title, gen = v - assert callable(gen) - gpats = (f'"{serialize_as_go_string(x)}"' for x in gen()) - yield f'{go_name}.Completion_for_arg = names_completer("{serialize_as_go_string(title)}", {", ".join(gpats)})' - else: - raise KeyError(f'Unknown args completion type: {k}') + yield from self.completion.as_go_code(go_name) def as_go_code(self, cmd_name: str, field_types: Dict[str, str], handled_fields: Set[str]) -> Iterator[str]: c = self.args_count @@ -313,6 +301,7 @@ class StreamInFlight: class RemoteCommand: Args = ArgsHandling + CompletionSpec = CompletionSpec name: str = '' short_desc: str = '' diff --git a/kitty/rc/goto_layout.py b/kitty/rc/goto_layout.py index d4d442777..e5141a3a7 100644 --- a/kitty/rc/goto_layout.py +++ b/kitty/rc/goto_layout.py @@ -30,7 +30,10 @@ class GotoLayout(RemoteCommand): ' You can use special match value :code:`all` to set the layout in all tabs.' ) options_spec = MATCH_TAB_OPTION - args = RemoteCommand.Args(spec='LAYOUT_NAME', count=1, completion={'names': ('Layouts', layout_names)}, json_field='layout', args_choices=layout_names) + args = RemoteCommand.Args( + spec='LAYOUT_NAME', count=1, json_field='layout', + completion=RemoteCommand.CompletionSpec.from_string('type:keyword group:"Layout" kwds:' + ','.join(layout_names())), + args_choices=layout_names) def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: if len(args) != 1: diff --git a/kitty/rc/set_colors.py b/kitty/rc/set_colors.py index 7115d1fb0..ceb3ed4b0 100644 --- a/kitty/rc/set_colors.py +++ b/kitty/rc/set_colors.py @@ -89,8 +89,8 @@ type=bool-set Restore all colors to the values they had at kitty startup. Note that if you specify this option, any color arguments are ignored and :option:`kitty @ set-colors --configured` and :option:`kitty @ set-colors --all` are implied. ''' + '\n\n' + MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--match-tab -t') - args = RemoteCommand.Args(spec='COLOR_OR_FILE ...', json_field='colors', special_parse='parse_colors_and_files(args)', completion={ - 'files': ('CONF files', ('*.conf',))}) + args = RemoteCommand.Args(spec='COLOR_OR_FILE ...', json_field='colors', special_parse='parse_colors_and_files(args)', + completion=RemoteCommand.CompletionSpec.from_string('type:file group:"CONF files", ext:conf')) def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: final_colors: Dict[str, Optional[int]] = {} diff --git a/kitty/rc/set_enabled_layouts.py b/kitty/rc/set_enabled_layouts.py index 272122702..7f45a0fce 100644 --- a/kitty/rc/set_enabled_layouts.py +++ b/kitty/rc/set_enabled_layouts.py @@ -42,7 +42,9 @@ Change the default enabled layout value so that the new value takes effect for a as well. ''' args = RemoteCommand.Args( - spec='LAYOUT ...', minimum_count=1, json_field='layouts', completion={'names': ('Layouts', layout_names)}, args_choices=layout_names) + spec='LAYOUT ...', minimum_count=1, json_field='layouts', + completion=RemoteCommand.CompletionSpec.from_string('type:keyword group:"Layout" kwds:' + ','.join(layout_names())), + args_choices=layout_names) def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: if len(args) < 1: