1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net> 4 5import builtins 6import re 7import typing 8from importlib import import_module 9from typing import ( 10 Any, Callable, Dict, Iterable, Iterator, List, Match, Optional, Set, Tuple, 11 Union, cast 12) 13 14import kitty.conf.utils as generic_parsers 15from kitty.constants import website_url 16 17if typing.TYPE_CHECKING: 18 Only = typing.Literal['macos', 'linux', ''] 19else: 20 Only = str 21 22 23class Unset: 24 def __bool__(self) -> bool: 25 return False 26 27 28unset = Unset() 29 30 31def expand_opt_references(conf_name: str, text: str) -> str: 32 conf_name += '.' 33 34 def expand(m: Match) -> str: 35 ref = m.group(1) 36 if '<' not in ref and '.' not in ref: 37 full_ref = conf_name + ref 38 return ':opt:`{} <{}>`'.format(ref, full_ref) 39 return str(m.group()) 40 41 return re.sub(r':opt:`(.+?)`', expand, text) 42 43 44def remove_markup(text: str) -> str: 45 ref_map = { 46 'layouts': f'{website_url("overview")}#layouts', 47 'sessions': f'{website_url("overview")}#startup-sessions', 48 'functional': f'{website_url("keyboard-protocol")}#functional-key-definitions', 49 'action-select_tab': f'{website_url("actions")}#select-tab', 50 } 51 52 def sub(m: Match) -> str: 53 if m.group(1) == 'ref': 54 return ref_map[m.group(2)] 55 return str(m.group(2)) 56 57 return re.sub(r':([a-zA-Z0-9]+):`(.+?)`', sub, text, flags=re.DOTALL) 58 59 60def iter_blocks(lines: Iterable[str]) -> Iterator[Tuple[List[str], int]]: 61 current_block: List[str] = [] 62 prev_indent = 0 63 for line in lines: 64 indent_size = len(line) - len(line.lstrip()) 65 if indent_size != prev_indent or not line: 66 if current_block: 67 yield current_block, prev_indent 68 current_block = [] 69 prev_indent = indent_size 70 if not line: 71 yield [''], 100 72 else: 73 current_block.append(line) 74 if current_block: 75 yield current_block, indent_size 76 77 78def wrapped_block(lines: Iterable[str]) -> Iterator[str]: 79 wrapper = getattr(wrapped_block, 'wrapper', None) 80 if wrapper is None: 81 import textwrap 82 wrapper = textwrap.TextWrapper( 83 initial_indent='#: ', subsequent_indent='#: ', width=70, break_long_words=False 84 ) 85 setattr(wrapped_block, 'wrapper', wrapper) 86 for block, indent_size in iter_blocks(lines): 87 if indent_size > 0: 88 for line in block: 89 if not line: 90 yield line 91 else: 92 yield '#: ' + line 93 else: 94 for line in wrapper.wrap('\n'.join(block)): 95 yield line 96 97 98def render_block(text: str) -> str: 99 text = remove_markup(text) 100 lines = text.splitlines() 101 return '\n'.join(wrapped_block(lines)) 102 103 104class CoalescedIteratorData: 105 106 option_groups: Dict[int, List['Option']] = {} 107 action_groups: Dict[str, List['Mapping']] = {} 108 coalesced: Set[int] = set() 109 initialized: bool = False 110 kitty_mod: str = 'kitty_mod' 111 112 def initialize(self, root: 'Group') -> None: 113 if self.initialized: 114 return 115 self.root = root 116 option_groups = self.option_groups = {} 117 current_group: List[Option] = [] 118 action_groups: Dict[str, List[Mapping]] = {} 119 self.action_groups = action_groups 120 coalesced = self.coalesced = set() 121 self.kitty_mod = 'kitty_mod' 122 for item in root.iter_all_non_groups(): 123 if isinstance(item, Option): 124 if item.name == 'kitty_mod': 125 self.kitty_mod = item.defval_as_string 126 if current_group: 127 if item.needs_coalescing: 128 current_group.append(item) 129 coalesced.add(id(item)) 130 continue 131 option_groups[id(current_group[0])] = current_group[1:] 132 current_group = [item] 133 else: 134 current_group.append(item) 135 elif isinstance(item, Mapping): 136 if item.name in action_groups: 137 coalesced.add(id(item)) 138 action_groups[item.name].append(item) 139 else: 140 action_groups[item.name] = [] 141 if current_group: 142 option_groups[id(current_group[0])] = current_group[1:] 143 144 def option_group_for_option(self, opt: 'Option') -> List['Option']: 145 return self.option_groups.get(id(opt), []) 146 147 def action_group_for_action(self, ac: 'Mapping') -> List['Mapping']: 148 return self.action_groups.get(ac.name, []) 149 150 151class Option: 152 153 def __init__( 154 self, name: str, defval: str, macos_default: Union[Unset, str], parser_func: Callable, 155 long_text: str, documented: bool, group: 'Group', choices: Tuple[str, ...], ctype: str 156 ): 157 self.name = name 158 self.ctype = ctype 159 self.defval_as_string = defval 160 self.macos_defval = macos_default 161 self.long_text = long_text 162 self.documented = documented 163 self.group = group 164 self.parser_func = parser_func 165 self.choices = choices 166 167 @property 168 def needs_coalescing(self) -> bool: 169 return self.documented and not self.long_text 170 171 @property 172 def is_color_table_color(self) -> bool: 173 return self.name.startswith('color') and self.name[5:].isdigit() 174 175 def as_conf(self, commented: bool = False, level: int = 0, option_group: List['Option'] = []) -> List[str]: 176 ans: List[str] = [] 177 a = ans.append 178 if not self.documented: 179 return ans 180 if option_group: 181 sz = max(len(self.name), max(len(o.name) for o in option_group)) 182 a(f'{self.name.ljust(sz)} {self.defval_as_string}') 183 for o in option_group: 184 a(f'{o.name.ljust(sz)} {o.defval_as_string}') 185 else: 186 a(f'{self.name} {self.defval_as_string}') 187 if self.long_text: 188 a('') 189 a(render_block(self.long_text)) 190 a('') 191 return ans 192 193 def as_rst( 194 self, conf_name: str, shortcut_slugs: Dict[str, Tuple[str, str]], 195 kitty_mod: str, level: int = 0, option_group: List['Option'] = [] 196 ) -> List[str]: 197 ans: List[str] = [] 198 a = ans.append 199 if not self.documented: 200 return ans 201 mopts = [self] + option_group 202 a('.. opt:: ' + ', '.join(conf_name + '.' + mo.name for mo in mopts)) 203 a('.. code-block:: conf') 204 a('') 205 sz = max(len(x.name) for x in mopts) 206 for mo in mopts: 207 a((' {:%ds} {}' % sz).format(mo.name, mo.defval_as_string)) 208 a('') 209 if self.long_text: 210 a(expand_opt_references(conf_name, self.long_text)) 211 a('') 212 return ans 213 214 215class MultiVal: 216 217 def __init__(self, val_as_str: str, add_to_default: bool, documented: bool, only: Only) -> None: 218 self.defval_as_str = val_as_str 219 self.documented = documented 220 self.only = only 221 self.add_to_default = add_to_default 222 223 224class MultiOption: 225 226 def __init__(self, name: str, parser_func: Callable, long_text: str, group: 'Group', ctype: str): 227 self.name = name 228 self.ctype = ctype 229 self.parser_func = parser_func 230 self.long_text = long_text 231 self.group = group 232 self.items: List[MultiVal] = [] 233 234 def add_value(self, val_as_str: str, add_to_default: bool, documented: bool, only: Only) -> None: 235 self.items.append(MultiVal(val_as_str, add_to_default, documented, only)) 236 237 def __iter__(self) -> Iterator[MultiVal]: 238 yield from self.items 239 240 def as_conf(self, commented: bool = False, level: int = 0) -> List[str]: 241 ans: List[str] = [] 242 a = ans.append 243 for k in self.items: 244 if k.documented: 245 prefix = '' if k.add_to_default else '# ' 246 a(f'{prefix}{self.name} {k.defval_as_str}') 247 if self.long_text: 248 a('') 249 a(render_block(self.long_text)) 250 a('') 251 return ans 252 253 def as_rst(self, conf_name: str, shortcut_slugs: Dict[str, Tuple[str, str]], kitty_mod: str, level: int = 0) -> List[str]: 254 ans: List[str] = [] 255 a = ans.append 256 a(f'.. opt:: {conf_name}.{self.name}') 257 a('.. code-block:: conf') 258 a('') 259 for k in self.items: 260 if k.documented: 261 a(f' {self.name:s} {k.defval_as_str}') 262 a('') 263 if self.long_text: 264 a(expand_opt_references(conf_name, self.long_text)) 265 a('') 266 return ans 267 268 269class Mapping: 270 add_to_default: bool 271 short_text: str 272 long_text: str 273 documented: bool 274 setting_name: str 275 name: str 276 only: Only 277 278 @property 279 def parseable_text(self) -> str: 280 return '' 281 282 @property 283 def key_text(self) -> str: 284 return '' 285 286 def as_conf(self, commented: bool = False, level: int = 0) -> List[str]: 287 ans: List[str] = [] 288 if self.documented: 289 a = ans.append 290 if self.add_to_default: 291 a(self.setting_name + ' ' + self.parseable_text) 292 if self.long_text: 293 a(''), a(render_block(self.long_text.strip())), a('') 294 return ans 295 296 def as_rst( 297 self, conf_name: str, shortcut_slugs: Dict[str, Tuple[str, str]], 298 kitty_mod: str, level: int = 0, action_group: List['Mapping'] = [] 299 ) -> List[str]: 300 ans: List[str] = [] 301 a = ans.append 302 if not self.documented: 303 return ans 304 if not self.short_text: 305 raise ValueError(f'The shortcut for {self.name} has no short_text') 306 sc_text = f'{conf_name}.{self.short_text}' 307 shortcut_slugs[f'{conf_name}.{self.name}'] = (sc_text, self.key_text.replace('kitty_mod', kitty_mod)) 308 a('.. shortcut:: ' + sc_text) 309 block_started = False 310 for sc in [self] + action_group: 311 if sc.add_to_default and sc.documented: 312 if not block_started: 313 a('.. code-block:: conf') 314 a('') 315 block_started = True 316 suffix = '' 317 if sc.only == 'macos': 318 suffix = ' ' 319 elif sc.only == 'linux': 320 suffix = ' ' 321 a(f' {sc.setting_name} {sc.parseable_text.replace("kitty_mod", kitty_mod)}{suffix}') 322 a('') 323 if self.long_text: 324 a('') 325 a(expand_opt_references(conf_name, self.long_text)) 326 a('') 327 328 return ans 329 330 331class ShortcutMapping(Mapping): 332 setting_name: str = 'map' 333 334 def __init__( 335 self, name: str, key: str, action_def: str, short_text: str, long_text: str, add_to_default: bool, documented: bool, group: 'Group', only: Only 336 ): 337 self.name = name 338 self.only = only 339 self.key = key 340 self.action_def = action_def 341 self.short_text = short_text 342 self.long_text = long_text 343 self.documented = documented 344 self.add_to_default = add_to_default 345 self.group = group 346 347 @property 348 def parseable_text(self) -> str: 349 return f'{self.key} {self.action_def}' 350 351 @property 352 def key_text(self) -> str: 353 return self.key 354 355 356class MouseMapping(Mapping): 357 setting_name: str = 'mouse_map' 358 359 def __init__( 360 self, name: str, button: str, event: str, modes: str, action_def: str, 361 short_text: str, long_text: str, add_to_default: bool, documented: bool, group: 'Group', only: Only 362 ): 363 self.name = name 364 self.only = only 365 self.button = button 366 self.event = event 367 self.modes = modes 368 self.action_def = action_def 369 self.short_text = short_text 370 self.long_text = long_text 371 self.documented = documented 372 self.add_to_default = add_to_default 373 self.group = group 374 375 @property 376 def parseable_text(self) -> str: 377 return f'{self.button} {self.event} {self.modes} {self.action_def}' 378 379 @property 380 def key_text(self) -> str: 381 return self.button 382 383 384NonGroups = Union[Option, MultiOption, ShortcutMapping, MouseMapping] 385GroupItem = Union[NonGroups, 'Group'] 386 387 388class Group: 389 390 def __init__(self, name: str, title: str, coalesced_iterator_data: CoalescedIteratorData, start_text: str = '', parent: Optional['Group'] = None): 391 self.name = name 392 self.coalesced_iterator_data = coalesced_iterator_data 393 self.title = title 394 self.start_text = start_text 395 self.end_text = '' 396 self.items: List[GroupItem] = [] 397 self.parent = parent 398 399 def append(self, item: GroupItem) -> None: 400 self.items.append(item) 401 402 def __iter__(self) -> Iterator[GroupItem]: 403 return iter(self.items) 404 405 def __len__(self) -> int: 406 return len(self.items) 407 408 def iter_with_coalesced_options(self) -> Iterator[GroupItem]: 409 for item in self: 410 if id(item) not in self.coalesced_iterator_data.coalesced: 411 yield item 412 413 def iter_all(self) -> Iterator[GroupItem]: 414 for x in self: 415 yield x 416 if isinstance(x, Group): 417 yield from x.iter_all() 418 419 def iter_all_non_groups(self) -> Iterator[NonGroups]: 420 for x in self: 421 if isinstance(x, Group): 422 yield from x.iter_all_non_groups() 423 else: 424 yield x 425 426 def as_rst(self, conf_name: str, shortcut_slugs: Dict[str, Tuple[str, str]], kitty_mod: str = 'kitty_mod', level: int = 0) -> List[str]: 427 ans: List[str] = [] 428 a = ans.append 429 if level: 430 a('') 431 a(f'.. _conf-{conf_name}-{self.name}:') 432 a('') 433 a(self.title) 434 heading_level = '+' if level > 1 else '-' 435 a(heading_level * (len(self.title) + 20)) 436 a('') 437 if self.start_text: 438 a(self.start_text) 439 a('') 440 else: 441 ans.extend(('.. default-domain:: conf', '')) 442 443 kitty_mod = self.coalesced_iterator_data.kitty_mod 444 for item in self.iter_with_coalesced_options(): 445 if isinstance(item, Option): 446 lines = item.as_rst(conf_name, shortcut_slugs, kitty_mod, option_group=self.coalesced_iterator_data.option_group_for_option(item)) 447 elif isinstance(item, Mapping): 448 lines = item.as_rst(conf_name, shortcut_slugs, kitty_mod, level + 1, action_group=self.coalesced_iterator_data.action_group_for_action(item)) 449 else: 450 lines = item.as_rst(conf_name, shortcut_slugs, kitty_mod, level + 1) 451 ans.extend(lines) 452 453 if level: 454 if self.end_text: 455 a('') 456 a(self.end_text) 457 return ans 458 459 def as_conf(self, commented: bool = False, level: int = 0) -> List[str]: 460 ans: List[str] = [] 461 a = ans.append 462 if level: 463 a('#: ' + self.title + ' {{''{') 464 a('') 465 if self.start_text: 466 a(render_block(self.start_text)) 467 a('') 468 else: 469 ans.extend(('# vim:fileencoding=utf-8:foldmethod=marker', '')) 470 471 for item in self.iter_with_coalesced_options(): 472 if isinstance(item, Option): 473 lines = item.as_conf(option_group=self.coalesced_iterator_data.option_group_for_option(item)) 474 else: 475 lines = item.as_conf(commented, level + 1) 476 ans.extend(lines) 477 478 if level: 479 if self.end_text: 480 a('') 481 a(render_block(self.end_text)) 482 a('#: }}''}') 483 a('') 484 else: 485 map_groups = [] 486 start: Optional[int] = None 487 count: Optional[int] = None 488 for i, line in enumerate(ans): 489 if line.startswith('map ') or line.startswith('mouse_map '): 490 if start is None: 491 start = i 492 count = 1 493 else: 494 if count is not None: 495 count += 1 496 else: 497 if start is not None and count is not None: 498 map_groups.append((start, count)) 499 start = count = None 500 for start, count in map_groups: 501 r = range(start, start + count) 502 sz = max(len(ans[i].split(' ', 3)[1]) for i in r) 503 for i in r: 504 line = ans[i] 505 parts = line.split(' ', 3) 506 parts[1] = parts[1].ljust(sz) 507 ans[i] = ' '.join(parts) 508 509 if commented: 510 ans = [x if x.startswith('#') or not x.strip() else ('# ' + x) for x in ans] 511 512 return ans 513 514 515def resolve_import(name: str, module: Any = None) -> Callable: 516 ans = None 517 if name.count('.') > 1: 518 m = import_module(name.rpartition('.')[0]) 519 ans = getattr(m, name.rpartition('.')[2]) 520 else: 521 ans = getattr(builtins, name, None) 522 if not callable(ans): 523 ans = getattr(generic_parsers, name, None) 524 if not callable(ans): 525 ans = getattr(module, name) 526 if not callable(ans): 527 raise TypeError(f'{name} is not a function') 528 return cast(Callable, ans) 529 530 531class Action: 532 533 def __init__(self, name: str, option_type: str, fields: Dict[str, str], imports: Iterable[str]): 534 self.name = name 535 self._parser_func = option_type 536 self.fields = fields 537 self.imports = frozenset(imports) 538 539 def resolve_imports(self, module: Any) -> 'Action': 540 self.parser_func = resolve_import(self._parser_func, module) 541 return self 542 543 544class Definition: 545 546 def __init__(self, package: str, *actions: Action, has_color_table: bool = False) -> None: 547 if package.startswith('!'): 548 self.module_for_parsers = import_module(package[1:]) 549 else: 550 self.module_for_parsers = import_module(f'{package}.options.utils') 551 self.has_color_table = has_color_table 552 self.coalesced_iterator_data = CoalescedIteratorData() 553 self.root_group = Group('', '', self.coalesced_iterator_data) 554 self.current_group = self.root_group 555 self.option_map: Dict[str, Option] = {} 556 self.multi_option_map: Dict[str, MultiOption] = {} 557 self.shortcut_map: Dict[str, List[ShortcutMapping]] = {} 558 self.mouse_map: Dict[str, List[MouseMapping]] = {} 559 self.actions = {a.name: a.resolve_imports(self.module_for_parsers) for a in actions} 560 self.deprecations: Dict[Callable, Tuple[str, ...]] = {} 561 562 def iter_all_non_groups(self) -> Iterator[NonGroups]: 563 yield from self.root_group.iter_all_non_groups() 564 565 def iter_all_options(self) -> Iterator[Union[Option, MultiOption]]: 566 for x in self.iter_all_non_groups(): 567 if isinstance(x, (Option, MultiOption)): 568 yield x 569 570 def iter_all_maps(self, which: str = 'map') -> Iterator[Union[ShortcutMapping, MouseMapping]]: 571 for x in self.iter_all_non_groups(): 572 if isinstance(x, ShortcutMapping) and which in ('map', '*'): 573 yield x 574 elif isinstance(x, MouseMapping) and which in ('mouse_map', '*'): 575 yield x 576 577 def parser_func(self, name: str) -> Callable: 578 ans = getattr(builtins, name, None) 579 if callable(ans): 580 return cast(Callable, ans) 581 ans = getattr(generic_parsers, name, None) 582 if callable(ans): 583 return cast(Callable, ans) 584 ans = getattr(self.module_for_parsers, name) 585 if not callable(ans): 586 raise TypeError(f'{name} is not a function') 587 return cast(Callable, ans) 588 589 def add_group(self, name: str, title: str = '', start_text: str = '') -> None: 590 self.current_group = Group(name, title or name, self.coalesced_iterator_data, start_text.strip(), self.current_group) 591 if self.current_group.parent is not None: 592 self.current_group.parent.append(self.current_group) 593 594 def end_group(self, end_text: str = '') -> None: 595 self.current_group.end_text = end_text.strip() 596 if self.current_group.parent is not None: 597 self.current_group = self.current_group.parent 598 599 def add_option( 600 self, name: str, defval: Union[str, float, int, bool], 601 option_type: str = 'str', long_text: str = '', 602 documented: bool = True, add_to_default: bool = False, 603 only: Only = '', macos_default: Union[Unset, str] = unset, 604 choices: Tuple[str, ...] = (), 605 ctype: str = '', 606 ) -> None: 607 if isinstance(defval, bool): 608 defval = 'yes' if defval else 'no' 609 else: 610 defval = str(defval) 611 is_multiple = name.startswith('+') 612 long_text = long_text.strip() 613 if is_multiple: 614 name = name[1:] 615 if macos_default is not unset: 616 raise TypeError(f'Cannot specify macos_default for is_multiple option: {name} use only instead') 617 is_new = name not in self.multi_option_map 618 if is_new: 619 self.multi_option_map[name] = MultiOption(name, self.parser_func(option_type), long_text, self.current_group, ctype) 620 mopt = self.multi_option_map[name] 621 if is_new: 622 self.current_group.append(mopt) 623 mopt.add_value(defval, add_to_default, documented, only) 624 return 625 opt = Option(name, defval, macos_default, self.parser_func(option_type), long_text, documented, self.current_group, choices, ctype) 626 self.current_group.append(opt) 627 self.option_map[name] = opt 628 629 def add_map( 630 self, short_text: str, defn: str, long_text: str = '', add_to_default: bool = True, documented: bool = True, only: Only = '' 631 ) -> None: 632 name, key, action_def = defn.split(maxsplit=2) 633 sc = ShortcutMapping(name, key, action_def, short_text, long_text.strip(), add_to_default, documented, self.current_group, only) 634 self.current_group.append(sc) 635 self.shortcut_map.setdefault(name, []).append(sc) 636 637 def add_mouse_map( 638 self, short_text: str, defn: str, long_text: str = '', add_to_default: bool = True, documented: bool = True, only: Only = '' 639 ) -> None: 640 name, button, event, modes, action_def = defn.split(maxsplit=4) 641 mm = MouseMapping(name, button, event, modes, action_def, short_text, long_text.strip(), add_to_default, documented, self.current_group, only) 642 self.current_group.append(mm) 643 self.mouse_map.setdefault(name, []).append(mm) 644 645 def add_deprecation(self, parser_name: str, *aliases: str) -> None: 646 self.deprecations[self.parser_func(parser_name)] = aliases 647 648 def as_conf(self, commented: bool = False) -> List[str]: 649 self.coalesced_iterator_data.initialize(self.root_group) 650 return self.root_group.as_conf(commented) 651 652 def as_rst(self, conf_name: str, shortcut_slugs: Dict[str, Tuple[str, str]]) -> List[str]: 653 self.coalesced_iterator_data.initialize(self.root_group) 654 return self.root_group.as_rst(conf_name, shortcut_slugs) 655