From a97a05b1ecac1febc039ff1540b1ca0a98c173b5 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 23 Nov 2021 14:43:36 +0530 Subject: [PATCH] Allow action_alias to work with any action --- kitty/boss.py | 14 ++- kitty/conf/generate.py | 9 +- kitty/conf/utils.py | 58 ++++++++++-- kitty/config.py | 25 +++-- kitty/options/definition.py | 7 +- kitty/options/types.py | 4 +- kitty/options/utils.py | 180 ++++++++++++++++++++---------------- kitty_tests/options.py | 16 +++- 8 files changed, 205 insertions(+), 108 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index ac9d68fc0..82ed03cb2 100755 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1951,8 +1951,18 @@ class Boss: def format_bad_line(bad_line: BadLine) -> str: return f'{bad_line.number}:{bad_line.exception} in line: {bad_line.line}\n' - msg = '\n'.join(map(format_bad_line, bad_lines)).rstrip() - self.show_error(_('Errors in kitty.conf'), msg) + groups: Dict[str, List[BadLine]] = {} + for bl in bad_lines: + groups.setdefault(bl.file, []).append(bl) + ans: List[str] = [] + a = ans.append + for file in sorted(groups): + if file: + a(f'In file {file}:') + [a(format_bad_line(x)) for x in groups[file]] + + msg = '\n'.join(ans).rstrip() + self.show_error(_('Errors parsing configuration'), msg) @ac('misc', ''' Change colors in the specified windows diff --git a/kitty/conf/generate.py b/kitty/conf/generate.py index 80ac3c828..2812f45ec 100644 --- a/kitty/conf/generate.py +++ b/kitty/conf/generate.py @@ -269,6 +269,11 @@ def generate_class(defn: Definition, loc: str) -> Tuple[str, str]: a('if not is_macos:') a(f' defaults.{option_name}.update({mval["linux"]!r}') + def resolve_action(a: Any) -> Any: + if hasattr(a, 'resolve_aliases_and_parse'): + a.resolve_aliases_and_parse({}) + return a + for aname, func in action_parsers.items(): a(f'defaults.{aname} = [') only: Dict[str, List[Tuple[str, Callable[..., Any]]]] = {} @@ -281,7 +286,7 @@ def generate_class(defn: Definition, loc: str) -> Tuple[str, str]: else: for val in func(text): a(f' # {sc.name}') - a(f' {val!r},') + a(f' {resolve_action(val)!r},') a(']') if only: imports.add(('kitty.constants', 'is_macos')) @@ -290,7 +295,7 @@ def generate_class(defn: Definition, loc: str) -> Tuple[str, str]: a(f'if {cond}:') for (text, func) in items: for val in func(text): - a(f' defaults.{aname}.append({val!r})') + a(f' defaults.{aname}.append({resolve_action(val)!r})') t('') t('') diff --git a/kitty/conf/utils.py b/kitty/conf/utils.py index 165cc77e3..4939cc860 100644 --- a/kitty/conf/utils.py +++ b/kitty/conf/utils.py @@ -4,13 +4,14 @@ import os import re import shlex +from contextlib import contextmanager from typing import ( - Any, Callable, Dict, Generator, Iterable, List, NamedTuple, Optional, - Sequence, Set, Tuple, TypeVar, Union, Generic + Any, Callable, Dict, Generator, Generic, Iterable, Iterator, List, + NamedTuple, Optional, Sequence, Set, Tuple, TypeVar, Union ) -from ..rgb import to_color as as_color from ..fast_data_types import Color +from ..rgb import to_color as as_color from ..types import ConvertibleToNumbers, ParsedShortcut from ..typing import Protocol from ..utils import expandvars, log_error @@ -30,6 +31,7 @@ class BadLine(NamedTuple): number: int line: str exception: Exception + file: str def positive_int(x: ConvertibleToNumbers) -> int: @@ -118,6 +120,40 @@ def choices(*choices: str) -> Choice: return Choice(choices) +class CurrentlyParsing: + __slots__ = 'line', 'number', 'file' + + def __init__(self, line: str = '', number: int = -1, file: str = ''): + self.line = line + self.number = number + self.file = file + + def __copy__(self) -> 'CurrentlyParsing': + return CurrentlyParsing(self.line, self.number, self.file) + + @contextmanager + def set_line(self, line: str, number: int) -> Iterator['CurrentlyParsing']: + orig = self.line, self.number + self.line = line + self.number = number + try: + yield self + finally: + self.line, self.number = orig + + @contextmanager + def set_file(self, file: str) -> Iterator['CurrentlyParsing']: + orig = self.file + self.file = file + try: + yield self + finally: + self.file = orig + + +currently_parsing = CurrentlyParsing() + + def parse_line( line: str, parse_conf_item: ItemParser, @@ -139,7 +175,8 @@ def parse_line( val = os.path.join(base_path_for_includes, val) try: with open(val, encoding='utf-8', errors='replace') as include: - _parse(include, parse_conf_item, ans, accumulate_bad_lines) + with currently_parsing.set_file(val): + _parse(include, parse_conf_item, ans, accumulate_bad_lines) except FileNotFoundError: log_error( 'Could not find included config file: {}, ignoring'. @@ -169,13 +206,12 @@ def _parse( base_path_for_includes = config_dir for i, line in enumerate(lines): try: - parse_line( - line, parse_conf_item, ans, base_path_for_includes, accumulate_bad_lines - ) + with currently_parsing.set_line(line, i + 1): + parse_line(line, parse_conf_item, ans, base_path_for_includes, accumulate_bad_lines) except Exception as e: if accumulate_bad_lines is None: raise - accumulate_bad_lines.append(BadLine(i + 1, line.rstrip(), e)) + accumulate_bad_lines.append(BadLine(i + 1, line.rstrip(), e, currently_parsing.file)) def parse_config_base( @@ -219,13 +255,15 @@ def load_config( continue try: with open(path, encoding='utf-8', errors='replace') as f: - vals = parse_config(f) + with currently_parsing.set_file(path): + vals = parse_config(f) except (FileNotFoundError, PermissionError): continue found_paths.append(path) ans = merge_configs(ans, vals) if overrides is not None: - vals = parse_config(overrides) + with currently_parsing.set_file(''): + vals = parse_config(overrides) ans = merge_configs(ans, vals) return ans, tuple(found_paths) diff --git a/kitty/config.py b/kitty/config.py index 85c2806f4..ff7ff4a2b 100644 --- a/kitty/config.py +++ b/kitty/config.py @@ -91,13 +91,20 @@ def prepare_config_file_for_editing() -> str: return defconf -def finalize_keys(opts: Options, alias_map: Dict[str, List[ActionAlias]]) -> None: +def finalize_keys(opts: Options, alias_map: Dict[str, List[ActionAlias]], accumulate_bad_lines: Optional[List[BadLine]] = None) -> None: defns: List[KeyDefinition] = [] for d in opts.map: if d is None: # clear_all_shortcuts defns = [] # type: ignore else: - defns.append(d.resolve_and_copy(opts.kitty_mod, alias_map)) + try: + defns.append(d.resolve_and_copy(opts.kitty_mod, alias_map)) + except Exception as err: + if accumulate_bad_lines is None: + log_error(f'Ignoring map with invalid action: {d.unresolved_action}. Error: {err}') + else: + accumulate_bad_lines.append(BadLine(d.definition_location.number, d.definition_location.line, err, d.definition_location.file)) + keymap: KeyMap = {} sequence_map: SequenceMap = {} @@ -122,13 +129,19 @@ def finalize_keys(opts: Options, alias_map: Dict[str, List[ActionAlias]]) -> Non opts.sequence_map = sequence_map -def finalize_mouse_mappings(opts: Options, alias_map: Dict[str, List[ActionAlias]]) -> None: +def finalize_mouse_mappings(opts: Options, alias_map: Dict[str, List[ActionAlias]], accumulate_bad_lines: Optional[List[BadLine]] = None) -> None: defns: List[MouseMapping] = [] for d in opts.mouse_map: if d is None: # clear_all_mouse_actions defns = [] # type: ignore else: - defns.append(d.resolve_and_copy(opts.kitty_mod, alias_map)) + try: + defns.append(d.resolve_and_copy(opts.kitty_mod, alias_map)) + except Exception as err: + if accumulate_bad_lines is None: + log_error(f'Ignoring mouse_map with invalid action: {d.unresolved_action}. Error: {err}') + else: + accumulate_bad_lines.append(BadLine(d.definition_location.number, d.definition_location.line, err, d.definition_location.file)) mousemap: MouseMap = {} for defn in defns: @@ -161,8 +174,8 @@ def load_config(*paths: str, overrides: Optional[Iterable[str]] = None, accumula alias_map = build_action_aliases(opts.kitten_alias, 'kitten') alias_map.update(build_action_aliases(opts.action_alias)) - finalize_keys(opts, alias_map) - finalize_mouse_mappings(opts, alias_map) + finalize_keys(opts, alias_map, accumulate_bad_lines) + finalize_mouse_mappings(opts, alias_map, accumulate_bad_lines) # delete no longer needed definitions, replacing with empty placeholders opts.kitten_alias = {} opts.action_alias = {} diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 3402f0e5c..1f3c439cd 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -2875,10 +2875,9 @@ opt('+action_alias', 'launch_tab launch --type=tab --cwd=current', add_to_default=False, long_text=''' Define aliases to avoid repeating the same options in multiple mappings. Aliases -can be defined for the :ref:`launch `, :ref:`kitten ` -and :ref:`action-remote_control` actions. Aliases are expanded recursively. -For example, the above alias allows you to create mappings to launch a new tab without -duplication:: +can be defined for any action. Aliases are expanded recursively. +For example, the above alias allows you to create mappings to launch a new tab +in the current working directory without duplication:: map f1 launch_tab vim map f2 launch_tab emacs diff --git a/kitty/options/types.py b/kitty/options/types.py index e76d07872..1be28f600 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -584,10 +584,10 @@ class Options: window_padding_width: FloatEdges = FloatEdges(left=0, top=0, right=0, bottom=0) window_resize_step_cells: int = 2 window_resize_step_lines: int = 2 - action_alias: typing.Dict[str, typing.List[str]] = {} + action_alias: typing.Dict[str, str] = {} env: typing.Dict[str, str] = {} font_features: typing.Dict[str, typing.Tuple[kitty.fonts.FontFeature, ...]] = {} - kitten_alias: typing.Dict[str, typing.List[str]] = {} + kitten_alias: typing.Dict[str, str] = {} symbol_map: typing.Dict[typing.Tuple[int, int], str] = {} watcher: typing.Dict[str, str] = {} map: typing.List[kitty.options.utils.KeyDefinition] = [] diff --git a/kitty/options/utils.py b/kitty/options/utils.py index aa2d72be1..00eb5e84f 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -12,8 +12,9 @@ from typing import ( import kitty.fast_data_types as defines from kitty.conf.utils import ( - KeyAction, KeyFuncWrapper, positive_float, positive_int, python_string, - to_bool, to_cmdline, to_color, uniq, unit_float + CurrentlyParsing, KeyAction, KeyFuncWrapper, currently_parsing, + positive_float, positive_int, python_string, to_bool, to_cmdline, to_color, + uniq, unit_float ) from kitty.constants import config_dir, is_macos from kitty.fast_data_types import ( @@ -765,11 +766,11 @@ def watcher(val: str, current_val: Container[str]) -> Iterable[Tuple[str, str]]: yield val, val -def action_alias(val: str) -> Iterable[Tuple[str, List[str]]]: +def action_alias(val: str) -> Iterable[Tuple[str, str]]: parts = val.split(maxsplit=1) if len(parts) > 1: alias_name, rest = parts - yield alias_name, to_cmdline(rest) + yield alias_name, rest kitten_alias = action_alias @@ -802,98 +803,122 @@ def symbol_map(val: str) -> Iterable[Tuple[Tuple[int, int], str]]: yield (a, b), family -def parse_combine(rest: str, action_type: str = 'map') -> Iterator[KeyAction]: - sep, rest = rest.split(maxsplit=1) - parts = re.split(r'\s*' + re.escape(sep) + r'\s*', rest) - for x in parts: - if x: - yield from parse_key_actions(x, action_type) - - -def parse_key_actions(action: str, action_type: str = 'map') -> Iterator[KeyAction]: +def parse_key_action(action: str, action_type: str = 'map') -> KeyAction: parts = action.strip().split(maxsplit=1) func = parts[0] if len(parts) == 1: - yield KeyAction(func, ()) - return + return KeyAction(func, ()) rest = parts[1] - if func == 'combine': - yield from parse_combine(rest, action_type) - else: - parser = func_with_args.get(func) or shlex_parse - try: - func, args = parser(func, rest) - except Exception as err: - log_error(f'Ignoring invalid {action_type} action: {action} with err: {err}') - else: - yield KeyAction(func, tuple(args)) + parser = func_with_args.get(func) + if parser is None: + raise KeyError(f'Unknown action: {func}') + func, args = parser(func, rest) + return KeyAction(func, tuple(args)) class ActionAlias(NamedTuple): - func_name: str - args: Tuple[str, ...] - second_arg_test: Optional[Callable[[Any], bool]] = None + name: str + value: str + is_recursive: bool + replace_second_arg: bool = False -def build_action_aliases(raw: Dict[str, List[str]], first_arg_replacement: str = '') -> Dict[str, List[ActionAlias]]: +def build_action_aliases(raw: Dict[str, str], first_arg_replacement: str = '') -> Dict[str, List[ActionAlias]]: ans: Dict[str, List[ActionAlias]] = {} if first_arg_replacement: - for alias_name, args in raw.items(): - ans.setdefault(first_arg_replacement, []).append(ActionAlias(first_arg_replacement, tuple(args), alias_name.__eq__)) + for alias_name, rest in raw.items(): + is_recursive = alias_name == rest.split(maxsplit=1)[0] + ans.setdefault(first_arg_replacement, []).append(ActionAlias(alias_name, rest, is_recursive, True)) else: - for alias_name, args in raw.items(): - ans[alias_name] = [ActionAlias(args[0], tuple(args[1:]))] + for alias_name, rest in raw.items(): + is_recursive = alias_name == rest.split(maxsplit=1)[0] + ans[alias_name] = [ActionAlias(alias_name, rest, is_recursive)] return ans -def resolve_aliases_in_action(action: KeyAction, aliases: Dict[str, List[ActionAlias]]) -> KeyAction: - for alias in aliases.get(action.func, ()): - if alias.second_arg_test is None: - new_action = action._replace(func=alias.func_name, args=alias.args + action.args) - if new_action.func == action.func: +def resolve_aliases_and_parse_actions( + defn: str, aliases: Dict[str, List[ActionAlias]], map_type: str +) -> Iterator[KeyAction]: + parts = defn.split(maxsplit=1) + if len(parts) == 1: + possible_alias = defn + rest = '' + else: + possible_alias = parts[0] + rest = parts[1] + for alias in aliases.get(possible_alias, ()): + if alias.replace_second_arg: # kitten_alias + if not rest: + continue + parts = rest.split(maxsplit=1) + if parts[0] != alias.name: + continue + new_defn = possible_alias + ' ' + alias.value + ((' ' + parts[1]) if len(parts) > 1 else '') + new_aliases = aliases + if alias.is_recursive: new_aliases = aliases.copy() - new_aliases.pop(alias.func_name) - else: - new_aliases = aliases - return resolve_aliases_in_action(new_action, new_aliases) - if action.args and alias.second_arg_test(action.args[0]): - new_action = action._replace(func=alias.func_name, args=alias.args + action.args[1:]) - if new_action.func == action.func and new_action.args and alias.second_arg_test(new_action.args[0]): + new_aliases[possible_alias] = [a for a in aliases[possible_alias] if a is not alias] + yield from resolve_aliases_and_parse_actions(new_defn, new_aliases, map_type) + return + else: # action_alias + new_defn = alias.value + ((' ' + rest) if rest else '') + new_aliases = aliases + if alias.is_recursive: new_aliases = aliases.copy() - new_aliases[action.func] = [x for x in aliases[action.func] if x is not alias] - else: - new_aliases = aliases - return resolve_aliases_in_action(new_action, new_aliases) - return action + new_aliases.pop(possible_alias) + yield from resolve_aliases_and_parse_actions(new_defn, new_aliases, map_type) + return + + if possible_alias == 'combine': + sep, rest = rest.split(maxsplit=1) + parts = re.split(r'\s*' + re.escape(sep) + r'\s*', rest) + for x in parts: + if x: + yield from resolve_aliases_and_parse_actions(x, aliases, map_type) + else: + yield parse_key_action(defn, map_type) class BaseDefinition: - actions: Tuple[KeyAction, ...] + actions: Tuple[KeyAction, ...] = () no_op_actions = frozenset(('noop', 'no-op', 'no_op')) + map_type: str = 'map' + definition_location: CurrentlyParsing + + def __init__(self, unresolved_action: str) -> None: + self.unresolved_action = unresolved_action + self.definition_location = currently_parsing.__copy__() @property def is_no_op(self) -> bool: return len(self.actions) == 1 and self.actions[0].func in self.no_op_actions - def resolve_aliases(self, aliases: Dict[str, List[ActionAlias]]) -> Tuple[KeyAction, ...]: - self.actions = tuple(resolve_aliases_in_action(a, aliases) for a in self.actions) - return self.actions + def resolve_aliases_and_parse(self, aliases: Dict[str, List[ActionAlias]]) -> None: + if self.unresolved_action: + self.actions = tuple(resolve_aliases_and_parse_actions( + self.unresolved_action, aliases, self.map_type)) + self.unresolved_action = '' class MouseMapping(BaseDefinition): + map_type: str = 'mouse_map' - def __init__(self, button: int, mods: int, repeat_count: int, grabbed: bool, actions: Tuple[KeyAction, ...]): + def __init__(self, button: int, mods: int, repeat_count: int, grabbed: bool, actions: Tuple[KeyAction, ...] = (), unresolved_action: str = ''): + super().__init__(unresolved_action) self.button = button self.mods = mods + self.actions = actions self.repeat_count = repeat_count self.grabbed = grabbed - self.actions = actions def __repr__(self) -> str: return f'MouseMapping({self.button}, {self.mods}, {self.repeat_count}, {self.grabbed}, {self.actions})' def resolve_and_copy(self, kitty_mod: int, aliases: Dict[str, List[ActionAlias]]) -> 'MouseMapping': - return MouseMapping(self.button, defines.resolve_key_mods(kitty_mod, self.mods), self.repeat_count, self.grabbed, self.resolve_aliases(aliases)) + ans = MouseMapping( + self.button, defines.resolve_key_mods(kitty_mod, self.mods), self.repeat_count, self.grabbed, self.actions, self.unresolved_action) + ans.resolve_aliases_and_parse(aliases) + return ans @property def trigger(self) -> MouseEvent: @@ -902,7 +927,11 @@ class MouseMapping(BaseDefinition): class KeyDefinition(BaseDefinition): - def __init__(self, is_sequence: bool, actions: Tuple[KeyAction, ...], mods: int, is_native: bool, key: int, rest: Tuple[SingleKey, ...] = ()): + def __init__( + self, is_sequence: bool, actions: Tuple[KeyAction, ...], mods: int, is_native: bool, key: int, + rest: Tuple[SingleKey, ...] = (), unresolved_action: str = '' + ): + super().__init__(unresolved_action) self.is_sequence = is_sequence self.actions = actions self.trigger = SingleKey(mods, is_native, key) @@ -915,10 +944,12 @@ class KeyDefinition(BaseDefinition): def r(k: SingleKey) -> SingleKey: mods = defines.resolve_key_mods(kitty_mod, k.mods) return k._replace(mods=mods) - return KeyDefinition( - self.is_sequence, self.resolve_aliases(aliases), - defines.resolve_key_mods(kitty_mod, self.trigger.mods), - self.trigger.is_native, self.trigger.key, tuple(map(r, self.rest))) + ans = KeyDefinition( + self.is_sequence, self.actions, defines.resolve_key_mods(kitty_mod, self.trigger.mods), + self.trigger.is_native, self.trigger.key, tuple(map(r, self.rest)), self.unresolved_action + ) + ans.resolve_aliases_and_parse(aliases) + return ans def parse_map(val: str) -> Iterable[KeyDefinition]: @@ -956,18 +987,12 @@ def parse_map(val: str) -> Iterable[KeyDefinition]: if mods is not None: log_error(f'Shortcut: {sc} has unknown key, ignoring') return - try: - paction = tuple(parse_key_actions(action)) - except Exception: - log_error(f'Invalid shortcut action: {action}. Ignoring.') + if is_sequence: + if trigger is not None: + yield KeyDefinition(True, (), trigger[0], trigger[1], trigger[2], rest, unresolved_action=action) else: - if paction: - if is_sequence: - if trigger is not None: - yield KeyDefinition(True, paction, trigger[0], trigger[1], trigger[2], rest) - else: - assert key is not None - yield KeyDefinition(False, paction, mods, is_native, key) + assert key is not None + yield KeyDefinition(False, (), mods, is_native, key, unresolved_action=action) def parse_mouse_map(val: str) -> Iterable[MouseMapping]: @@ -1000,15 +1025,8 @@ def parse_mouse_map(val: str) -> Iterable[MouseMapping]: if specified_modes - {'grabbed', 'ungrabbed'}: log_error(f'Mouse modes: {modes} not recognized, ignoring') return - try: - paction = tuple(parse_key_actions(action, 'mouse_map')) - except Exception: - log_error(f'Invalid mouse action: {action}. Ignoring.') - return - if not paction: - return for mode in sorted(specified_modes): - yield MouseMapping(button, mods, count, mode == 'grabbed', paction) + yield MouseMapping(button, mods, count, mode == 'grabbed', unresolved_action=action) def deprecated_hide_window_decorations_aliases(key: str, val: str, ans: Dict[str, Any]) -> None: diff --git a/kitty_tests/options.py b/kitty_tests/options.py index f63203324..d0f4a2572 100644 --- a/kitty_tests/options.py +++ b/kitty_tests/options.py @@ -27,7 +27,10 @@ class TestConfParsing(BaseTest): del bad_lines[:] del self.error_messages[:] ans = load_config(overrides=lines, accumulate_bad_lines=bad_lines) - self.ae(len(bad_lines), bad_line_num) + if bad_line_num: + self.ae(len(bad_lines), bad_line_num) + else: + self.assertFalse(bad_lines) return ans def keys_for_func(opts, name): @@ -51,6 +54,7 @@ class TestConfParsing(BaseTest): opts = p('pointer_shape_when_grabbed XXX', bad_line_num=1) self.ae(opts.pointer_shape_when_grabbed, defaults.pointer_shape_when_grabbed) + # test the aliasing options opts = p('env A=1', 'env B=x$A', 'env C=', 'env D', 'clear_all_shortcuts y', 'kitten_alias a b --moo', 'map f1 kitten a arg') self.ae(opts.env, {'A': '1', 'B': 'x1', 'C': '', 'D': DELETE_ENV_VAR}) ka = tuple(opts.keymap.values())[0][0] @@ -77,10 +81,20 @@ class TestConfParsing(BaseTest): self.ae(ka.func, 'launch') self.ae(ka.args, ('--moo', 'XXX')) + opts = p('clear_all_shortcuts y', 'action_alias cfs change_font_size current', 'map f1 cfs +2') + ka = tuple(opts.keymap.values())[0][0] + self.ae(ka.func, 'change_font_size') + self.ae(ka.args, (False, '+', 2.0)) + opts = p('clear_all_shortcuts y', 'action_alias la launch --moo', 'map f1 combine : new_window : la ') ka = tuple(opts.keymap.values())[0] self.ae((ka[0].func, ka[1].func), ('new_window', 'launch')) + opts = p('clear_all_shortcuts y', 'action_alias cc combine : new_window : launch --moo', 'map f1 cc XXX') + ka = tuple(opts.keymap.values())[0] + self.ae((ka[0].func, ka[1].func), ('new_window', 'launch')) + self.ae(ka[1].args, ('--moo', 'XXX')) + opts = p('kitty_mod alt') self.ae(opts.kitty_mod, to_modifiers('alt')) self.ae(next(keys_for_func(opts, 'next_layout')).mods, opts.kitty_mod)