diff --git a/gen/go_code.py b/gen/go_code.py index 1b660d02d..a1203200f 100755 --- a/gen/go_code.py +++ b/gen/go_code.py @@ -514,10 +514,13 @@ def kitten_clis() -> None: with replace_if_needed(f'kittens/{kitten}/cli_generated.go'): od = [] + ser = [] kcd = kitten_cli_docs(kitten) has_underscore = '_' in kitten print(f'package {kitten}') + print('import "fmt"') print('import "kitty/tools/cli"') + print('var _ = fmt.Sprintf') print('func create_cmd(root *cli.Command, run_func func(*cli.Command, *Options, []string)(int, error)) {') print('ans := root.AddSubCommand(&cli.Command{') print(f'Name: "{kitten}",') @@ -538,6 +541,7 @@ def kitten_clis() -> None: for opt in gopts: print(opt.as_option('ans')) od.append(opt.struct_declaration()) + ser.append(opt.as_string_for_commandline()) if ac is not None: print(''.join(ac.as_go_code('ans.ArgCompleter', ' = '))) if not kcd: @@ -550,6 +554,11 @@ def kitten_clis() -> None: print('type Options struct {') print('\n'.join(od)) print('}') + print('func (opts Options) AsCommandLine() (ans []string) {') + for x in ser: + print('\t' + x) + print('return') + print('}') # }}} diff --git a/kitty/cli.py b/kitty/cli.py index 18a115b05..76016ae04 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -115,7 +115,7 @@ def serialize_as_go_string(x: str) -> str: go_type_map = { - 'bool-set': 'bool', 'bool-reset': 'bool', 'int': 'int', 'float': 'float64', + 'bool-set': 'bool', 'bool-reset': 'bool', 'bool-unset': 'bool', 'int': 'int', 'float': 'float64', '': 'string', 'list': '[]string', 'choices': 'string', 'str': 'string'} @@ -146,9 +146,13 @@ class GoOption: def struct_declaration(self) -> str: return f'{self.go_var_name} {self.go_type}' + @property + def flags(self) -> list[str]: + return sorted(self.obj_dict['aliases']) + 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(' - aliases = ' '.join(sorted(self.obj_dict['aliases'])) + aliases = ' '.join(self.flags) ans = f'''{cmd_name}.{add}cli.OptionSpec{{ Name: "{serialize_as_go_string(aliases)}", Type: "{self.type}", @@ -168,6 +172,22 @@ class GoOption: ans += f'\nDefault: "{serialize_as_go_string(self.default)}",\n' return ans + '})' + def as_string_for_commandline(self) -> str: + flag = self.flags[0] + val = f'opts.{self.go_var_name}' + match self.go_type: + case 'bool': + val = f'fmt.Sprintf(`%#v`, {val})' + case 'int': + val = f'fmt.Sprintf(`%d`, {val})' + case 'string': + val = val + case 'float64': + val = f'fmt.Sprintf(`%f`, {val})' + case '[]string': + return f'for _, x := range {val} {{ ans = append(ans, `{flag}=` + x) }}' + return f'ans = append(ans, `{flag}=` + {val})' + @property def sorted_choices(self) -> list[str]: choices = sorted(self.obj_dict['choices']) @@ -426,6 +446,10 @@ def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpe 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'] = frozenset(vals) if current_cmd['default'] is None: current_cmd['default'] = vals[0] @@ -740,8 +764,20 @@ def defval_for_opt(opt: OptionDict) -> Any: return dv +bool_map = {'y': True, 'yes': True, 'true': True, 'n': False, 'no': False, 'false': False} + + +def to_bool(alias: str, x: str) -> bool: + try: + return bool_map[x] + except KeyError: + raise SystemExit(f'{x} is not a valid value for {alias}. Valid values are y, yes, true, n, no, false only') + + 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 @@ -757,32 +793,39 @@ class Options: self.names_map[name] = opt self.values_map[name] = defval_for_opt(opt) - def opt_for_alias(self, alias: str) -> OptionDict: + 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)}') - return opt - - def needs_arg(self, alias: str) -> bool: - if alias in ('-h', '--help'): - print_help_for_seq(self.seq, self.usage, self.message, self.appname or appname) - raise SystemExit(0) - opt = self.opt_for_alias(alias) if opt['dest'] == 'version': - print(version()) + if self.do_print: + print(version()) raise SystemExit(0) + + def is_bool(self, alias: str) -> bool: + opt = self.alias_map[alias] typ = opt.get('type', '') - return not typ.startswith('bool-') + return typ.startswith('bool-') def process_arg(self, alias: str, val: Any = None) -> None: - opt = self.opt_for_alias(alias) + opt = self.alias_map[alias] typ = opt.get('type', '') name = opt['dest'] nmap = {'float': float, 'int': int} if typ == 'bool-set': - self.values_map[name] = True + if val is None: + self.values_map[name] = True + else: + self.values_map[name] = to_bool(alias, val) elif typ == 'bool-reset': - self.values_map[name] = False + 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) @@ -809,26 +852,43 @@ def parse_cmdline(oc: Options, disabled: OptionSpecSeq, ans: Any, args: list[str dargs = deque(sys.argv[1:] if args is None else args) leftover_args: list[str] = [] current_option = None + payload: str | None = None while dargs: arg = dargs.popleft() if state is NORMAL: if arg.startswith('-'): - if arg == '--': + is_long_opt = arg.startswith('--') + if is_long_opt and arg == '--': leftover_args = list(dargs) break - parts = arg.split('=', 1) - needs_arg = oc.needs_arg(parts[0]) - if not needs_arg: - if len(parts) != 1: - raise SystemExit(f'The {emph(parts[0])} option does not accept arguments') - oc.process_arg(parts[0]) - continue - if len(parts) == 1: - current_option = parts[0] - state = EXPECTING_ARG - continue - oc.process_arg(parts[0], parts[1]) + 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 diff --git a/kitty/cli_stub.py b/kitty/cli_stub.py index 84c7bcc1e..a12ebcad5 100644 --- a/kitty/cli_stub.py +++ b/kitty/cli_stub.py @@ -6,7 +6,8 @@ from collections.abc import Sequence class CLIOptions: - pass + def __repr__(self) -> str: + return repr(vars(self)) LaunchCLIOptions = AskCLIOptions = ClipboardCLIOptions = DiffCLIOptions = CLIOptions diff --git a/kitty_tests/options.py b/kitty_tests/options.py index b717d9ac0..3756ed43e 100644 --- a/kitty_tests/options.py +++ b/kitty_tests/options.py @@ -28,6 +28,89 @@ class TestConfParsing(BaseTest): def test_conf_parsing(self): conf_parsing(self) + def test_cli_parsing(self): + cli_parsing(self) + + +def cli_parsing(self): + from kitty.cli import CLIOptions, Options, parse_cmdline, parse_option_spec + from kitty.utils import shlex_split + seq, disabled = parse_option_spec('''\ +--simple-string -s +a simple string + + +--list -l +type=list +a list + + +--choice -c +choices=a,b,c +some choices + + +--bool-set -1 +type=bool-set +set a bool + + +--bool-reset -0 +type=bool-reset +unset a bool + + +--int -i +type=int +a integer + + +--float -f +type=float +a float + + +--version -v +type=bool-set +version +''') + oc = Options(seq, usage='xxx', message='yyy', appname='test') + oc.do_print = False + + def t(args, leftover=(), fails=False, **expected): + oc.values_map['list'] = [] + args = list(shlex_split(args)) + ans = CLIOptions() + if fails: + with self.assertRaises(SystemExit): + parse_cmdline(oc, disabled, ans, args=args) + else: + actual_leftover = parse_cmdline(oc, disabled, ans, args=args) + 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}') + + 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) + t('-01=n', bool_reset=False, bool_set=False) + t('--simple-string xx moo -1', leftover=['moo', '-1'], simple_string='xx') + t('--simple-string -0 -- -1', leftover=['-1'], simple_string='-0') + t('--simple-string=-0 -- -1', leftover=['-1'], simple_string='-0') + t('--simple-string=--help -- -1', leftover=['-1'], simple_string='--help') + t('--simple-string --help -- -1', leftover=['-1'], simple_string='--help') + t('-1l=a --list=b -c b --list c', bool_set=True, choice='b', list=list('abc')) + t('-1s= -l "" --list= x', leftover=['x'], bool_set=True, simple_string='', list=['', '']) + t('--choice moo', fails=True) + t('-1c moo', fails=True) + t('-10c=moo', fails=True) + def conf_parsing(self): from kitty.config import defaults, load_config