Generate option parser in C for kitty CLI

This commit is contained in:
Kovid Goyal 2025-04-27 13:52:56 +05:30
parent 0a288ad56c
commit 27c5b6aac5
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
7 changed files with 218 additions and 194 deletions

View file

@ -31,7 +31,6 @@
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.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__

View file

@ -6,14 +6,23 @@
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 @@ def handle_version(self) -> NoReturn:
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):

View file

@ -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;

View file

@ -7,10 +7,10 @@
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:

View file

@ -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 @@ def kitty_options_spec() -> str:
))
ans: str = getattr(kitty_options_spec, 'ans')
return ans
if __name__ == '__main__':
print(111111111, appname)

View file

@ -109,6 +109,7 @@ def t(args, leftover=(), fails=False, **expected):
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):

View file

@ -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'))