From 27c5b6aac5e2aa295dc7a70408c26708ba654e27 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 27 Apr 2025 13:52:56 +0530 Subject: [PATCH] Generate option parser in C for kitty CLI --- gen/go_code.py | 3 +- kitty/cli.py | 182 +++---------------------------- kitty/launcher/main.c | 2 +- kitty/rc/base.py | 4 +- kitty/simple_cli_definitions.py | 188 ++++++++++++++++++++++++++++++-- kitty_tests/options.py | 1 + setup.py | 32 ++++-- 7 files changed, 218 insertions(+), 194 deletions(-) diff --git a/gen/go_code.py b/gen/go_code.py index 80a670d05..2710d1e9b 100755 --- a/gen/go_code.py +++ b/gen/go_code.py @@ -31,7 +31,6 @@ from kitty.actions import get_all_actions from kitty.cli import ( GoOption, go_options_for_seq, - parse_option_spec, ) from kitty.conf.generate import gen_go_code from kitty.conf.types import Definition @@ -43,7 +42,7 @@ from kitty.options.types import Options from kitty.rc.base import RemoteCommand, all_command_names, command_for_name from kitty.remote_control import global_options_spec from kitty.rgb import color_names -from kitty.simple_cli_definitions import CompletionSpec, serialize_as_go_string +from kitty.simple_cli_definitions import CompletionSpec, parse_option_spec, serialize_as_go_string if __name__ == '__main__' and not __package__: import __main__ diff --git a/kitty/cli.py b/kitty/cli.py index 9da5af29a..993f050ff 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -6,14 +6,23 @@ import re import sys from collections.abc import Callable, Iterator, Sequence from re import Match -from typing import Any, NoReturn, TypeVar, Union, cast +from typing import Any, NoReturn, TypeVar, cast from .cli_stub import CLIOptions from .conf.utils import resolve_config from .constants import appname, clear_handled_signals, config_dir, default_pager_for_help, defconf, is_macos, str_version, website_url from .fast_data_types import parse_cli_from_spec, wcswidth from .options.types import Options as KittyOpts -from .simple_cli_definitions import CompletionSpec, CompletionType, OptionDict, kitty_options_spec, serialize_as_go_string +from .simple_cli_definitions import ( + CompletionType, + OptionDict, + OptionSpecSeq, + defval_for_opt, + get_option_maps, + kitty_options_spec, + parse_option_spec, + serialize_as_go_string, +) from .types import run_once from .typing_compat import BadLineType @@ -274,103 +283,6 @@ def disc(x: str) -> str: return ref_hyperlink(x, 'discussions-') -OptionSpecSeq = list[Union[str, OptionDict]] - - -def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpecSeq]: - if spec is None: - spec = kitty_options_spec() - NORMAL, METADATA, HELP = 'NORMAL', 'METADATA', 'HELP' - state = NORMAL - lines = spec.splitlines() - prev_line = '' - prev_indent = 0 - seq: OptionSpecSeq = [] - disabled: OptionSpecSeq = [] - mpat = re.compile('([a-z]+)=(.+)') - current_cmd: OptionDict = { - 'dest': '', 'aliases': (), 'help': '', 'choices': (), - 'type': '', 'condition': False, 'default': None, 'completion': CompletionSpec(), 'name': '' - } - empty_cmd = current_cmd - - def indent_of_line(x: str) -> int: - return len(x) - len(x.lstrip()) - - for line in lines: - line = line.rstrip() - if state is NORMAL: - if not line: - continue - if line.startswith('# '): - seq.append(line[2:]) - continue - if line.startswith('--'): - parts = line.split(' ') - defdest = parts[0][2:].replace('-', '_') - current_cmd = { - 'dest': defdest, 'aliases': tuple(parts), 'help': '', - 'choices': tuple(), 'type': '', 'name': defdest, - 'default': None, 'condition': True, 'completion': CompletionSpec(), - } - state = METADATA - continue - raise ValueError(f'Invalid option spec, unexpected line: {line}') - elif state is METADATA: - m = mpat.match(line) - if m is None: - state = HELP - current_cmd['help'] += line - else: - k, v = m.group(1), m.group(2) - if k == 'choices': - vals = tuple(x.strip() for x in v.split(',')) - if not current_cmd['type']: - current_cmd['type'] = 'choices' - if current_cmd['type'] != 'choices': - raise ValueError(f'Cannot specify choices for an option of type: {current_cmd["type"]}') - current_cmd['choices'] = tuple(vals) - if current_cmd['default'] is None: - current_cmd['default'] = vals[0] - else: - if k == 'default': - current_cmd['default'] = v - elif k == 'type': - if v == 'choice': - v = 'choices' - current_cmd['type'] = v - elif k == 'dest': - current_cmd['dest'] = v - elif k == 'condition': - current_cmd['condition'] = bool(eval(v)) - elif k == 'completion': - current_cmd['completion'] = CompletionSpec.from_string(v) - elif state is HELP: - if line: - current_indent = indent_of_line(line) - if current_indent > 1: - if prev_indent == 0: - current_cmd['help'] += '\n' - else: - line = line.strip() - prev_indent = current_indent - spc = '' if current_cmd['help'].endswith('\n') else ' ' - current_cmd['help'] += spc + line - else: - prev_indent = 0 - if prev_line: - current_cmd['help'] += '\n' if current_cmd['help'].endswith('::') else '\n\n' - else: - state = NORMAL - (seq if current_cmd.get('condition', True) else disabled).append(current_cmd) - current_cmd = empty_cmd - prev_line = line - if current_cmd is not empty_cmd: - (seq if current_cmd.get('condition', True) else disabled).append(current_cmd) - - return seq, disabled - - def prettify(text: str) -> str: def identity(x: str) -> str: @@ -635,21 +547,6 @@ def as_type_stub(seq: OptionSpecSeq, disabled: OptionSpecSeq, class_name: str, e return '\n'.join(ans) + '\n\n\n' -def defval_for_opt(opt: OptionDict) -> Any: - dv: Any = opt.get('default') - typ = opt.get('type', '') - if typ.startswith('bool-'): - if dv is None: - dv = False if typ == 'bool-set' else True - else: - dv = dv.lower() in ('true', 'yes', 'y') - elif typ == 'list': - dv = [] - elif typ in ('int', 'float'): - dv = (int if typ == 'int' else float)(dv or 0) - return dv - - bool_map = {'y': True, 'yes': True, 'true': True, 'n': False, 'no': False, 'false': False} @@ -665,19 +562,9 @@ class Options: do_print = True def __init__(self, seq: OptionSpecSeq, usage: str | None, message: str | None, appname: str | None): - self.alias_map = {} self.seq = seq - self.names_map: dict[str, OptionDict] = {} - self.values_map: dict[str, Any] = {} self.usage, self.message, self.appname = usage, message, appname - for opt in seq: - if isinstance(opt, str): - continue - for alias in opt['aliases']: - self.alias_map[alias] = opt - name = opt['dest'] - self.names_map[name] = opt - self.values_map[name] = defval_for_opt(opt) + self.names_map, self.alias_map, self.values_map = get_option_maps(seq) def handle_help(self) -> NoReturn: if self.do_print: @@ -689,45 +576,6 @@ class Options: print(version()) raise SystemExit(0) - def is_bool(self, alias: str) -> bool: - opt = self.alias_map[alias] - typ = opt.get('type', '') - return typ.startswith('bool-') - - def process_arg(self, alias: str, val: Any = None) -> None: - opt = self.alias_map[alias] - typ = opt.get('type', '') - name = opt['dest'] - nmap = {'float': float, 'int': int} - if typ == 'bool-set': - if val is None: - self.values_map[name] = True - else: - self.values_map[name] = to_bool(alias, val) - elif typ == 'bool-reset': - if val is None: - self.values_map[name] = False - else: - self.values_map[name] = to_bool(alias, val) - elif typ == 'list': - self.values_map.setdefault(name, []) - self.values_map[name].append(val) - elif typ == 'choices': - choices = opt['choices'] - if val not in choices: - raise SystemExit('{} is not a valid value for the {} option. Valid values are: {}'.format( - val, emph(alias), ', '.join(choices))) - self.values_map[name] = val - elif typ in nmap: - f = nmap[typ] - try: - self.values_map[name] = f(val) - except Exception: - raise SystemExit('{} is not a valid value for the {} option, a number is required.'.format( - val, emph(alias))) - else: - self.values_map[name] = val - def parse_cmdline(oc: Options, disabled: OptionSpecSeq, ans: Any, args: list[str] | None = None) -> list[str]: try: @@ -736,10 +584,10 @@ def parse_cmdline(oc: Options, disabled: OptionSpecSeq, ans: Any, args: list[str raise SystemExit(str(e)) for key, (val, is_seen) in vals.items(): - if key == 'version' and is_seen and val: - oc.handle_version() - elif key == 'help' and is_seen and val: + if key == 'help' and is_seen and val: oc.handle_help() + elif key == 'version' and is_seen and val: + oc.handle_version() setattr(ans, key, val) for opt in disabled: if not isinstance(opt, str): diff --git a/kitty/launcher/main.c b/kitty/launcher/main.c index 06a1077c7..07ea97b98 100644 --- a/kitty/launcher/main.c +++ b/kitty/launcher/main.c @@ -308,7 +308,7 @@ exec_kitten(int argc, char *argv[], char *exe_dir) { static bool is_boolean_flag(const char *x) { - static const char *all_boolean_options = KITTY_CLI_BOOL_OPTIONS; + static const char *all_boolean_options = ""; char buf[128]; safe_snprintf(buf, sizeof(buf), " %s ", x); return strstr(all_boolean_options, buf) != NULL; diff --git a/kitty/rc/base.py b/kitty/rc/base.py index 66c28355a..0ffd8bfeb 100644 --- a/kitty/rc/base.py +++ b/kitty/rc/base.py @@ -7,10 +7,10 @@ from dataclasses import dataclass, field from io import BytesIO from typing import TYPE_CHECKING, Any, NoReturn, Optional, 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 from kitty.cli_stub import RCOptions as R from kitty.constants import list_kitty_resources, running_in_kitty -from kitty.simple_cli_definitions import CompletionSpec +from kitty.simple_cli_definitions import CompletionSpec, parse_option_spec from kitty.types import AsyncResponse if TYPE_CHECKING: diff --git a/kitty/simple_cli_definitions.py b/kitty/simple_cli_definitions.py index 05832c9f0..53f5c4b37 100644 --- a/kitty/simple_cli_definitions.py +++ b/kitty/simple_cli_definitions.py @@ -4,20 +4,16 @@ # This module must be runnable by a vanilla python interperter # as it is used to generate C code when building kitty -import os +import re import sys from dataclasses import dataclass from enum import Enum, auto -from typing import Iterator, TypedDict +from typing import Any, Iterator, TypedDict try: from kitty.constants import appname, is_macos except ImportError: is_macos = 'darwin' in sys.platform.lower() - try: - appname - except NameError: - appname = os.environ['KITTY_APPNAME'] try: from kitty.utils import shlex_split as ksplit def shlex_split(text: str) -> Iterator[str]: @@ -122,6 +118,182 @@ class OptionDict(TypedDict): completion: CompletionSpec +OptionSpecSeq = list[str | OptionDict] + + +def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpecSeq]: + if spec is None: + spec = kitty_options_spec() + NORMAL, METADATA, HELP = 'NORMAL', 'METADATA', 'HELP' + state = NORMAL + lines = spec.splitlines() + prev_line = '' + prev_indent = 0 + seq: OptionSpecSeq = [] + disabled: OptionSpecSeq = [] + mpat = re.compile('([a-z]+)=(.+)') + current_cmd: OptionDict = { + 'dest': '', 'aliases': (), 'help': '', 'choices': (), + 'type': '', 'condition': False, 'default': None, 'completion': CompletionSpec(), 'name': '' + } + empty_cmd = current_cmd + + def indent_of_line(x: str) -> int: + return len(x) - len(x.lstrip()) + + for line in lines: + line = line.rstrip() + if state is NORMAL: + if not line: + continue + if line.startswith('# '): + seq.append(line[2:]) + continue + if line.startswith('--'): + parts = line.split(' ') + defdest = parts[0][2:].replace('-', '_') + current_cmd = { + 'dest': defdest, 'aliases': tuple(parts), 'help': '', + 'choices': tuple(), 'type': '', 'name': defdest, + 'default': None, 'condition': True, 'completion': CompletionSpec(), + } + state = METADATA + continue + raise ValueError(f'Invalid option spec, unexpected line: {line}') + elif state is METADATA: + m = mpat.match(line) + if m is None: + state = HELP + current_cmd['help'] += line + else: + k, v = m.group(1), m.group(2) + if k == 'choices': + vals = tuple(x.strip() for x in v.split(',')) + if not current_cmd['type']: + current_cmd['type'] = 'choices' + if current_cmd['type'] != 'choices': + raise ValueError(f'Cannot specify choices for an option of type: {current_cmd["type"]}') + current_cmd['choices'] = tuple(vals) + if current_cmd['default'] is None: + current_cmd['default'] = vals[0] + else: + if k == 'default': + current_cmd['default'] = v + elif k == 'type': + if v == 'choice': + v = 'choices' + current_cmd['type'] = v + elif k == 'dest': + current_cmd['dest'] = v + elif k == 'condition': + current_cmd['condition'] = bool(eval(v)) + elif k == 'completion': + current_cmd['completion'] = CompletionSpec.from_string(v) + elif state is HELP: + if line: + current_indent = indent_of_line(line) + if current_indent > 1: + if prev_indent == 0: + current_cmd['help'] += '\n' + else: + line = line.strip() + prev_indent = current_indent + spc = '' if current_cmd['help'].endswith('\n') else ' ' + current_cmd['help'] += spc + line + else: + prev_indent = 0 + if prev_line: + current_cmd['help'] += '\n' if current_cmd['help'].endswith('::') else '\n\n' + else: + state = NORMAL + (seq if current_cmd.get('condition', True) else disabled).append(current_cmd) + current_cmd = empty_cmd + prev_line = line + if current_cmd is not empty_cmd: + (seq if current_cmd.get('condition', True) else disabled).append(current_cmd) + + return seq, disabled + + +def defval_for_opt(opt: OptionDict) -> Any: + dv: Any = opt.get('default') + typ = opt.get('type', '') + if typ.startswith('bool-'): + if dv is None: + dv = False if typ == 'bool-set' else True + else: + dv = dv.lower() in ('true', 'yes', 'y') + elif typ == 'list': + dv = [] + elif typ in ('int', 'float'): + dv = (int if typ == 'int' else float)(dv or 0) + return dv + + +def get_option_maps(seq: OptionSpecSeq) -> tuple[dict[str, OptionDict], dict[str, OptionDict], dict[str, Any]]: + names_map: dict[str, OptionDict] = {} + alias_map: dict[str, OptionDict] = {} + values_map: dict[str, Any] = {} + for opt in seq: + if isinstance(opt, str): + continue + for alias in opt['aliases']: + alias_map[alias] = opt + name = opt['dest'] + names_map[name] = opt + values_map[name] = defval_for_opt(opt) + return names_map, alias_map, values_map + + +def c_str(x: str) -> str: + x = x.replace('\\', r'\\') + return f'"{x}"' + + +def generate_c_parser_for(funcname: str, spec: str) -> Iterator[str]: + names_map, _, defaults_map = get_option_maps(parse_option_spec(spec)[0]) + yield f'static void\nparse_cli_for_{funcname}(CLISpec *spec, int argc, char **argv) {{' # }} + yield '\tFlagSpec flag;' + for name, opt in names_map.items(): + for alias in opt['aliases']: + yield f'\tif (vt_is_end(vt_insert(&spec->alias_map, {c_str(alias)}, {c_str(name)}))) OOM;' + yield f'\tflag = (FlagSpec){{.dest={c_str(name)},}};' + defval = defaults_map[name] + match opt['type']: + case 'bool-set' | 'bool-reset': + yield '\tflag.defval.type = CLI_VALUE_BOOL;' + yield f'\tflag.defval.boolval = {"true" if defval else "false"};' + case 'int': + yield '\tflag.defval.type = CLI_VALUE_INT;' + yield f'\tflag.defval.intval = {defval};' + case 'float': + yield '\tflag.defval.type = CLI_VALUE_FLOAT;' + yield f'\tflag.defval.floatval = {defval};' + case 'list': + yield '\tflag.defval.type = CLI_VALUE_LIST;' + case 'choices': + yield '\tflag.defval.type = CLI_VALUE_CHOICE;' + yield f'\tflag.defval.strval = {c_str(defval)};' + choices = opt['choices'] + yield f'\tflag.defval.listval.items = alloc_for_cli(spec, {len(choices)} * sizeof(flag.defval.listval.items[0]));' + yield '\tif (!flag.defval.listval.items) OOM;' + yield f'\tflag.defval.listval.count = {len(choices)};' + yield f'\tflag.defval.listval.capacity = {len(choices)};' + for n, choice in enumerate(choices): + yield f'\tflag.defval.listval.items[{n}] = {c_str(choice)};' + case _: + yield '\tflag.defval.type = CLI_VALUE_STRING;' + yield f'\tflag.defval.strval = {"NULL" if defval is None else c_str(defval)};' + yield '\tif (vt_is_end(vt_insert(&spec->flag_map, flag.dest, flag))) OOM;' + yield '}' + + +def generate_c_parsers() -> Iterator[str]: + yield '#pragma once' + yield '// generated by simple_cli_definitions.py do NOT edit!' + yield '#include "cli-parser.h"' + yield from generate_c_parser_for('kitty', kitty_options_spec()) + yield '' listen_on_defn = f'''\ @@ -336,7 +508,3 @@ type=bool-set )) ans: str = getattr(kitty_options_spec, 'ans') return ans - - -if __name__ == '__main__': - print(111111111, appname) diff --git a/kitty_tests/options.py b/kitty_tests/options.py index 3df0b8cb4..5751529e0 100644 --- a/kitty_tests/options.py +++ b/kitty_tests/options.py @@ -109,6 +109,7 @@ version t('-1 -0v', fails=True) t('-1 -v0', fails=True) t('-1 --version', fails=True) + t('-f=3.142 --int 17', float=3.142, int=17) def conf_parsing(self): diff --git a/setup.py b/setup.py index bf6631292..51ef603b8 100755 --- a/setup.py +++ b/setup.py @@ -1058,17 +1058,30 @@ def extract_rst_targets() -> Dict[str, Dict[str, str]]: return cast(Dict[str, Dict[str, str]], m['main']()) +def update_if_changed(path: str, text: str) -> None: + q = '' + with suppress(FileNotFoundError), open(path) as f: + q = f.read() + if q != text: + with open(path, 'w') as f: + f.write(text) + + def build_ref_map(skip_generation: bool = False) -> str: dest = 'kitty/docs_ref_map_generated.h' if not skip_generation: d = extract_rst_targets() h = 'static const char docs_ref_map[] = {\n' + textwrap.fill(', '.join(map(str, bytearray(json.dumps(d, sort_keys=True).encode('utf-8'))))) + '\n};\n' - q = '' - with suppress(FileNotFoundError), open(dest) as f: - q = f.read() - if q != h: - with open(dest, 'w') as f: - f.write(h) + update_if_changed(dest, h) + return dest + + +def build_cli_parser_specs(skip_generation: bool = False) -> str: + dest = 'kitty/launcher/cli-parser-data_generated.h' + if not skip_generation: + m = runpy.run_path('kitty/simple_cli_definitions.py', {'appname': appname}) + h = '\n'.join(m['generate_c_parsers']()) + update_if_changed(dest, h) return dest @@ -1136,6 +1149,7 @@ def build(args: Options, native_optimizations: bool = True, call_init: bool = Tr init_env_from_args(args, native_optimizations) sources, headers = find_c_files() headers.append(build_ref_map(args.skip_code_generation)) + headers.append(build_cli_parser_specs(args.skip_code_generation)) headers.append(build_uniforms_header(args.skip_code_generation)) compile_c_extension( kitty_env(args), 'kitty/fast_data_types', args.compilation_database, sources, headers, @@ -1281,11 +1295,6 @@ def read_bool_options(path: str = 'kitty/cli.py') -> Tuple[str, ...]: return tuple(ans) -@lru_cache(2) -def kitty_cli_boolean_options() -> Tuple[str, ...]: - return tuple(sorted(set(read_bool_options()) | set(read_bool_options('kittens/panel/main.py')))) - - def build_launcher(args: Options, launcher_dir: str = '.', bundle_type: str = 'source') -> str: werror = '' if args.ignore_compiler_warnings else '-pedantic-errors -Werror' cflags = f'-Wall {werror} -fpie'.split() @@ -1343,7 +1352,6 @@ def build_launcher(args: Options, launcher_dir: str = '.', bundle_type: str = 's os.makedirs(launcher_dir, exist_ok=True) os.makedirs(build_dir, exist_ok=True) objects = [] - cppflags.append('-DKITTY_CLI_BOOL_OPTIONS=" ' + ' '.join(kitty_cli_boolean_options()) + ' "') cppflags.append('-DKITTY_VERSION="' + '.'.join(map(str, version)) + '"') for src in ('kitty/launcher/main.c', 'kitty/launcher/single-instance.c', 'kitty/launcher/cmdline.c'): obj = os.path.join(build_dir, src.replace('/', '-').replace('.c', '.o'))