Use the new completionspec for rc cmds

This commit is contained in:
Kovid Goyal
2022-09-16 15:05:20 +05:30
parent d84efe105c
commit 26d4f5bcc9
6 changed files with 91 additions and 64 deletions

View File

@@ -7,6 +7,7 @@ import shlex
import socket import socket
import sys import sys
from collections import deque from collections import deque
from enum import Enum, auto
from typing import ( from typing import (
Any, Callable, Dict, FrozenSet, Iterator, List, Match, Optional, Sequence, Any, Callable, Dict, FrozenSet, Iterator, List, Match, Optional, Sequence,
Tuple, Type, TypeVar, Union, cast Tuple, Type, TypeVar, Union, cast
@@ -15,8 +16,8 @@ from typing import (
from .cli_stub import CLIOptions from .cli_stub import CLIOptions
from .conf.utils import resolve_config from .conf.utils import resolve_config
from .constants import ( from .constants import (
appname, clear_handled_signals, config_dir, defconf, is_macos, str_version, appname, clear_handled_signals, config_dir, default_pager_for_help,
website_url, default_pager_for_help defconf, is_macos, str_version, website_url
) )
from .fast_data_types import wcswidth from .fast_data_types import wcswidth
from .options.types import Options as KittyOpts from .options.types import Options as KittyOpts
@@ -24,6 +25,55 @@ from .types import run_once
from .typing import BadLineType, TypedDict 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): class OptionDict(TypedDict):
dest: str dest: str
name: str name: str
@@ -33,7 +83,7 @@ class OptionDict(TypedDict):
type: str type: str
default: Optional[str] default: Optional[str]
condition: bool condition: bool
completion: Dict[str, str] completion: CompletionSpec
def serialize_as_go_string(x: str) -> str: 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]+)=(.+)') 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, 'completion': {}, 'name': '' 'type': '', 'condition': False, 'default': None, 'completion': CompletionSpec(), 'name': ''
} }
empty_cmd = current_cmd empty_cmd = current_cmd
@@ -364,7 +414,7 @@ def parse_option_spec(spec: Optional[str] = None) -> Tuple[OptionSpecSeq, Option
current_cmd = { current_cmd = {
'dest': defdest, 'aliases': frozenset(parts), 'help': '', 'dest': defdest, 'aliases': frozenset(parts), 'help': '',
'choices': frozenset(), 'type': '', 'name': defdest, 'choices': frozenset(), 'type': '', 'name': defdest,
'default': None, 'condition': True, 'completion': {} 'default': None, 'condition': True, 'completion': CompletionSpec(),
} }
state = METADATA state = METADATA
continue continue
@@ -391,10 +441,7 @@ def parse_option_spec(spec: Optional[str] = None) -> Tuple[OptionSpecSeq, Option
elif k == 'condition': elif k == 'condition':
current_cmd['condition'] = bool(eval(v)) current_cmd['condition'] = bool(eval(v))
elif k == 'completion': elif k == 'completion':
cv = current_cmd['completion'] = {} current_cmd['completion'] = CompletionSpec.from_string(v)
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:
current_indent = indent_of_line(line) current_indent = indent_of_line(line)

View File

@@ -14,12 +14,13 @@ from kittens.runner import (
) )
from .cli 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 .constants import config_dir, shell_integration_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
from .remote_control import global_options_spec
from .shell import options_for_cmd from .shell import options_for_cmd
from .types import run_once from .types import run_once
from .utils import screen_size_function 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: def complete_remote_command(ans: Completions, cmd_name: str, words: Sequence[str], new_word: bool) -> None:
aliases, alias_map = options_for_cmd(cmd_name) 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 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) 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) 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: if spec.extensions:
extensions = frozenset(os.extsep + x.lower() for x in spec['ext'].split(',')) extensions = frozenset(os.extsep + x.lower() for x in spec.extensions)
else: else:
extensions = frozenset() extensions = frozenset()
if 'mime' in spec: if spec.mime_patterns:
import re import re
from fnmatch import translate 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 from .guess_mime_type import guess_type
else: else:
mimes = () mimes = ()
@@ -602,14 +591,12 @@ def filter_files_from_completion_spec(spec: Dict[str, str]) -> Callable[['os.Dir
return check_file 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'\ ', ' ') prefix = prefix.replace(r'\ ', ' ')
relative_to = spec.get('relative', '') if spec.relative_to is spec.relative_to.__class__.cwd:
if relative_to:
if relative_to == 'conf':
relative_to = config_dir
else:
relative_to = os.getcwd() relative_to = os.getcwd()
else:
relative_to = config_dir
src_dir = relative_to src_dir = relative_to
check_against = prefix check_against = prefix
prefix_result_with = prefix prefix_result_with = prefix
@@ -641,26 +628,25 @@ def complete_file_path(ans: Completions, spec: Dict[str, str], prefix: str, only
if dirs: if dirs:
ans.add_match_group('Directories', dirs, trailing_space=False, is_files=True) ans.add_match_group('Directories', dirs, trailing_space=False, is_files=True)
if not only_dirs and files: 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: def complete_path(ans: Completions, opt: OptionDict, prefix: str) -> None:
spec = opt['completion'] spec = opt['completion']
t = spec['type'] if spec.kwds:
if 'kwds' in spec: kwds = [x for x in spec.kwds if x.startswith(prefix)]
kwds = [x for x in spec['kwds'].split(',') if x.startswith(prefix)]
if kwds: if kwds:
ans.add_match_group('Keywords', kwds) ans.add_match_group('Keywords', kwds)
if t == 'file': if spec.type is CompletionType.file:
complete_file_path(ans, spec, prefix) complete_file_path(ans, spec, prefix)
elif t == 'directory': elif spec.type is CompletionType.directory:
complete_file_path(ans, spec, prefix, only_dirs=True) 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['completion'].get('type') in ('file', 'directory'): elif opt['completion'].type is not CompletionType.none:
complete_path(ans, opt, prefix) complete_path(ans, opt, prefix)

View File

@@ -6,10 +6,10 @@ from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from typing import ( from typing import (
TYPE_CHECKING, Any, Callable, Dict, FrozenSet, Iterable, Iterator, 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.cli_stub import RCOptions as R
from kitty.constants import appname, list_kitty_resources, running_in_kitty from kitty.constants import appname, list_kitty_resources, running_in_kitty
from kitty.types import AsyncResponse from kitty.types import AsyncResponse
@@ -88,7 +88,8 @@ CmdGenerator = Iterator[CmdReturnType]
PayloadType = Optional[Union[CmdReturnType, CmdGenerator]] PayloadType = Optional[Union[CmdReturnType, CmdGenerator]]
PayloadGetType = PayloadGetter PayloadGetType = PayloadGetter
ArgsType = List[str] 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 = '''\ MATCH_WINDOW_OPTION = '''\
@@ -184,7 +185,7 @@ class ArgsHandling:
json_field: str = '' json_field: str = ''
count: Optional[int] = None count: Optional[int] = None
spec: str = '' spec: str = ''
completion: Optional[Mapping[str, Tuple[str, Union[Callable[[], Iterable[str]], Tuple[str, ...]]]]] = None completion: CompletionSpec = CompletionSpec()
value_if_unspecified: Tuple[str, ...] = () value_if_unspecified: Tuple[str, ...] = ()
minimum_count: int = -1 minimum_count: int = -1
first_rest: Optional[Tuple[str, str]] = None first_rest: Optional[Tuple[str, str]] = None
@@ -198,24 +199,11 @@ class ArgsHandling:
return self.count return self.count
def as_go_completion_code(self, go_name: str) -> Iterator[str]: def as_go_completion_code(self, go_name: str) -> Iterator[str]:
from ..cli import serialize_as_go_string
c = self.args_count c = self.args_count
if c is not None: if c is not None:
yield f'{go_name}.Stop_processing_at_arg = {c}' yield f'{go_name}.Stop_processing_at_arg = {c}'
if self.completion: if self.completion:
for k, v in self.completion.items(): yield from self.completion.as_go_code(go_name)
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}')
def as_go_code(self, cmd_name: str, field_types: Dict[str, str], handled_fields: Set[str]) -> Iterator[str]: def as_go_code(self, cmd_name: str, field_types: Dict[str, str], handled_fields: Set[str]) -> Iterator[str]:
c = self.args_count c = self.args_count
@@ -313,6 +301,7 @@ class StreamInFlight:
class RemoteCommand: class RemoteCommand:
Args = ArgsHandling Args = ArgsHandling
CompletionSpec = CompletionSpec
name: str = '' name: str = ''
short_desc: str = '' short_desc: str = ''

View File

@@ -30,7 +30,10 @@ class GotoLayout(RemoteCommand):
' You can use special match value :code:`all` to set the layout in all tabs.' ' You can use special match value :code:`all` to set the layout in all tabs.'
) )
options_spec = MATCH_TAB_OPTION 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: def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
if len(args) != 1: if len(args) != 1:

View File

@@ -89,8 +89,8 @@ type=bool-set
Restore all colors to the values they had at kitty startup. Note that if you specify 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. 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') ''' + '\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={ args = RemoteCommand.Args(spec='COLOR_OR_FILE ...', json_field='colors', special_parse='parse_colors_and_files(args)',
'files': ('CONF files', ('*.conf',))}) 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: def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
final_colors: Dict[str, Optional[int]] = {} final_colors: Dict[str, Optional[int]] = {}

View File

@@ -42,7 +42,9 @@ Change the default enabled layout value so that the new value takes effect for a
as well. as well.
''' '''
args = RemoteCommand.Args( 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: def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
if len(args) < 1: if len(args) < 1: