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