diff --git a/kitty/cli.py b/kitty/cli.py index 5edb448f2..08697a160 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -4,17 +4,16 @@ import os import re import sys -from collections import deque from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass from enum import Enum, auto from re import Match -from typing import Any, TypeVar, Union, cast +from typing import Any, NoReturn, TypeVar, Union, 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 wcswidth +from .fast_data_types import parse_cli_from_spec, wcswidth from .options.types import Options as KittyOpts from .types import run_once from .typing import BadLineType, TypedDict @@ -101,9 +100,9 @@ class CompletionSpec: class OptionDict(TypedDict): dest: str name: str - aliases: frozenset[str] + aliases: tuple[str, ...] help: str - choices: frozenset[str] + choices: tuple[str, ...] type: str default: str | None condition: bool @@ -410,7 +409,7 @@ def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpe disabled: OptionSpecSeq = [] mpat = re.compile('([a-z]+)=(.+)') current_cmd: OptionDict = { - 'dest': '', 'aliases': frozenset(), 'help': '', 'choices': frozenset(), + 'dest': '', 'aliases': (), 'help': '', 'choices': (), 'type': '', 'condition': False, 'default': None, 'completion': CompletionSpec(), 'name': '' } empty_cmd = current_cmd @@ -430,8 +429,8 @@ def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpe parts = line.split(' ') defdest = parts[0][2:].replace('-', '_') current_cmd = { - 'dest': defdest, 'aliases': frozenset(parts), 'help': '', - 'choices': frozenset(), 'type': '', 'name': defdest, + 'dest': defdest, 'aliases': tuple(parts), 'help': '', + 'choices': tuple(), 'type': '', 'name': defdest, 'default': None, 'condition': True, 'completion': CompletionSpec(), } state = METADATA @@ -450,7 +449,7 @@ def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpe 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'] = frozenset(vals) + current_cmd['choices'] = tuple(vals) if current_cmd['default'] is None: current_cmd['default'] = vals[0] else: @@ -800,18 +799,15 @@ class Options: self.names_map[name] = opt self.values_map[name] = defval_for_opt(opt) - def check_for_standard_flag(self, alias: str) -> None: - if alias in ('-h', '--help'): - if self.do_print: - print_help_for_seq(self.seq, self.usage, self.message, self.appname or appname) - raise SystemExit(0) - opt = self.alias_map.get(alias) - if opt is None: - raise SystemExit(f'Unknown option: {emph(alias)}') - if opt['dest'] == 'version': - if self.do_print: - print(version()) - raise SystemExit(0) + def handle_help(self) -> NoReturn: + if self.do_print: + print_help_for_seq(self.seq, self.usage, self.message, self.appname or appname) + raise SystemExit(0) + + def handle_version(self) -> NoReturn: + if self.do_print: + print(version()) + raise SystemExit(0) def is_bool(self, alias: str) -> bool: opt = self.alias_map[alias] @@ -854,58 +850,16 @@ class Options: def parse_cmdline(oc: Options, disabled: OptionSpecSeq, ans: Any, args: list[str] | None = None) -> list[str]: - NORMAL, EXPECTING_ARG = 'NORMAL', 'EXPECTING_ARG' - state = NORMAL - dargs = deque(sys.argv[1:] if args is None else args) - leftover_args: list[str] = [] - current_option = None - payload: str | None = None + try: + vals, leftover_args = parse_cli_from_spec(sys.argv[1:] if args is None else args, oc.names_map, oc.values_map) + except Exception as e: + raise SystemExit(str(e)) - while dargs: - arg = dargs.popleft() - if state is NORMAL: - if arg.startswith('-'): - is_long_opt = arg.startswith('--') - if is_long_opt and arg == '--': - leftover_args = list(dargs) - break - flag, has_equal, payload = arg.partition('=') - if not has_equal: - payload = None - if is_long_opt: - oc.check_for_standard_flag(flag) - if oc.is_bool(flag): - oc.process_arg(flag, payload) - continue - if not has_equal: - current_option = flag - state = EXPECTING_ARG - continue - oc.process_arg(flag, payload) - else: - letters = flag[1:] - for letter in letters[:-1]: - flag = f'-{letter}' - oc.check_for_standard_flag(flag) - oc.process_arg(flag) - flag = f'-{letters[-1]}' - oc.check_for_standard_flag(flag) - if oc.is_bool(flag) or payload is not None: - oc.process_arg(flag, payload) - else: - current_option = flag - state = EXPECTING_ARG - continue - else: - leftover_args = [arg] + list(dargs) - break - elif current_option is not None: - oc.process_arg(current_option, arg) - current_option, state = None, NORMAL - if state is EXPECTING_ARG: - raise SystemExit(f'An argument is required for the option: {emph(arg)}') - - for key, val in oc.values_map.items(): + 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: + oc.handle_help() setattr(ans, key, val) for opt in disabled: if not isinstance(opt, str): diff --git a/kitty/data-types.c b/kitty/data-types.c index 16cc46d20..637eebe82 100644 --- a/kitty/data-types.c +++ b/kitty/data-types.c @@ -678,10 +678,12 @@ py_get_config_dir(PyObject *self UNUSED, PyObject *args UNUSED) { return PyUnicode_FromString(""); } +#include "launcher/cli-parser.h" static PyMethodDef module_methods[] = { METHODB(replace_c0_codes_except_nl_space_tab, METH_O), METHODB(read_file, METH_O), + {"parse_cli_from_spec", parse_cli_from_python_spec, METH_VARARGS, ""}, {"wcwidth", (PyCFunction)wcwidth_wrap, METH_O, ""}, {"expanduser", (PyCFunction)expanduser, METH_O, ""}, {"abspath", (PyCFunction)abspath, METH_O, ""}, diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index c19725cde..95edcfbef 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -2,6 +2,7 @@ import termios from typing import Any, Callable, Dict, Iterator, List, Literal, NewType, Optional, Tuple, TypedDict, Union, overload from kitty.boss import Boss +from kitty.cli import OptionDict from kitty.fonts import VariableData from kitty.fonts.render import FontObject from kitty.marks import MarkerFunc @@ -1704,6 +1705,7 @@ def get_clipboard_mime(ct: int, mime: Optional[str], callback: Callable[[bytes], def run_with_activation_token(func: Callable[[str], None]) -> bool: ... def make_x11_window_a_dock_window(x11_window_id: int, strut: Tuple[int, int, int, int, int, int, int, int, int, int, int, int]) -> None: ... def toggle_os_window_visibility(os_window_id: int, visible: Literal[True, 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 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 wrapped_kitten_names() -> List[str]: ... diff --git a/kitty/launcher/cli-parser.h b/kitty/launcher/cli-parser.h index 37e255ec6..09099893c 100644 --- a/kitty/launcher/cli-parser.h +++ b/kitty/launcher/cli-parser.h @@ -7,12 +7,29 @@ #pragma once +#include "listobject.h" +#include #include #include #include #include #include +#ifndef RAII_ALLOC +static inline void cleanup_decref2(PyObject **p) { Py_CLEAR(*p); } +#define RAII_PyObject(name, initializer) __attribute__((cleanup(cleanup_decref2))) PyObject *name = initializer + +static inline void cleanup_free(void *p) { free(*(void**)p); } +#define RAII_ALLOC(type, name, initializer) __attribute__((cleanup(cleanup_free))) type *name = initializer +#endif + +static inline void +cleanup_argv(void *p) { + for (char **argv = *(void **)p; *argv; argv++) free(*argv); + free(*(void**)p); +} +#define RAII_ARGV(name, argc) __attribute__((cleanup(cleanup_argv))) char** name = calloc(argc + 1, sizeof(char*)) + typedef enum CLIValueType { CLI_VALUE_STRING, CLI_VALUE_BOOL, CLI_VALUE_INT, CLI_VALUE_FLOAT, CLI_VALUE_LIST, CLI_VALUE_CHOICE } CLIValueType; typedef struct CLIValue { CLIValueType type; @@ -23,6 +40,7 @@ typedef struct CLIValue { struct { const char* * items; size_t count, capacity; + bool needs_free; } listval; } CLIValue; @@ -40,7 +58,7 @@ typedef struct CLIValue { typedef struct FlagSpec { CLIValue defval; - const char *dest, *choices, *aliases; + const char *dest; } FlagSpec; #define NAME flag_hash @@ -55,7 +73,7 @@ typedef struct CLISpec { alias_hash alias_map; flag_hash flag_map; char **argv; int argc; // leftover args - char err[512]; + char err[1024]; } CLISpec; static void @@ -66,16 +84,21 @@ out_of_memory(int line) { #define OOM out_of_memory(__LINE__) -static bool -report_unknown_alias(CLISpec *spec, const char *alias) { - snprintf(spec->err, sizeof(spec->err), "Unknown flag: %s use --help", alias); - return false; +static const char* +dest_for_alias(CLISpec *spec, const char *alias) { + alias_hash_itr itr = vt_get(&spec->alias_map, alias); + if (vt_is_end(itr)) { + snprintf(spec->err, sizeof(spec->err), "Unknown flag: %s use --help", alias); + return NULL; + } + return itr.data->val; } static bool is_alias_bool(CLISpec* spec, const char *alias) { - flag_hash_itr itr = vt_get(&spec->flag_map, alias); - if (!itr.data) return report_unknown_alias(spec, alias); + const char *dest = dest_for_alias(spec, alias); + if (!dest) return false; + flag_hash_itr itr = vt_get(&spec->flag_map, dest); return itr.data->val->defval.type == CLI_VALUE_BOOL; } @@ -97,8 +120,9 @@ add_list_value(CLISpec *spec, const char *dest, const char *val) { static bool process_cli_arg(CLISpec* spec, const char *alias, const char *payload) { - flag_hash_itr itr = vt_get(&spec->flag_map, alias); - if (!itr.data) return report_unknown_alias(spec, alias); + const char *dest = dest_for_alias(spec, alias); + if (!dest) return false; + flag_hash_itr itr = vt_get(&spec->flag_map, dest); const FlagSpec *flag = itr.data->val; CLIValue val = {.type=flag->defval.type}; #define streq(q) (strcmp(payload, #q) == 0) @@ -117,11 +141,14 @@ process_cli_arg(CLISpec* spec, const char *alias, const char *payload) { break; case CLI_VALUE_CHOICE: val.strval = NULL; - for (const char* q = flag->choices; q; q++) if (streq(q)) { val.strval = payload; break; } + for (size_t c = 0; c < flag->defval.listval.count; c++) { + if (strcmp(payload, flag->defval.listval.items[c]) == 0) { val.strval = payload; break; } + } if (!val.strval) { int n = snprintf(spec->err, sizeof(spec->err), "%s is an invalid value for %s. Valid values are:", payload[0] ? payload : "", alias); - for (const char* q = flag->choices; q; q++) n += snprintf(spec->err + n, sizeof(spec->err) - n, " %s,", q); + for (size_t c = 0; c < flag->defval.listval.count; c++) + n += snprintf(spec->err + n, sizeof(spec->err) - n, " %s,", flag->defval.listval.items[c]); spec->err[n-1] = '.'; return false; } @@ -146,37 +173,37 @@ process_cli_arg(CLISpec* spec, const char *alias, const char *payload) { } static void -alloc_cli_spec(CLISpec *spec, const FlagSpec *flags) { +alloc_cli_spec(CLISpec *spec) { vt_init(&spec->value_map); vt_init(&spec->alias_map); vt_init(&spec->flag_map); - for (const FlagSpec *flag = flags; flag != NULL; flag++) { - if (vt_is_end(vt_insert(&spec->flag_map, flag->dest, flag))) OOM; - for (const char *alias = flag->aliases; alias != NULL; alias++) { - if (vt_is_end(vt_insert(&spec->alias_map, alias, flag->dest))) OOM; - } - } } static void dealloc_cli_value(CLIValue v) { - free((void*)v.listval.items); + if (v.listval.needs_free) free((void*)v.listval.items); } static void -dealloc_cli_spec(CLISpec *spec) { +dealloc_cli_spec(void *v) { + CLISpec *spec = v; value_map_for_loop(&spec->value_map) { dealloc_cli_value(itr.data->val); } + flag_map_for_loop(&spec->flag_map) { + dealloc_cli_value(itr.data->val->defval); + } vt_cleanup(&spec->value_map); vt_cleanup(&spec->alias_map); vt_cleanup(&spec->flag_map); } +#define RAII_CLISpec(name) __attribute__((cleanup(dealloc_cli_spec))) CLISpec name = {0}; alloc_cli_spec(&name) + static bool parse_cli_loop(CLISpec *spec, int argc, char **argv) { // argv must contain arg1 and beyond enum { NORMAL, EXPECTING_ARG } state = NORMAL; - spec->argc = 0; spec->argv = NULL; + spec->argc = 0; spec->argv = NULL; spec->err[0] = 0; char flag[3] = {'-', 0, 0}; const char *current_option = NULL; for (int i = 0; i < argc; i++) { @@ -239,3 +266,105 @@ parse_cli_loop(CLISpec *spec, int argc, char **argv) { // argv must contain arg if (state == EXPECTING_ARG) snprintf(spec->err, sizeof(spec->err), "The %s flag must be followed by an argument.", current_option ? current_option : ""); return spec->err[0] == 0; } + +static PyObject* +cli_parse_result_as_python(CLISpec *spec) { + if (PyErr_Occurred()) return NULL; + if (spec->err[0]) { + PyErr_SetString(PyExc_ValueError, spec->err); return NULL; + } + RAII_PyObject(ans, PyDict_New()); if (!ans) return NULL; + flag_map_for_loop(&spec->flag_map) { + const FlagSpec *flag = itr.data->val; + cli_hash_itr i = vt_get(&spec->value_map, flag->dest); + PyObject *is_seen = vt_is_end(i) ? Py_False : Py_True; + const CLIValue *v = is_seen == Py_True ? &i.data->val : &flag->defval; +#define S(fv) { RAII_PyObject(temp, Py_BuildValue("NO", fv, is_seen)); if (!temp) return NULL; \ + if (PyDict_SetItemString(ans, flag->dest, temp) != 0) return NULL;} + switch (v->type) { + case CLI_VALUE_BOOL: S(PyBool_FromLong((long)v->boolval)); break; + case CLI_VALUE_STRING: if (v->strval) { S(PyUnicode_FromString(v->strval)); } else { S(Py_NewRef(Py_None)); } break; + case CLI_VALUE_CHOICE: S(PyUnicode_FromString(v->strval)); break; + case CLI_VALUE_INT: S(PyLong_FromLongLong(v->intval)); break; + case CLI_VALUE_FLOAT: S(PyFloat_FromDouble(v->floatval)); break; + case CLI_VALUE_LIST: { + RAII_PyObject(l, PyList_New(v->listval.count)); if (!l) return NULL; + for (size_t i = 0; i < v->listval.count; i++) { + PyObject *x = PyUnicode_FromString(v->listval.items[i]); if (!x) return NULL; + PyList_SET_ITEM(l, i, x); + } + S(Py_NewRef(l)); + } break; + } + } +#undef S + RAII_PyObject(leftover_args, PyList_New(spec->argc)); if (!leftover_args) return NULL; + for (int i = 0; i < spec->argc; i++) { + PyObject *t = PyUnicode_FromString(spec->argv[i]); + if (!t) return NULL; + PyList_SET_ITEM(leftover_args, i, t); + } + return Py_BuildValue("OO", ans, leftover_args); +} + +static PyObject* +parse_cli_from_python_spec(PyObject *self, PyObject *args) { + (void)self; PyObject *pyargs, *names_map, *defval_map; + if (!PyArg_ParseTuple(args, "O!O!O!", &PyList_Type, &pyargs, &PyDict_Type, &names_map, &PyDict_Type, &defval_map)) return NULL; + int argc = PyList_GET_SIZE(pyargs); + RAII_ARGV(argv, argc); if (!argv) return PyErr_NoMemory(); + for (int i = 0; i < argc; i++) { + argv[i] = strdup(PyUnicode_AsUTF8(PyList_GET_ITEM(pyargs, i))); + if (!argv[i]) return PyErr_NoMemory(); + } + RAII_ALLOC(FlagSpec, flags, calloc(PyDict_GET_SIZE(names_map), sizeof(FlagSpec))); if (!flags) return PyErr_NoMemory(); + RAII_CLISpec(spec); + PyObject *key = NULL, *opt = NULL; + Py_ssize_t pos = 0, flag_num = 0; + while (PyDict_Next(names_map, &pos, &key, &opt)) { + FlagSpec *flag = &flags[flag_num++]; + flag->dest = PyUnicode_AsUTF8(key); + PyObject *pytype = PyDict_GetItemString(opt, "type"); + const char *type = pytype ? PyUnicode_AsUTF8(pytype) : ""; + PyObject *defval = PyDict_GetItemWithError(defval_map, key); if (!defval && PyErr_Occurred()) return NULL; + PyObject *pyaliases = PyDict_GetItemString(opt, "aliases"); + for (int a = 0; a < PyTuple_GET_SIZE(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 (strstr(type, "bool-") == type) { + flag->defval.type = CLI_VALUE_BOOL; + flag->defval.boolval = PyObject_IsTrue(defval); + } else if (strcmp(type, "int") == 0) { + flag->defval.type = CLI_VALUE_INT; + flag->defval.intval = PyLong_AsLongLong(defval); + } else if (strcmp(type, "float") == 0) { + flag->defval.type = CLI_VALUE_FLOAT; + flag->defval.floatval = PyFloat_AsDouble(defval); + } else if (strcmp(type, "list") == 0) { + flag->defval.type = CLI_VALUE_LIST; + } else if (strcmp(type, "choices") == 0) { + flag->defval.type = CLI_VALUE_CHOICE; + flag->defval.strval = PyUnicode_AsUTF8(defval); + PyObject *pyc = PyDict_GetItemString(opt, "choices"); + flag->defval.listval.items = malloc(PyTuple_GET_SIZE(pyc) * sizeof(char*)); + if (!flag->defval.listval.items) return PyErr_NoMemory(); + flag->defval.listval.count = PyTuple_GET_SIZE(pyc); + flag->defval.listval.needs_free = true; + flag->defval.listval.capacity = PyTuple_GET_SIZE(pyc); + for (size_t n = 0; n < flag->defval.listval.count; n++) { + flag->defval.listval.items[n] = PyUnicode_AsUTF8(PyTuple_GET_ITEM(pyc, n)); + if (!flag->defval.listval.items[n]) return NULL; + } + } else { + flag->defval.type = CLI_VALUE_STRING; + flag->defval.strval = PyUnicode_Check(defval) ? PyUnicode_AsUTF8(defval) : NULL; + } + if (vt_is_end(vt_insert(&spec.flag_map, flag->dest, flag))) return PyErr_NoMemory(); + } + if (PyErr_Occurred()) return NULL; + parse_cli_loop(&spec, argc, argv); + PyObject *ans = cli_parse_result_as_python(&spec); + return ans; +} + diff --git a/kitty_tests/options.py b/kitty_tests/options.py index 3756ed43e..3df0b8cb4 100644 --- a/kitty_tests/options.py +++ b/kitty_tests/options.py @@ -78,7 +78,6 @@ version oc.do_print = False def t(args, leftover=(), fails=False, **expected): - oc.values_map['list'] = [] args = list(shlex_split(args)) ans = CLIOptions() if fails: @@ -89,13 +88,8 @@ version self.assertEqual(tuple(leftover), tuple(actual_leftover), f'{args}\n{ans}') for dest, defval in oc.values_map.items(): val = expected.get(dest, defval) - self.assertEqual(val, getattr(ans, dest), f'Failed to parse {dest} correctly for: {args} \n{ans}') + self.assertEqual(val, getattr(ans, dest, BaseTest), f'Failed to parse {dest} correctly for: {args} \n{ans}') - t('-1 -h', fails=True) - t('-1 --help', fails=True) - t('-1 -0v', fails=True) - t('-1 -v0', fails=True) - t('-1 --version', fails=True) t('-1', bool_set=True) t('-01', bool_reset=False, bool_set=True) t('-01=y', bool_reset=False, bool_set=True) @@ -110,6 +104,11 @@ version t('--choice moo', fails=True) t('-1c moo', fails=True) t('-10c=moo', fails=True) + t('-1 -h', fails=True) + t('-1 --help', fails=True) + t('-1 -0v', fails=True) + t('-1 -v0', fails=True) + t('-1 --version', fails=True) def conf_parsing(self):