Cache parsing of command line specifications

Change option specification from dict to tuple for better performance
and immutability and better type checking.
This commit is contained in:
Kovid Goyal
2025-09-29 12:31:10 +05:30
parent 1252098016
commit d8b524c692
7 changed files with 130 additions and 125 deletions

View File

@@ -247,7 +247,7 @@ def clone_safe_launch_opts() -> Sequence[GoOption]:
ans = [] ans = []
allowed = clone_safe_opts() allowed = clone_safe_opts()
for o in go_options_for_seq(parse_option_spec(options_spec())[0]): for o in go_options_for_seq(parse_option_spec(options_spec())[0]):
if o.obj_dict['name'] in allowed: if o.obj_defn.name in allowed:
ans.append(o) ans.append(o)
return tuple(ans) return tuple(ans)

View File

@@ -29,16 +29,16 @@ def migrate_help(x: str) -> str:
def help_of(x: str) -> str: def help_of(x: str) -> str:
return migrate_help(panel_opts[x]['help']) return migrate_help(panel_opts[x].help)
agr('qat', 'Window appearance') agr('qat', 'Window appearance')
opt('lines', '25', long_text=panel_opts['lines']['help']) opt('lines', '25', long_text=panel_opts['lines'].help)
opt('columns', '80', long_text=panel_opts['columns']['help']) opt('columns', '80', long_text=panel_opts['columns'].help)
opt('edge', 'top', choices=panel_opts['edge']['choices'], long_text=help_of('edge')) opt('edge', 'top', choices=panel_opts['edge'].choices, long_text=help_of('edge'))
opt('background_opacity', '0.85', option_type='unit_float', long_text=''' opt('background_opacity', '0.85', option_type='unit_float', long_text='''
The background opacity of the window. This works the same as the kitty The background opacity of the window. This works the same as the kitty
@@ -80,7 +80,7 @@ opt('output_name', '', long_text=help_of('output_name'))
opt('start_as_hidden', 'no', option_type='to_bool', opt('start_as_hidden', 'no', option_type='to_bool',
long_text='Whether to start the quick access terminal hidden. Useful if you are starting it as part of system startup.') long_text='Whether to start the quick access terminal hidden. Useful if you are starting it as part of system startup.')
opt('focus_policy', 'exclusive', choices=panel_opts['focus_policy']['choices'], long_text=help_of('focus_policy')) opt('focus_policy', 'exclusive', choices=panel_opts['focus_policy'].choices, long_text=help_of('focus_policy'))

View File

@@ -15,7 +15,7 @@ from .fast_data_types import parse_cli_from_spec, wcswidth
from .options.types import Options as KittyOpts from .options.types import Options as KittyOpts
from .simple_cli_definitions import ( from .simple_cli_definitions import (
CompletionType, CompletionType,
OptionDict, OptionDefinition,
OptionSpecSeq, OptionSpecSeq,
defval_for_opt, defval_for_opt,
get_option_maps, get_option_maps,
@@ -34,34 +34,34 @@ go_type_map = {
class GoOption: class GoOption:
def __init__(self, x: OptionDict) -> None: def __init__(self, x: OptionDefinition) -> None:
flags = sorted(x['aliases'], key=len) flags = sorted(x.aliases, key=len)
short = '' short = ''
self.aliases = [] self.aliases = []
if len(flags) > 1 and not flags[0].startswith("--"): if len(flags) > 1 and not flags[0].startswith("--"):
short = flags[0][1:] short = flags[0][1:]
self.short, self.long = short, x['name'].replace('_', '-') self.short, self.long = short, x.name.replace('_', '-')
for f in flags: for f in flags:
q = f[2:] if f.startswith('--') else f[1:] q = f[2:] if f.startswith('--') else f[1:]
self.aliases.append(q) self.aliases.append(q)
self.type = x['type'] self.type = x.type
if x['choices']: if x.choices:
self.type = 'choices' self.type = 'choices'
self.default = x['default'] self.default = x.default
self.obj_dict = x self.obj_defn = x
self.go_type = go_type_map[self.type] self.go_type = go_type_map[self.type]
if x['dest']: if x.dest:
self.go_var_name = ''.join(x.capitalize() for x in x['dest'].replace('-', '_').split('_')) self.go_var_name = ''.join(x.capitalize() for x in x.dest.replace('-', '_').split('_'))
else: else:
self.go_var_name = ''.join(x.capitalize() for x in self.long.replace('-', '_').split('_')) self.go_var_name = ''.join(x.capitalize() for x in self.long.replace('-', '_').split('_'))
self.help_text = serialize_as_go_string(self.obj_dict['help'].strip()) self.help_text = serialize_as_go_string(self.obj_defn.help.strip())
def struct_declaration(self) -> str: def struct_declaration(self) -> str:
return f'{self.go_var_name} {self.go_type}' return f'{self.go_var_name} {self.go_type}'
@property @property
def flags(self) -> list[str]: def flags(self) -> list[str]:
return sorted(self.obj_dict['aliases']) return sorted(self.obj_defn.aliases)
def as_option(self, cmd_name: str = 'cmd', depth: int = 0, group: str = '') -> str: def as_option(self, cmd_name: str = 'cmd', depth: int = 0, group: str = '') -> str:
add = f'AddToGroup("{serialize_as_go_string(group)}", ' if group else 'Add(' add = f'AddToGroup("{serialize_as_go_string(group)}", ' if group else 'Add('
@@ -77,8 +77,8 @@ class GoOption:
cx = ', '.join(f'"{serialize_as_go_string(x)}"' for x in self.sorted_choices) cx = ', '.join(f'"{serialize_as_go_string(x)}"' for x in self.sorted_choices)
ans += f'\nChoices: "{serialize_as_go_string(c)}",\n' ans += f'\nChoices: "{serialize_as_go_string(c)}",\n'
ans += f'\nCompleter: cli.NamesCompleter("Choices for {self.long}", {cx}),' ans += f'\nCompleter: cli.NamesCompleter("Choices for {self.long}", {cx}),'
elif self.obj_dict['completion'].type is not CompletionType.none: elif self.obj_defn.completion.type is not CompletionType.none:
ans += ''.join(self.obj_dict['completion'].as_go_code('Completer', ': ')) + ',' ans += ''.join(self.obj_defn.completion.as_go_code('Completer', ': ')) + ','
if depth > 0: if depth > 0:
ans += f'\n\tDepth: {depth},\n' ans += f'\n\tDepth: {depth},\n'
if self.default: if self.default:
@@ -111,7 +111,7 @@ class GoOption:
@property @property
def sorted_choices(self) -> list[str]: def sorted_choices(self) -> list[str]:
choices = sorted(self.obj_dict['choices']) choices = sorted(self.obj_defn.choices)
choices.remove(self.default or '') choices.remove(self.default or '')
choices.insert(0, self.default or '') choices.insert(0, self.default or '')
return choices return choices
@@ -374,7 +374,7 @@ def get_defaults_from_seq(seq: OptionSpecSeq) -> dict[str, Any]:
ans: dict[str, Any] = {} ans: dict[str, Any] = {}
for opt in seq: for opt in seq:
if not isinstance(opt, str): if not isinstance(opt, str):
ans[opt['dest']] = defval_for_opt(opt) ans[opt.dest] = defval_for_opt(opt)
return ans return ans
@@ -432,24 +432,24 @@ class PrintHelpForSeq:
if isinstance(opt, str): if isinstance(opt, str):
a(f'{title(opt)}:') a(f'{title(opt)}:')
continue continue
help_text = opt['help'] help_text = opt.help
if help_text == '!': if help_text == '!':
continue # hidden option continue # hidden option
a(' ' + ', '.join(map(green, sorted(opt['aliases'], reverse=True)))) a(' ' + ', '.join(map(green, sorted(opt.aliases, reverse=True))))
defval = opt.get('default') defval = opt.default
if (otype := opt.get('type', '')).startswith('bool-'): if (otype := opt.type).startswith('bool-'):
blocks[-1] += italic(f'[={help_defval_for_bool(otype)}]') blocks[-1] += italic(f'[={help_defval_for_bool(otype)}]')
else: else:
dt = f'''=[{italic(defval or '""')}]''' dt = f'''=[{italic(defval or '""')}]'''
blocks[-1] += dt blocks[-1] += dt
if opt.get('help'): if opt.help:
t = help_text.replace('%default', str(defval)).strip() t = help_text.replace('%default', str(defval)).strip()
# replace rst literal code block syntax # replace rst literal code block syntax
t = t.replace('::\n\n', ':\n\n') t = t.replace('::\n\n', ':\n\n')
t = t.replace('#placeholder_for_formatting#', '') t = t.replace('#placeholder_for_formatting#', '')
wa(prettify(t), indent=4) wa(prettify(t), indent=4)
if opt.get('choices'): if opt.choices:
wa('Choices: {}'.format(', '.join(opt['choices'])), indent=4) wa('Choices: {}'.format(', '.join(opt.choices)), indent=4)
a('') a('')
text = '\n'.join(blocks) + '\n\n' + version() text = '\n'.join(blocks) + '\n\n' + version()
@@ -510,25 +510,25 @@ def seq_as_rst(
a(opt) a(opt)
a('~' * (len(opt) + 10)) a('~' * (len(opt) + 10))
continue continue
help_text = opt['help'] help_text = opt.help
if help_text == '!': if help_text == '!':
continue # hidden option continue # hidden option
defn = '.. option:: ' defn = '.. option:: '
if (otype := opt.get('type', '')).startswith('bool-'): if (otype := opt.type).startswith('bool-'):
val_name = f' [={help_defval_for_bool(otype)}]' val_name = f' [={help_defval_for_bool(otype)}]'
else: else:
val_name = ' <{}>'.format(opt['dest'].upper()) val_name = ' <{}>'.format(opt.dest.upper())
a(defn + ', '.join(o + val_name for o in sorted(opt['aliases']))) a(defn + ', '.join(o + val_name for o in sorted(opt.aliases)))
if opt.get('help'): if opt.help:
defval = opt.get('default') defval = opt.default
t = help_text.replace('%default', ':code:`' + escape_rst(str(defval)) + '`').strip() t = help_text.replace('%default', ':code:`' + escape_rst(str(defval)) + '`').strip()
t = t.replace('#placeholder_for_formatting#', '') t = t.replace('#placeholder_for_formatting#', '')
a('') a('')
a(textwrap.indent(prettify_rst(t), ' ' * 4)) a(textwrap.indent(prettify_rst(t), ' ' * 4))
if defval is not None: if defval is not None:
a(textwrap.indent(f'Default: :code:`{escape_rst(str(defval))}`', ' ' * 4)) a(textwrap.indent(f'Default: :code:`{escape_rst(str(defval))}`', ' ' * 4))
if opt.get('choices'): if opt.choices:
a(textwrap.indent('Choices: {}'.format(', '.join(f':code:`{escape_rst(c)}`' for c in sorted(opt['choices']))), ' ' * 4)) a(textwrap.indent('Choices: {}'.format(', '.join(f':code:`{escape_rst(c)}`' for c in sorted(opt.choices))), ' ' * 4))
a('') a('')
text = '\n'.join(blocks) text = '\n'.join(blocks)
@@ -541,8 +541,8 @@ def as_type_stub(seq: OptionSpecSeq, disabled: OptionSpecSeq, class_name: str, e
for opt in chain(seq, disabled): for opt in chain(seq, disabled):
if isinstance(opt, str): if isinstance(opt, str):
continue continue
name = opt['dest'] name = opt.dest
otype = opt['type'] or 'str' otype = opt.type or 'str'
if otype in ('str', 'int', 'float'): if otype in ('str', 'int', 'float'):
t = otype t = otype
if t == 'str' and defval_for_opt(opt) is None: if t == 'str' and defval_for_opt(opt) is None:
@@ -550,8 +550,8 @@ def as_type_stub(seq: OptionSpecSeq, disabled: OptionSpecSeq, class_name: str, e
elif otype == 'list': elif otype == 'list':
t = 'typing.Sequence[str]' t = 'typing.Sequence[str]'
elif otype in ('choice', 'choices'): elif otype in ('choice', 'choices'):
if opt['choices']: if opt.choices:
t = 'typing.Literal[{}]'.format(','.join(f'{x!r}' for x in opt['choices'])) t = 'typing.Literal[{}]'.format(','.join(f'{x!r}' for x in opt.choices))
else: else:
t = 'str' t = 'str'
elif otype.startswith('bool-'): elif otype.startswith('bool-'):
@@ -616,14 +616,14 @@ def apply_preparsed_cli_flags(
def parse_cmdline_inner( def parse_cmdline_inner(
args: list[str], oc: Options, disabled: OptionSpecSeq, names_map: dict[str, OptionDict], args: list[str], oc: Options, disabled: OptionSpecSeq, names_map: dict[str, OptionDefinition],
values_map: dict[str, OptionDict], ans: Any, track_seen_options: dict[str, Any] | None = None values_map: dict[str, OptionDefinition], ans: Any, track_seen_options: dict[str, Any] | None = None
) -> list[str]: ) -> list[str]:
preparsed = parse_cli_from_spec(args, names_map, values_map) preparsed = parse_cli_from_spec(args, names_map, values_map)
leftover_args = apply_preparsed_cli_flags(preparsed, ans, lambda: oc, track_seen_options) leftover_args = apply_preparsed_cli_flags(preparsed, ans, lambda: oc, track_seen_options)
for opt in disabled: for opt in disabled:
if not isinstance(opt, str): if not isinstance(opt, str):
setattr(ans, opt['dest'], defval_for_opt(opt)) setattr(ans, opt.dest, defval_for_opt(opt))
return leftover_args return leftover_args
@@ -634,10 +634,10 @@ def parse_cmdline(
names_map = oc.names_map.copy() names_map = oc.names_map.copy()
values_map = oc.values_map.copy() values_map = oc.values_map.copy()
if 'help' not in names_map: if 'help' not in names_map:
names_map['help'] = {'type': 'bool-set', 'aliases': ('--help', '-h')} # type: ignore names_map['help'] = OptionDefinition(type='bool-set', aliases=('--help', '-h'))
values_map['help'] = False values_map['help'] = False
if 'version' not in names_map: if 'version' not in names_map:
names_map['version'] = {'type': 'bool-set', 'aliases': ('--version', '-v')} # type: ignore names_map['version'] = OptionDefinition(type='bool-set', aliases=('--version', '-v'))
values_map['version'] = False values_map['version'] = False
try: try:
return parse_cmdline_inner(sys.argv[1:] if args is None else args, oc, disabled, names_map, values_map, ans, track_seen_options) return parse_cmdline_inner(sys.argv[1:] if args is None else args, oc, disabled, names_map, values_map, ans, track_seen_options)

View File

@@ -8,7 +8,7 @@ from kitty.fonts.render import FontObject
from kitty.marks import MarkerFunc from kitty.marks import MarkerFunc
from kitty.notifications import MacOSNotificationCategory from kitty.notifications import MacOSNotificationCategory
from kitty.options.types import Options from kitty.options.types import Options
from kitty.simple_cli_definitions import OptionDict from kitty.simple_cli_definitions import OptionDefinition
from kitty.types import LayerShellConfig, SignalInfo from kitty.types import LayerShellConfig, SignalInfo
from kitty.typing_compat import EdgeLiteral, NotRequired, ReadableBuffer, WriteableBuffer from kitty.typing_compat import EdgeLiteral, NotRequired, ReadableBuffer, WriteableBuffer
@@ -1720,7 +1720,9 @@ def set_clipboard_data_types(ct: int, mime_types: Tuple[str, ...]) -> None: ...
def get_clipboard_mime(ct: int, mime: Optional[str], callback: Callable[[bytes], None]) -> None: ... def get_clipboard_mime(ct: int, mime: Optional[str], callback: Callable[[bytes], None]) -> None: ...
def run_with_activation_token(func: Callable[[str], None]) -> bool: ... def run_with_activation_token(func: Callable[[str], None]) -> bool: ...
def toggle_os_window_visibility(os_window_id: int, visible: bool | Literal[-1] = -1, move_to_active_screen: bool = False) -> bool: ... def toggle_os_window_visibility(os_window_id: int, visible: bool | Literal[-1] = -1, move_to_active_screen: bool = False) -> bool: ...
def parse_cli_from_spec(args: list[str], names_map: dict[str, OptionDict], defval_map: dict[str, Any]) -> tuple[dict[str, tuple[Any, bool]], list[str]]: ... def parse_cli_from_spec(
args: list[str], names_map: dict[str, OptionDefinition], defval_map: dict[str, Any]
) -> tuple[dict[str, tuple[Any, bool]], list[str]]: ...
def layer_shell_config_for_os_window(os_window_id: int) -> dict[str, Any] | None: ... def layer_shell_config_for_os_window(os_window_id: int) -> dict[str, Any] | None: ...
def set_layer_shell_config(os_window_id: int, cfg: LayerShellConfig) -> bool: ... def set_layer_shell_config(os_window_id: int, cfg: LayerShellConfig) -> bool: ...
def wrapped_kitten_names() -> List[str]: ... def wrapped_kitten_names() -> List[str]: ...

View File

@@ -559,14 +559,16 @@ parse_cli_from_python_spec(PyObject *self, PyObject *args) {
memcpy(argv[i + 1], src, sz); memcpy(argv[i + 1], src, sz);
} }
argv[++argc] = 0; argv[++argc] = 0;
PyObject *key = NULL, *opt = NULL; PyObject *key = NULL, *optdef = NULL;
Py_ssize_t pos = 0; Py_ssize_t pos = 0;
while (PyDict_Next(names_map, &pos, &key, &opt)) { while (PyDict_Next(names_map, &pos, &key, &optdef)) {
FlagSpec flag = {.dest=PyUnicode_AsUTF8(key)}; FlagSpec flag = {.dest=PyUnicode_AsUTF8(key)};
PyObject *pytype = PyDict_GetItemString(opt, "type"); RAII_PyObject(pytype, PyObject_GetAttrString(optdef, "type"));
const char *type = pytype ? PyUnicode_AsUTF8(pytype) : ""; if (!pytype) return NULL;
const char *type = PyUnicode_AsUTF8(pytype);
PyObject *defval = PyDict_GetItemWithError(defval_map, key); if (!defval && PyErr_Occurred()) return NULL; PyObject *defval = PyDict_GetItemWithError(defval_map, key); if (!defval && PyErr_Occurred()) return NULL;
PyObject *pyaliases = PyDict_GetItemString(opt, "aliases"); RAII_PyObject(pyaliases, PyObject_GetAttrString(optdef, "aliases"));
if (!pyaliases) return NULL;
for (int a = 0; a < PyTuple_GET_SIZE(pyaliases); a++) { for (int a = 0; a < PyTuple_GET_SIZE(pyaliases); a++) {
const char *alias = PyUnicode_AsUTF8(PyTuple_GET_ITEM(pyaliases, a)); const char *alias = PyUnicode_AsUTF8(PyTuple_GET_ITEM(pyaliases, a));
if (vt_is_end(vt_insert(&spec.alias_map, alias, flag.dest))) return PyErr_NoMemory(); if (vt_is_end(vt_insert(&spec.alias_map, alias, flag.dest))) return PyErr_NoMemory();
@@ -588,7 +590,8 @@ parse_cli_from_python_spec(PyObject *self, PyObject *args) {
} else if (strcmp(type, "choices") == 0) { } else if (strcmp(type, "choices") == 0) {
flag.defval.type = CLI_VALUE_CHOICE; flag.defval.type = CLI_VALUE_CHOICE;
flag.defval.strval = PyUnicode_AsUTF8(defval); flag.defval.strval = PyUnicode_AsUTF8(defval);
PyObject *pyc = PyDict_GetItemString(opt, "choices"); RAII_PyObject(pyc, PyObject_GetAttrString(optdef, "choices"));
if (!pyc) return NULL;
flag.defval.listval.items = alloc_for_cli(&spec, PyTuple_GET_SIZE(pyc) * sizeof(char*)); flag.defval.listval.items = alloc_for_cli(&spec, PyTuple_GET_SIZE(pyc) * sizeof(char*));
if (!flag.defval.listval.items) return PyErr_NoMemory(); if (!flag.defval.listval.items) return PyErr_NoMemory();
flag.defval.listval.count = PyTuple_GET_SIZE(pyc); flag.defval.listval.count = PyTuple_GET_SIZE(pyc);

View File

@@ -82,8 +82,8 @@ CmdGenerator = Iterator[CmdReturnType]
PayloadType = Optional[Union[CmdReturnType, CmdGenerator]] PayloadType = Optional[Union[CmdReturnType, CmdGenerator]]
PayloadGetType = PayloadGetter PayloadGetType = PayloadGetter
ArgsType = list[str] ArgsType = list[str]
ImageCompletion = CompletionSpec.from_string('type:file group:"Images"') ImageCompletion = CompletionSpec.from_string('type:file group:"Images"')._replace(
ImageCompletion.extensions = 'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'tiff' extensions=('png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'tiff'))
SUPPORTED_IMAGE_FORMATS = tuple(x.upper() for x in ImageCompletion.extensions if x != 'jpg') SUPPORTED_IMAGE_FORMATS = tuple(x.upper() for x in ImageCompletion.extensions if x != 'jpg')

View File

@@ -6,9 +6,9 @@
import re import re
import sys import sys
from dataclasses import dataclass
from enum import Enum, auto from enum import Enum, auto
from typing import Any, Iterator, Sequence, TypedDict from functools import lru_cache
from typing import Any, Iterator, NamedTuple, Sequence
try: try:
from kitty.constants import appname, is_macos from kitty.constants import appname, is_macos
@@ -42,8 +42,7 @@ class CompletionRelativeTo(Enum):
config_dir = auto() config_dir = auto()
@dataclass class CompletionSpec(NamedTuple):
class CompletionSpec:
type: CompletionType = CompletionType.none type: CompletionType = CompletionType.none
kwds: tuple[str,...] = () kwds: tuple[str,...] = ()
@@ -54,27 +53,33 @@ class CompletionSpec:
@staticmethod @staticmethod
def from_string(raw: str) -> 'CompletionSpec': def from_string(raw: str) -> 'CompletionSpec':
self = CompletionSpec() typ = CompletionType.none
kwds: tuple[str, ...] = ()
extensions: tuple[str, ...] = ()
mime_patterns: tuple[str, ...] = ()
group = ''
relative_to = CompletionRelativeTo.cwd
for x in shlex_split(raw): for x in shlex_split(raw):
ck, vv = x.split(':', 1) ck, vv = x.split(':', 1)
if ck == 'type': if ck == 'type':
self.type = getattr(CompletionType, vv) typ = getattr(CompletionType, vv)
elif ck == 'kwds': elif ck == 'kwds':
self.kwds += tuple(vv.split(',')) kwds += tuple(vv.split(','))
elif ck == 'ext': elif ck == 'ext':
self.extensions += tuple(vv.split(',')) extensions += tuple(vv.split(','))
elif ck == 'group': elif ck == 'group':
self.group = vv group = vv
elif ck == 'mime': elif ck == 'mime':
self.mime_patterns += tuple(vv.split(',')) mime_patterns += tuple(vv.split(','))
elif ck == 'relative': elif ck == 'relative':
if vv == 'conf': if vv == 'conf':
self.relative_to = CompletionRelativeTo.config_dir relative_to = CompletionRelativeTo.config_dir
else: else:
raise ValueError(f'Unknown completion relative to value: {vv}') raise ValueError(f'Unknown completion relative to value: {vv}')
else: else:
raise KeyError(f'Unknown completion property: {ck}') raise KeyError(f'Unknown completion property: {ck}')
return self return CompletionSpec(
type=typ, kwds=kwds, extensions=extensions, mime_patterns=mime_patterns, group=group, relative_to=relative_to)
def as_go_code(self, go_name: str, sep: str = ': ') -> Iterator[str]: def as_go_code(self, go_name: str, sep: str = ': ') -> Iterator[str]:
completers = [] completers = []
@@ -106,21 +111,23 @@ class CompletionSpec:
yield f'{go_name}{sep}{completers[0]}' yield f'{go_name}{sep}{completers[0]}'
class OptionDict(TypedDict): class OptionDefinition(NamedTuple):
dest: str dest: str = ''
name: str name: str = ''
aliases: tuple[str, ...] aliases: tuple[str, ...] = ()
help: str help: str = ''
choices: tuple[str, ...] choices: tuple[str, ...] = ()
type: str type: str = ''
default: str | None default: str | None = None
condition: bool condition: bool = False
completion: CompletionSpec completion: CompletionSpec = CompletionSpec()
OptionSpecSeq = Sequence[str | OptionDict]
OptionSpecSeq = Sequence[str | OptionDefinition]
@lru_cache(64)
def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpecSeq]: def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpecSeq]:
if spec is None: if spec is None:
spec = kitty_options_spec() spec = kitty_options_spec()
@@ -129,14 +136,10 @@ def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpe
lines = spec.splitlines() lines = spec.splitlines()
prev_line = '' prev_line = ''
prev_indent = 0 prev_indent = 0
seq: list[str | OptionDict] = [] seq: list[str | OptionDefinition] = []
disabled: list[str | OptionDict] = [] disabled: list[str | OptionDefinition] = []
mpat = re.compile('([a-z]+)=(.+)') mpat = re.compile('([a-z]+)=(.+)')
current_cmd: OptionDict = { current_cmd = empty_cmd = OptionDefinition()
'dest': '', 'aliases': (), 'help': '', 'choices': (),
'type': '', 'condition': False, 'default': None, 'completion': CompletionSpec(), 'name': ''
}
empty_cmd = current_cmd
def indent_of_line(x: str) -> int: def indent_of_line(x: str) -> int:
return len(x) - len(x.lstrip()) return len(x) - len(x.lstrip())
@@ -152,11 +155,7 @@ def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpe
if line.startswith('--'): if line.startswith('--'):
parts = line.split(' ') parts = line.split(' ')
defdest = parts[0][2:].replace('-', '_') defdest = parts[0][2:].replace('-', '_')
current_cmd = { current_cmd = OptionDefinition(dest=defdest, aliases=tuple(parts), name=defdest, condition=True)
'dest': defdest, 'aliases': tuple(parts), 'help': '',
'choices': tuple(), 'type': '', 'name': defdest,
'default': None, 'condition': True, 'completion': CompletionSpec(),
}
state = METADATA state = METADATA
continue continue
raise ValueError(f'Invalid option spec, unexpected line: {line}') raise ValueError(f'Invalid option spec, unexpected line: {line}')
@@ -164,60 +163,61 @@ def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpe
m = mpat.match(line) m = mpat.match(line)
if m is None: if m is None:
state = HELP state = HELP
current_cmd['help'] += line current_cmd = current_cmd._replace(help=current_cmd.help + line)
else: else:
k, v = m.group(1), m.group(2) k, v = m.group(1), m.group(2)
if k == 'choices': if k == 'choices':
vals = tuple(x.strip() for x in v.split(',')) vals = tuple(x.strip() for x in v.split(','))
if not current_cmd['type']: if not current_cmd.type:
current_cmd['type'] = 'choices' current_cmd = current_cmd._replace(type='choices')
if current_cmd['type'] != 'choices': if current_cmd.type != 'choices':
raise ValueError(f'Cannot specify choices for an option of type: {current_cmd["type"]}') raise ValueError(f'Cannot specify choices for an option of type: {current_cmd.type}')
current_cmd['choices'] = tuple(vals) current_cmd = current_cmd._replace(choices=tuple(vals))
if current_cmd['default'] is None: if current_cmd.default is None:
current_cmd['default'] = vals[0] current_cmd = current_cmd._replace(default=vals[0])
else: else:
if k == 'default': if k == 'default':
current_cmd['default'] = v current_cmd = current_cmd._replace(default=v)
elif k == 'type': elif k == 'type':
if v == 'choice': if v == 'choice':
v = 'choices' v = 'choices'
current_cmd['type'] = v current_cmd = current_cmd._replace(type=v)
elif k == 'dest': elif k == 'dest':
current_cmd['dest'] = v current_cmd = current_cmd._replace(dest=v)
elif k == 'condition': elif k == 'condition':
current_cmd['condition'] = bool(eval(v)) current_cmd = current_cmd._replace(condition=bool(eval(v)))
elif k == 'completion': elif k == 'completion':
current_cmd['completion'] = CompletionSpec.from_string(v) current_cmd = current_cmd._replace(completion=CompletionSpec.from_string(v))
elif state is HELP: elif state is HELP:
if line: if line:
current_indent = indent_of_line(line) current_indent = indent_of_line(line)
if current_indent > 1: if current_indent > 1:
if prev_indent == 0: if prev_indent == 0:
current_cmd['help'] += '\n' current_cmd = current_cmd._replace(help=current_cmd.help + '\n')
else: else:
line = line.strip() line = line.strip()
prev_indent = current_indent prev_indent = current_indent
spc = '' if current_cmd['help'].endswith('\n') else ' ' spc = '' if current_cmd.help.endswith('\n') else ' '
current_cmd['help'] += spc + line current_cmd = current_cmd._replace(help=current_cmd.help + spc + line)
else: else:
prev_indent = 0 prev_indent = 0
if prev_line: if prev_line:
current_cmd['help'] += '\n' if current_cmd['help'].endswith('::') else '\n\n' h = '\n' if current_cmd.help.endswith('::') else '\n\n'
current_cmd = current_cmd._replace(help=current_cmd.help + h)
else: else:
state = NORMAL state = NORMAL
(seq if current_cmd.get('condition', True) else disabled).append(current_cmd) (seq if current_cmd.condition else disabled).append(current_cmd)
current_cmd = empty_cmd current_cmd = empty_cmd
prev_line = line prev_line = line
if current_cmd is not empty_cmd: if current_cmd is not empty_cmd:
(seq if current_cmd.get('condition', True) else disabled).append(current_cmd) (seq if current_cmd.condition else disabled).append(current_cmd)
return seq, disabled return seq, disabled
def defval_for_opt(opt: OptionDict) -> Any: def defval_for_opt(opt: OptionDefinition) -> Any:
dv: Any = opt.get('default') dv: Any = opt.default
typ = opt.get('type', '') typ = opt.type
if typ.startswith('bool-'): if typ.startswith('bool-'):
if dv is None: if dv is None:
dv = False if typ == 'bool-set' else True dv = False if typ == 'bool-set' else True
@@ -230,16 +230,16 @@ def defval_for_opt(opt: OptionDict) -> Any:
return dv return dv
def get_option_maps(seq: OptionSpecSeq) -> tuple[dict[str, OptionDict], dict[str, OptionDict], dict[str, Any]]: def get_option_maps(seq: OptionSpecSeq) -> tuple[dict[str, OptionDefinition], dict[str, OptionDefinition], dict[str, Any]]:
names_map: dict[str, OptionDict] = {} names_map: dict[str, OptionDefinition] = {}
alias_map: dict[str, OptionDict] = {} alias_map: dict[str, OptionDefinition] = {}
values_map: dict[str, Any] = {} values_map: dict[str, Any] = {}
for opt in seq: for opt in seq:
if isinstance(opt, str): if isinstance(opt, str):
continue continue
for alias in opt['aliases']: for alias in opt.aliases:
alias_map[alias] = opt alias_map[alias] = opt
name = opt['dest'] name = opt.dest
names_map[name] = opt names_map[name] = opt
values_map[name] = defval_for_opt(opt) values_map[name] = defval_for_opt(opt)
return names_map, alias_map, values_map return names_map, alias_map, values_map
@@ -259,9 +259,9 @@ def add_list_values(*values: str) -> Iterator[str]:
yield f'\tflag.defval.listval.items[{n}] = {c_str(value)};' yield f'\tflag.defval.listval.items[{n}] = {c_str(value)};'
def generate_c_for_opt(name: str, defval: Any, opt: OptionDict) -> Iterator[str]: def generate_c_for_opt(name: str, defval: Any, opt: OptionDefinition) -> Iterator[str]:
yield f'\tflag = (FlagSpec){{.dest={c_str(name)},}};' yield f'\tflag = (FlagSpec){{.dest={c_str(name)},}};'
match opt['type']: match opt.type:
case 'bool-set' | 'bool-reset': case 'bool-set' | 'bool-reset':
yield '\tflag.defval.type = CLI_VALUE_BOOL;' yield '\tflag.defval.type = CLI_VALUE_BOOL;'
yield f'\tflag.defval.boolval = {"true" if defval else "false"};' yield f'\tflag.defval.boolval = {"true" if defval else "false"};'
@@ -278,7 +278,7 @@ def generate_c_for_opt(name: str, defval: Any, opt: OptionDict) -> Iterator[str]
case 'choices': case 'choices':
yield '\tflag.defval.type = CLI_VALUE_CHOICE;' yield '\tflag.defval.type = CLI_VALUE_CHOICE;'
yield f'\tflag.defval.strval = {c_str(defval)};' yield f'\tflag.defval.strval = {c_str(defval)};'
yield from add_list_values(*opt['choices']) yield from add_list_values(*opt.choices)
case _: case _:
yield '\tflag.defval.type = CLI_VALUE_STRING;' yield '\tflag.defval.type = CLI_VALUE_STRING;'
if defval is not None: if defval is not None:
@@ -289,22 +289,22 @@ def generate_c_parser_for(funcname: str, spec: str) -> Iterator[str]:
seq, disabled = parse_option_spec(spec) seq, disabled = parse_option_spec(spec)
names_map, _, defaults_map = get_option_maps(seq) names_map, _, defaults_map = get_option_maps(seq)
if 'help' not in names_map: if 'help' not in names_map:
names_map['help'] = {'type': 'bool-set', 'aliases': ('--help', '-h')} # type: ignore names_map['help'] = OptionDefinition(type='bool-set', aliases=('--help', '-h'))
defaults_map['help'] = False defaults_map['help'] = False
if 'version' not in names_map: if 'version' not in names_map:
names_map['version'] = {'type': 'bool-set', 'aliases': ('--version', '-v')} # type: ignore names_map['version'] = OptionDefinition(type='bool-set', aliases=('--version', '-v'))
defaults_map['version'] = False defaults_map['version'] = False
yield f'static void\nparse_cli_for_{funcname}(CLISpec *spec, int argc, char **argv) {{' # }} yield f'static void\nparse_cli_for_{funcname}(CLISpec *spec, int argc, char **argv) {{' # }}
yield '\tFlagSpec flag;' yield '\tFlagSpec flag;'
for name, opt in names_map.items(): for name, opt in names_map.items():
for alias in opt['aliases']: 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'\tif (vt_is_end(vt_insert(&spec->alias_map, {c_str(alias)}, {c_str(name)}))) OOM;'
yield from generate_c_for_opt(name, defaults_map[name], opt) yield from generate_c_for_opt(name, defaults_map[name], opt)
yield '\tif (vt_is_end(vt_insert(&spec->flag_map, flag.dest, flag))) OOM;' yield '\tif (vt_is_end(vt_insert(&spec->flag_map, flag.dest, flag))) OOM;'
for d in disabled: for d in disabled:
if not isinstance(d, str): if not isinstance(d, str):
yield from generate_c_for_opt(d['dest'], defval_for_opt(d), d) yield from generate_c_for_opt(d.dest, defval_for_opt(d), d)
yield '\tif (vt_is_end(vt_insert(&spec->disabled_map, flag.dest, flag))) OOM;' yield '\tif (vt_is_end(vt_insert(&spec->disabled_map, flag.dest, flag))) OOM;'
yield '\tparse_cli_loop(spec, true, argc, argv);' yield '\tparse_cli_loop(spec, true, argc, argv);'