1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
4
5
6import inspect
7import os
8import pprint
9import re
10import textwrap
11from typing import (
12    Any, Callable, Dict, Iterator, List, Set, Tuple, Union, get_type_hints
13)
14
15from kitty.conf.types import Definition, MultiOption, Option, unset
16
17
18def chunks(lst: List, n: int) -> Iterator[List]:
19    for i in range(0, len(lst), n):
20        yield lst[i:i + n]
21
22
23def atoi(text: str) -> str:
24    return f'{int(text):08d}' if text.isdigit() else text
25
26
27def natural_keys(text: str) -> Tuple[str, ...]:
28    return tuple(atoi(c) for c in re.split(r'(\d+)', text))
29
30
31def generate_class(defn: Definition, loc: str) -> Tuple[str, str]:
32    class_lines: List[str] = []
33    tc_lines: List[str] = []
34    a = class_lines.append
35    t = tc_lines.append
36    a('class Options:')
37    t('class Parser:')
38    choices = {}
39    imports: Set[Tuple[str, str]] = set()
40    tc_imports: Set[Tuple[str, str]] = set()
41
42    def type_name(x: type) -> str:
43        ans = x.__name__
44        if x.__module__ and x.__module__ != 'builtins':
45            imports.add((x.__module__, x.__name__))
46        return ans
47
48    def option_type_as_str(x: Any) -> str:
49        if hasattr(x, '__name__'):
50            return type_name(x)
51        ans = repr(x)
52        ans = ans.replace('NoneType', 'None')
53        return ans
54
55    def option_type_data(option: Union[Option, MultiOption]) -> Tuple[Callable, str]:
56        func = option.parser_func
57        if func.__module__ == 'builtins':
58            return func, func.__name__
59        th = get_type_hints(func)
60        rettype = th['return']
61        typ = option_type_as_str(rettype)
62        if isinstance(option, MultiOption):
63            typ = typ[typ.index('[') + 1:-1]
64            typ = typ.replace('Tuple', 'Dict', 1)
65        return func, typ
66
67    is_mutiple_vars = {}
68    option_names = set()
69    color_table = list(map(str, range(256)))
70
71    def parser_function_declaration(option_name: str) -> None:
72        t('')
73        t(f'    def {option_name}(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:')
74
75    for option in sorted(defn.iter_all_options(), key=lambda a: natural_keys(a.name)):
76        option_names.add(option.name)
77        parser_function_declaration(option.name)
78        if isinstance(option, MultiOption):
79            mval: Dict[str, Dict[str, Any]] = {'macos': {}, 'linux': {}, '': {}}
80            func, typ = option_type_data(option)
81            for val in option:
82                if val.add_to_default:
83                    gr = mval[val.only]
84                    for k, v in func(val.defval_as_str):
85                        gr[k] = v
86            is_mutiple_vars[option.name] = typ, mval
87            sig = inspect.signature(func)
88            tc_imports.add((func.__module__, func.__name__))
89            if len(sig.parameters) == 1:
90                t(f'        for k, v in {func.__name__}(val):')
91                t(f'            ans["{option.name}"][k] = v')
92            else:
93                t(f'        for k, v in {func.__name__}(val, ans["{option.name}"]):')
94                t(f'            ans["{option.name}"][k] = v')
95            continue
96
97        if option.choices:
98            typ = 'typing.Literal[{}]'.format(', '.join(repr(x) for x in option.choices))
99            ename = f'choices_for_{option.name}'
100            choices[ename] = typ
101            typ = ename
102            func = str
103        elif defn.has_color_table and option.is_color_table_color:
104            func, typ = option_type_data(option)
105            t(f'        ans[{option.name!r}] = {func.__name__}(val)')
106            tc_imports.add((func.__module__, func.__name__))
107            cnum = int(option.name[5:])
108            color_table[cnum] = '0x{:06x}'.format(func(option.defval_as_string).__int__())
109            continue
110        else:
111            func, typ = option_type_data(option)
112            try:
113                params = inspect.signature(func).parameters
114            except Exception:
115                params = {}
116            if 'dict_with_parse_results' in params:
117                t(f'        {func.__name__}(val, ans)')
118            else:
119                t(f'        ans[{option.name!r}] = {func.__name__}(val)')
120            if func.__module__ != 'builtins':
121                tc_imports.add((func.__module__, func.__name__))
122
123        defval = repr(func(option.defval_as_string))
124        if option.macos_defval is not unset:
125            md = repr(func(option.macos_defval))
126            defval = f'{md} if is_macos else {defval}'
127            imports.add(('kitty.constants', 'is_macos'))
128        a(f'    {option.name}: {typ} = {defval}')
129        if option.choices:
130            t('        val = val.lower()')
131            t(f'        if val not in self.choices_for_{option.name}:')
132            t(f'            raise ValueError(f"The value {{val}} is not a valid choice for {option.name}")')
133            t(f'        ans["{option.name}"] = val')
134            t('')
135            t(f'    choices_for_{option.name} = frozenset({option.choices!r})')
136
137    for option_name, (typ, mval) in is_mutiple_vars.items():
138        a(f'    {option_name}: {typ} = ' '{}')
139
140    for parser, aliases in defn.deprecations.items():
141        for alias in aliases:
142            parser_function_declaration(alias)
143            tc_imports.add((parser.__module__, parser.__name__))
144            t(f'        {parser.__name__}({alias!r}, val, ans)')
145
146    action_parsers = {}
147
148    def resolve_import(ftype: str) -> str:
149        if '.' in ftype:
150            fmod, ftype = ftype.rpartition('.')[::2]
151        else:
152            fmod = f'{loc}.options.utils'
153        imports.add((fmod, ftype))
154        return ftype
155
156    for aname, action in defn.actions.items():
157        option_names.add(aname)
158        action_parsers[aname] = func = action.parser_func
159        th = get_type_hints(func)
160        rettype = th['return']
161        typ = option_type_as_str(rettype)
162        typ = typ[typ.index('[') + 1:-1]
163        a(f'    {aname}: typing.List[{typ}] = []')
164        for imp in action.imports:
165            resolve_import(imp)
166        for fname, ftype in action.fields.items():
167            ftype = resolve_import(ftype)
168            a(f'    {fname}: {ftype} = ' '{}')
169        parser_function_declaration(aname)
170        t(f'        for k in {func.__name__}(val):')
171        t(f'            ans[{aname!r}].append(k)')
172        tc_imports.add((func.__module__, func.__name__))
173
174    if defn.has_color_table:
175        imports.add(('array', 'array'))
176        a('    color_table: array = array("L", (')
177        for grp in chunks(color_table, 8):
178            a('        ' + ', '.join(grp) + ',')
179        a('    ))')
180
181    a('    config_paths: typing.Tuple[str, ...] = ()')
182    a('    config_overrides: typing.Tuple[str, ...] = ()')
183    a('')
184    a('    def __init__(self, options_dict: typing.Optional[typing.Dict[str, typing.Any]] = None) -> None:')
185    if defn.has_color_table:
186        a('        self.color_table = array(self.color_table.typecode, self.color_table)')
187    a('        if options_dict is not None:')
188    a('            null = object()')
189    a('            for key in option_names:')
190    a('                val = options_dict.get(key, null)')
191    a('                if val is not null:')
192    a('                    setattr(self, key, val)')
193
194    a('')
195    a('    @property')
196    a('    def _fields(self) -> typing.Tuple[str, ...]:')
197    a('        return option_names')
198
199    a('')
200    a('    def __iter__(self) -> typing.Iterator[str]:')
201    a('        return iter(self._fields)')
202
203    a('')
204    a('    def __len__(self) -> int:')
205    a('        return len(self._fields)')
206
207    a('')
208    a('    def _copy_of_val(self, name: str) -> typing.Any:')
209    a('        ans = getattr(self, name)')
210    a('        if isinstance(ans, dict):\n            ans = ans.copy()')
211    a('        elif isinstance(ans, list):\n            ans = ans[:]')
212    a('        return ans')
213
214    a('')
215    a('    def _asdict(self) -> typing.Dict[str, typing.Any]:')
216    a('        return {k: self._copy_of_val(k) for k in self}')
217
218    a('')
219    a('    def _replace(self, **kw: typing.Any) -> "Options":')
220    a('        ans = Options()')
221    a('        for name in self:')
222    a('            setattr(ans, name, self._copy_of_val(name))')
223    a('        for name, val in kw.items():')
224    a('            setattr(ans, name, val)')
225    a('        return ans')
226
227    a('')
228    a('    def __getitem__(self, key: typing.Union[int, str]) -> typing.Any:')
229    a('        k = option_names[key] if isinstance(key, int) else key')
230    a('        try:')
231    a('            return getattr(self, k)')
232    a('        except AttributeError:')
233    a('            pass')
234    a('        raise KeyError(f"No option named: {k}")')
235
236    if defn.has_color_table:
237        a('')
238        a('    def __getattr__(self, key: str) -> typing.Any:')
239        a('        if key.startswith("color"):')
240        a('            q = key[5:]')
241        a('            if q.isdigit():')
242        a('                k = int(q)')
243        a('                if 0 <= k <= 255:')
244        a('                    x = self.color_table[k]')
245        a('                    return Color((x >> 16) & 255, (x >> 8) & 255, x & 255)')
246        a('        raise AttributeError(key)')
247        a('')
248        a('    def __setattr__(self, key: str, val: typing.Any) -> typing.Any:')
249        a('        if key.startswith("color"):')
250        a('            q = key[5:]')
251        a('            if q.isdigit():')
252        a('                k = int(q)')
253        a('                if 0 <= k <= 255:')
254        a('                    self.color_table[k] = int(val)')
255        a('                    return')
256        a('        object.__setattr__(self, key, val)')
257
258    a('')
259    a('')
260    a('defaults = Options()')
261    for option_name, (typ, mval) in is_mutiple_vars.items():
262        a(f'defaults.{option_name} = {mval[""]!r}')
263        if mval['macos']:
264            imports.add(('kitty.constants', 'is_macos'))
265            a('if is_macos:')
266            a(f'    defaults.{option_name}.update({mval["macos"]!r}')
267        if mval['macos']:
268            imports.add(('kitty.constants', 'is_macos'))
269            a('if not is_macos:')
270            a(f'    defaults.{option_name}.update({mval["linux"]!r}')
271
272    for aname, func in action_parsers.items():
273        a(f'defaults.{aname} = [')
274        only: Dict[str, List[Tuple[str, Callable]]] = {}
275        for sc in defn.iter_all_maps(aname):
276            if not sc.add_to_default:
277                continue
278            text = sc.parseable_text
279            if sc.only:
280                only.setdefault(sc.only, []).append((text, func))
281            else:
282                for val in func(text):
283                    a(f'    # {sc.name}')
284                    a(f'    {val!r},')
285        a(']')
286        if only:
287            imports.add(('kitty.constants', 'is_macos'))
288            for cond, items in only.items():
289                cond = 'is_macos' if cond == 'macos' else 'not is_macos'
290                a(f'if {cond}:')
291                for (text, func) in items:
292                    for val in func(text):
293                        a(f'    defaults.{aname}.append({val!r})')
294
295    t('')
296    t('')
297    t('def create_result_dict() -> typing.Dict[str, typing.Any]:')
298    t('    return {')
299    for oname in is_mutiple_vars:
300        t(f'        {oname!r}: {{}},')
301    for aname in defn.actions:
302        t(f'        {aname!r}: [],')
303    t('    }')
304
305    t('')
306    t('')
307    t(f'actions = frozenset({tuple(defn.actions)!r})')
308    t('')
309    t('')
310    t('def merge_result_dicts(defaults: typing.Dict[str, typing.Any], vals: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:')
311    t('    ans = {}')
312    t('    for k, v in defaults.items():')
313    t('        if isinstance(v, dict):')
314    t('            ans[k] = merge_dicts(v, vals.get(k, {}))')
315    t('        elif k in actions:')
316    t('            ans[k] = v + vals.get(k, [])')
317    t('        else:')
318    t('            ans[k] = vals.get(k, v)')
319    t('    return ans')
320    tc_imports.add(('kitty.conf.utils', 'merge_dicts'))
321
322    t('')
323    t('')
324    t('parser = Parser()')
325    t('')
326    t('')
327    t('def parse_conf_item(key: str, val: str, ans: typing.Dict[str, typing.Any]) -> bool:')
328    t('    func = getattr(parser, key, None)')
329    t('    if func is not None:')
330    t('        func(val, ans)')
331    t('        return True')
332    t('    return False')
333
334    preamble = ['# generated by gen-config.py DO NOT edit', '# vim:fileencoding=utf-8', '']
335    a = preamble.append
336
337    def output_imports(imports: Set, add_module_imports: bool = True) -> None:
338        a('import typing')
339        seen_mods = {'typing'}
340        mmap: Dict[str, List[str]] = {}
341        for mod, name in imports:
342            mmap.setdefault(mod, []).append(name)
343        for mod in sorted(mmap):
344            names = sorted(mmap[mod])
345            lines = textwrap.wrap(', '.join(names), 100)
346            if len(lines) == 1:
347                s = lines[0]
348            else:
349                s = '\n    '.join(lines)
350                s = f'(\n    {s}\n)'
351            a(f'from {mod} import {s}')
352            if add_module_imports and mod not in seen_mods and mod != s:
353                a(f'import {mod}')
354                seen_mods.add(mod)
355
356    output_imports(imports)
357    a('')
358    if choices:
359        a('if typing.TYPE_CHECKING:')
360        for name, cdefn in choices.items():
361            a(f'    {name} = {cdefn}')
362        a('else:')
363        for name in choices:
364            a(f'    {name} = str')
365
366    a('')
367    a('option_names = (  # {{''{')
368    a(' ' + pprint.pformat(tuple(sorted(option_names, key=natural_keys)))[1:] + '  # }}''}')
369    class_def = '\n'.join(preamble + ['', ''] + class_lines)
370
371    preamble = ['# generated by gen-config.py DO NOT edit', '# vim:fileencoding=utf-8', '']
372    a = preamble.append
373    output_imports(tc_imports, False)
374
375    return class_def, '\n'.join(preamble + ['', ''] + tc_lines)
376
377
378def generate_c_conversion(loc: str, ctypes: List[Option]) -> str:
379    lines: List[str] = []
380    basic_converters = {
381        'int': 'PyLong_AsLong', 'uint': 'PyLong_AsUnsignedLong', 'bool': 'PyObject_IsTrue',
382        'float': 'PyFloat_AsFloat', 'double': 'PyFloat_AsDouble',
383        'time': 'parse_s_double_to_monotonic_t', 'time-ms': 'parse_ms_long_to_monotonic_t'
384    }
385
386    for opt in ctypes:
387        lines.append('')
388        lines.append(f'static void\nconvert_from_python_{opt.name}(PyObject *val, Options *opts) ''{')
389        is_special = opt.ctype.startswith('!')
390        if is_special:
391            func = opt.ctype[1:]
392            lines.append(f'    {func}(val, opts);')
393        else:
394            func = basic_converters.get(opt.ctype, opt.ctype)
395            lines.append(f'    opts->{opt.name} = {func}(val);')
396        lines.append('}')
397        lines.append('')
398        lines.append(f'static void\nconvert_from_opts_{opt.name}(PyObject *py_opts, Options *opts) ''{')
399        lines.append(f'    PyObject *ret = PyObject_GetAttrString(py_opts, "{opt.name}");')
400        lines.append('    if (ret == NULL) return;')
401        lines.append(f'    convert_from_python_{opt.name}(ret, opts);')
402        lines.append('    Py_DECREF(ret);')
403        lines.append('}')
404
405    lines.append('')
406    lines.append('static bool\nconvert_opts_from_python_opts(PyObject *py_opts, Options *opts) ''{')
407    for opt in ctypes:
408        lines.append(f'    convert_from_opts_{opt.name}(py_opts, opts);')
409        lines.append('    if (PyErr_Occurred()) return false;')
410    lines.append('    return true;')
411    lines.append('}')
412
413    preamble = ['// generated by gen-config.py DO NOT edit', '// vim:fileencoding=utf-8', '#pragma once', '#include "to-c.h"']
414    return '\n'.join(preamble + ['', ''] + lines)
415
416
417def write_output(loc: str, defn: Definition) -> None:
418    cls, tc = generate_class(defn, loc)
419    with open(os.path.join(*loc.split('.'), 'options', 'types.py'), 'w') as f:
420        f.write(cls + '\n')
421    with open(os.path.join(*loc.split('.'), 'options', 'parse.py'), 'w') as f:
422        f.write(tc + '\n')
423    ctypes = []
424    for opt in defn.root_group.iter_all_non_groups():
425        if isinstance(opt, Option) and opt.ctype:
426            ctypes.append(opt)
427    if ctypes:
428        c = generate_c_conversion(loc, ctypes)
429        with open(os.path.join(*loc.split('.'), 'options', 'to-c-generated.h'), 'w') as f:
430            f.write(c + '\n')
431
432
433def main() -> None:
434    # To use run it as:
435    # kitty +runpy 'from kitty.conf.generate import main; main()' /path/to/kitten/file.py
436    import importlib
437    import sys
438
439    from kittens.runner import path_to_custom_kitten, resolved_kitten
440    from kitty.constants import config_dir
441
442    kitten = sys.argv[-1]
443    if not kitten.endswith('.py'):
444        kitten += '.py'
445    kitten = resolved_kitten(kitten)
446    path = os.path.realpath(path_to_custom_kitten(config_dir, kitten))
447    if not os.path.dirname(path):
448        raise SystemExit(f'No custom kitten named {kitten} found')
449    sys.path.insert(0, os.path.dirname(path))
450    package_name = os.path.basename(os.path.dirname(path))
451    m = importlib.import_module('kitten_options_definition')
452    defn = getattr(m, 'definition')
453    loc = package_name
454    cls, tc = generate_class(defn, loc)
455    with open(os.path.join(os.path.dirname(path), 'kitten_options_types.py'), 'w') as f:
456        f.write(cls + '\n')
457    with open(os.path.join(os.path.dirname(path), 'kitten_options_parse.py'), 'w') as f:
458        f.write(tc + '\n')
459