From 597a6fead268df0b56d99635b190ffac4cb9b9a1 Mon Sep 17 00:00:00 2001 From: Tim Basel Date: Thu, 21 Nov 2019 13:03:12 +0100 Subject: [PATCH] vlib: cli module --- examples/.gitignore | 1 + examples/cli.v | 50 +++++++++++ vlib/cli/command.v | 190 ++++++++++++++++++++++++++++++++++++++++ vlib/cli/command_test.v | 187 +++++++++++++++++++++++++++++++++++++++ vlib/cli/flag.v | 110 +++++++++++++++++++++++ vlib/cli/flag_test.v | 56 ++++++++++++ vlib/cli/help.v | 73 +++++++++++++++ vlib/cli/version.v | 24 +++++ 8 files changed, 691 insertions(+) create mode 100644 examples/cli.v create mode 100644 vlib/cli/command.v create mode 100644 vlib/cli/command_test.v create mode 100644 vlib/cli/flag.v create mode 100644 vlib/cli/flag_test.v create mode 100644 vlib/cli/help.v create mode 100644 vlib/cli/version.v diff --git a/examples/.gitignore b/examples/.gitignore index 0b6f509d10..45c94880f0 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -1,3 +1,4 @@ +/cli /hello_world /json /links_scraper diff --git a/examples/cli.v b/examples/cli.v new file mode 100644 index 0000000000..79269cd643 --- /dev/null +++ b/examples/cli.v @@ -0,0 +1,50 @@ +module main + +import ( + cli + os +) + +fn main() { + mut cmd := cli.Command{ + name: 'cli', + description: 'An example of the cli library', + version: '1.0.0', + } + + mut greet_cmd := cli.Command{ + name: 'greet', + description: 'Prints greeting in different languages', + execute: greet_func, + } + greet_cmd.add_flag(cli.Flag{ + flag: .string, + required: true, + name: 'language', + abbrev: 'l', + description: 'Language of the message' + }) + greet_cmd.add_flag(cli.Flag{ + flag: .int, + name: 'times', + value: '3', + description: 'Number of times the message gets printed' + }) + + cmd.add_command(greet_cmd) + cmd.parse(os.args) +} + +fn greet_func(cmd cli.Command) { + language := cmd.flags.get_string('language') or { panic('failed to get \'language\' flag: $err') } + times := cmd.flags.get_int('times') or { panic('failed to get \'times\' flag: $err') } + + for i := 0; i < times; i++ { + match language { + 'english' { println('Hello World') } + 'german' { println('Hallo Welt') } + 'dutch' { println('Hallo Wereld') } + else { println('unsupported language') } + } + } +} diff --git a/vlib/cli/command.v b/vlib/cli/command.v new file mode 100644 index 0000000000..d91d6a7e25 --- /dev/null +++ b/vlib/cli/command.v @@ -0,0 +1,190 @@ +module cli + +pub struct Command { +pub mut: + name string + description string + version string + execute fn(cmd Command) + + disable_help bool + disable_version bool + + parent &Command + commands []Command + flags []Flag + args []string +} + +pub fn (cmd Command) full_name() string { + if isnil(cmd.parent) { + return cmd.name + } + return cmd.parent.full_name() + ' ${cmd.name}' +} + +pub fn (cmd Command) root() Command { + if isnil(cmd.parent) { + return cmd + } + return cmd.parent.root() +} + +pub fn (cmd mut Command) add_command(command Command) { + cmd.commands << command +} + +pub fn (cmd mut Command) add_flag(flag Flag) { + cmd.flags << flag +} + +pub fn (cmd mut Command) parse(args []string) { + cmd.add_default_flags() + cmd.add_default_commands() + + cmd.args = args.right(1) + for i := 0; i < cmd.commands.len; i++ { + cmd.commands[i].parent = cmd + } + + cmd.parse_flags() + cmd.parse_commands() +} + +fn (cmd mut Command) add_default_flags() { + if !cmd.disable_help && !cmd.flags.contains('help') && !cmd.flags.contains('h') { + cmd.add_flag(help_flag()) + } + if !cmd.disable_version && cmd.version != '' && !cmd.flags.contains('version') && !cmd.flags.contains('v') { + cmd.add_flag(version_flag()) + } +} + +fn (cmd mut Command) add_default_commands() { + if !cmd.disable_help && !cmd.commands.contains('help') { + cmd.add_command(help_cmd()) + } + if !cmd.disable_version && cmd.version != '' && !cmd.commands.contains('version') { + cmd.add_command(version_cmd()) + } +} + +fn (cmd mut Command) parse_flags() { + for { + if cmd.args.len < 1 || !cmd.args[0].starts_with('-') { + break + } + mut found := false + for i := 0; i < cmd.flags.len; i++ { + mut flag := &cmd.flags[i] + if flag.matches(cmd.args) { + found = true + mut args := flag.parse(cmd.args) or { // TODO: fix once options types can be assigned to struct variables + println('failed to parse flag ${cmd.args[0]}: ${err}') + exit(1) + } + cmd.args = args + break + } + } + + if !found { + println('invalid flag: ${cmd.args[0]}') + exit(1) + } + } +} + +fn (cmd mut Command) parse_commands() { + flags := cmd.flags + global_flags := flags.filter(it.global) // TODO: fix once filter can be applied to struct variable + + cmd.check_help_flag() + cmd.check_version_flag() + + for i := 0; i < cmd.args.len; i++ { + arg := cmd.args[i] + for j := 0; j < cmd.commands.len; j++ { + mut command := cmd.commands[j] + if command.name == arg { + for flag in global_flags { + command.add_flag(flag) + } + command.parse(cmd.args.right(i)) + return + } + } + } + + // if no further command was found execute current command + if int(cmd.execute) == 0 { + if !cmd.disable_help { + help_cmd := cmd.commands.get('help') or { return } // ignore error and handle command normally + execute := help_cmd.execute + execute(help_cmd) + } + } else { + cmd.check_required_flags() + + execute := cmd.execute + execute(cmd) // TODO: fix once higher order function can be execute on struct variable + } +} + +fn (cmd mut Command) check_help_flag() { + if cmd.disable_help { + return + } + if cmd.flags.contains('help') { + help_flag := cmd.flags.get_bool('help') or { return } // ignore error and handle command normally + if help_flag { + help_cmd := cmd.commands.get('help') or { return } // ignore error and handle command normally + execute := help_cmd.execute + execute(help_cmd) + exit(0) + } + } +} + +fn (cmd mut Command) check_version_flag() { + if cmd.disable_version { + return + } + if cmd.version != '' && cmd.flags.contains('version') { + version_flag := cmd.flags.get_bool('version') or { return } // ignore error and handle command normally + if version_flag { + version_cmd := cmd.commands.get('version') or { return } // ignore error and handle command normally + execute := version_cmd.execute + execute(version_cmd) + exit(0) + } + } +} + +fn (cmd mut Command) check_required_flags() { + for flag in cmd.flags { + if flag.required && flag.value == '' { + full_name := cmd.full_name() + println('flag \'${flag.name}\' is required by \'${full_name}\'') + exit(1) + } + } +} + +fn (cmds []Command) contains(name string) bool { + for cmd in cmds { + if cmd.name == name { + return true + } + } + return false +} + +fn (cmds []Command) get(name string) ?Command { + for cmd in cmds { + if cmd.name == name { + return cmd + } + } + return error('command \'${name}\' not found.') +} diff --git a/vlib/cli/command_test.v b/vlib/cli/command_test.v new file mode 100644 index 0000000000..66c4895a68 --- /dev/null +++ b/vlib/cli/command_test.v @@ -0,0 +1,187 @@ +import cli + +fn test_if_command_parses_empty_args() { + mut cmd := cli.Command{ + name: 'command', + execute: empty_func, + } + cmd.parse(['command']) + assert cmd.name == 'command' + && compare_arrays(cmd.args, []) +} + +fn test_if_command_parses_args() { + mut cmd := cli.Command{ + name: 'command', + execute: empty_func, + } + cmd.parse(['command', 'arg0', 'arg1']) + + assert cmd.name == 'command' + && compare_arrays(cmd.args, ['arg0', 'arg1']) +} + +fn test_if_subcommands_parse_args() { + mut cmd := cli.Command{ + name: 'command', + } + subcmd := cli.Command{ + name: 'subcommand', + execute: empty_func, + } + cmd.add_command(subcmd) + cmd.parse(['command', 'subcommand', 'arg0', 'arg1']) +} + +fn if_subcommands_parse_args_func(cmd cli.Command) { + assert cmd.name == 'subcommand' + && compare_arrays(cmd.args, ['arg0', 'arg1']) +} + +fn test_if_command_has_default_help_subcommand() { + mut cmd := cli.Command{ + name: 'command', + } + cmd.parse(['command']) + + assert has_command(cmd, 'help') +} + +fn test_if_command_has_default_version_subcommand_if_version_is_set() { + mut cmd := cli.Command{ + name: 'command', + version: '1.0.0', + } + cmd.parse(['command']) + + assert has_command(cmd, 'version') +} + +fn test_if_flag_gets_set() { + mut cmd := cli.Command{ + name: 'command', + execute: if_flag_gets_set_func, + } + cmd.add_flag(cli.Flag{ + flag: .string + name: 'flag' + }) + cmd.parse(['command', '--flag', 'value']) +} + +fn if_flag_gets_set_func(cmd cli.Command) { + flag := cmd.flags.get_string('flag') or { panic(err) } + assert flag == 'value' +} + +fn test_if_flag_gets_set_with_abbrev() { + mut cmd := cli.Command{ + name: 'command', + execute: if_flag_gets_set_with_abbrev_func, + } + cmd.add_flag(cli.Flag{ + flag: .string, + name: 'flag', + abbrev: 'f', + }) + cmd.parse(['command', '-f', 'value']) +} + +fn if_flag_gets_set_with_abbrev_func(cmd cli.Command) { + flag := cmd.flags.get_string('flag') or { panic(err) } + assert flag == 'value' +} + +fn test_if_multiple_flags_get_set() { + mut cmd := cli.Command{ + name: 'command', + execute: if_multiple_flags_get_set_func, + } + cmd.add_flag(cli.Flag{ + flag: .string + name: 'flag' + }) + cmd.add_flag(cli.Flag{ + flag: .int + name: 'value' + }) + cmd.parse(['command', '--flag', 'value', '--value', '42']) +} + +fn if_multiple_flags_get_set_func(cmd cli.Command) { + flag := cmd.flags.get_string('flag') or { panic(err) } + value := cmd.flags.get_int('value') or { panic(err) } + assert flag == 'value' + && value == 42 +} + +fn test_if_flag_gets_set_in_subcommand() { + mut cmd := cli.Command{ + name: 'command', + execute: empty_func, + } + mut subcmd := cli.Command{ + name: 'subcommand', + execute: if_flag_gets_set_in_subcommand_func + } + subcmd.add_flag(cli.Flag{ + flag: .string + name: 'flag' + }) + cmd.add_command(subcmd) + cmd.parse(['command', 'subcommand', '--flag', 'value']) +} + +fn if_flag_gets_set_in_subcommand_func(cmd cli.Command) { + flag := cmd.flags.get_string('flag') or { panic(err) } + assert flag == 'value' +} + +fn test_if_global_flag_gets_set_in_subcommand() { + mut cmd := cli.Command{ + name: 'command', + execute: empty_func, + } + cmd.add_flag(cli.Flag{ + flag: .string, + name: 'flag', + global: true, + }) + subcmd := cli.Command{ + name: 'subcommand', + execute: if_global_flag_gets_set_in_subcommand_func, + } + cmd.add_command(subcmd) + cmd.parse(['command', '--flag', 'value', 'subcommand']) +} + +fn if_global_flag_gets_set_in_subcommand_func(cmd cli.Command) { + flag := cmd.flags.get_string('flag') or { panic(err) } + assert flag == 'value' +} + + +// helper functions + +fn empty_func(cmd cli.Command) {} + +fn has_command(cmd cli.Command, name string) bool { + for subcmd in cmd.commands { + if subcmd.name == name { + return true + } + } + return false +} + +fn compare_arrays(array0 []string, array1 []string) bool { + if array0.len != array1.len { + return false + } + for i := 0; i < array0.len; i++ { + if array0[i] != array1[i] { + return false + } + } + return true +} diff --git a/vlib/cli/flag.v b/vlib/cli/flag.v new file mode 100644 index 0000000000..2e5c93bacb --- /dev/null +++ b/vlib/cli/flag.v @@ -0,0 +1,110 @@ +module cli + +pub enum FlagType { + bool + int + float + string +} + +pub struct Flag { +pub mut: + flag FlagType + name string + abbrev string + description string + global bool + required bool + + value string +} + +pub fn (flags []Flag) get_bool(name string) ?bool { + flag := flags.get(name) or { return error(err) } + if flag.flag != .bool { return error('invalid flag type') } + return flag.value == 'true' +} + +pub fn (flags []Flag) get_int(name string) ?int { + flag := flags.get(name) or { return error(err) } + if flag.flag != .int { return error('invalid flag type') } + return flag.value.int() +} + +pub fn (flags []Flag) get_float(name string) ?f32 { + flag := flags.get(name) or { return error(err) } + if flag.flag != .float { return error('invalid flag type') } + return flag.value.f32() +} + +pub fn (flags []Flag) get_string(name string) ?string { + flag := flags.get(name) or { return error(err) } + if flag.flag != .string { return error('invalid flag type') } + return flag.value +} + +// parse flag value from arguments and return arguments with all consumed element removed +fn (flag mut Flag) parse(args []string) ?[]string { + if flag.matches(args) { + if flag.flag == .bool { + new_args := flag.parse_bool(args) or { return error(err) } + return new_args + } else { + new_args := flag.parse_raw(args) or { return error(err) } + return new_args + } + } else { + return args + } +} + +// check if first arg matches flag +fn (flag mut Flag) matches(args []string) bool { + return + (flag.name != '' && args[0].starts_with('--${flag.name}')) || + (flag.abbrev != '' && args[0].starts_with('-${flag.abbrev}')) +} + +fn (flag mut Flag) parse_raw(args []string) ?[]string { + if args[0].len > flag.name.len && args[0].contains('=') { + println('1') + flag.value = args[0].split('=')[1] + return args.right(1) + } else if args.len >= 2 { + flag.value = args[1] + return args.right(2) + } + return error('missing argument for ${flag.name}') +} + +fn (flag mut Flag) parse_bool(args []string) ?[]string { + if args[0].len > flag.name.len && args[0].contains('=') { + flag.value = args[0].split('=')[1] + return args.right(1) + } else if args.len >= 2 { + if args[1] in ['true', 'false'] { + flag.value = args[1] + return args.right(2) + } + } + flag.value = 'true' + return args.right(1) +} + +fn (flags []Flag) get(name string) ?Flag { + for flag in flags { + if flag.name == name { + return flag + } + } + return error('flag ${name} not found.') +} + +fn (flags []Flag) contains(name string) bool { + for flag in flags { + if flag.name == name || flag.abbrev == name { + return true + } + } + return false +} diff --git a/vlib/cli/flag_test.v b/vlib/cli/flag_test.v new file mode 100644 index 0000000000..c5278a3029 --- /dev/null +++ b/vlib/cli/flag_test.v @@ -0,0 +1,56 @@ +import cli + +fn test_if_string_flag_parses() { + mut flag := cli.Flag{ + flag: .string, + name: 'flag', + } + + flag.parse(['--flag', 'value']) or { panic(err) } + assert flag.value == 'value' + + flag.parse(['--flag=value']) or { panic(err) } + assert flag.value == 'value' +} + +fn test_if_bool_flag_parses() { + mut flag := cli.Flag{ + flag: .bool, + name: 'flag', + } + + flag.parse(['--flag']) or { panic(err) } + assert flag.value == 'true' + + flag.parse(['--flag', 'true']) or { panic(err) } + assert flag.value == 'true' + + flag.parse(['--flag=true']) or { panic(err) } + assert flag.value == 'true' +} + +fn test_if_int_flag_parses() { + mut flag := cli.Flag{ + flag: .int, + name: 'flag', + } + + flag.parse(['--flag', '42']) or { panic(err) } + assert flag.value.int() == 42 + + flag.parse(['--flag=42']) or { panic(err) } + assert flag.value.int() == 42 +} + +fn test_if_float_flag_parses() { + mut flag := cli.Flag{ + flag: .float, + name: 'flag', + } + + flag.parse(['--flag', '3.14159']) or { panic(err) } + assert flag.value.f32() == 3.14159 + + flag.parse(['--flag=3.14159']) or { panic(err) } + assert flag.value.f32() == 3.14159 +} diff --git a/vlib/cli/help.v b/vlib/cli/help.v new file mode 100644 index 0000000000..e597c7f661 --- /dev/null +++ b/vlib/cli/help.v @@ -0,0 +1,73 @@ +module cli + +const ( + BASE_INDENT = 2 + ABBREV_INDENT = 5 + DESCRIPTION_INDENT = 20 +) + +fn help_flag() Flag { + return Flag{ + flag: .bool, + name: 'help', + abbrev: 'h', + description: 'Prints help information', + } +} + +fn help_cmd() Command { + return Command{ + name: 'help', + description: 'Prints help information', + execute: help_func, + } +} + +fn help_func(help_cmd cli.Command) { + cmd := help_cmd.parent + full_name := cmd.full_name() + + mut help := '' + help += 'Usage: ${full_name}' + if cmd.flags.len > 0 { help += ' [FLAGS]'} + if cmd.commands.len > 0 { help += ' [COMMANDS]'} + help += '\n\n' + + if cmd.description != '' { + help += '${cmd.description}\n\n' + } + if cmd.flags.len > 0 { + help += 'Flags:\n' + for flag in cmd.flags { + mut flag_name := '' + if flag.abbrev != '' { + abbrev_indent := ' '.repeat(ABBREV_INDENT-(flag.abbrev.len+1)) + flag_name = '-${flag.abbrev}${abbrev_indent}--${flag.name}' + } else { + abbrev_indent := ' '.repeat(ABBREV_INDENT-(flag.abbrev.len)) + flag_name = '${abbrev_indent}--${flag.name}' + } + mut required := '' + if flag.required { + required = ' (required)' + } + + base_indent := ' '.repeat(BASE_INDENT) + description_indent := ' '.repeat(DESCRIPTION_INDENT-flag_name.len) + help += '${base_indent}${flag_name}${description_indent}${flag.description}${required}\n' + } + help += '\n' + } + if cmd.commands.len > 0 { + help += 'Commands:\n' + for command in cmd.commands { + base_indent := ' '.repeat(BASE_INDENT) + description_indent := ' '.repeat(DESCRIPTION_INDENT-command.name.len) + + help += '${base_indent}${command.name}${description_indent}${command.description}\n' + } + help += '\n' + } + + print(help) +} diff --git a/vlib/cli/version.v b/vlib/cli/version.v new file mode 100644 index 0000000000..f2df16427b --- /dev/null +++ b/vlib/cli/version.v @@ -0,0 +1,24 @@ +module cli + +fn version_flag() Flag { + return Flag{ + flag: .bool, + name: 'version', + abbrev: 'v', + description: 'Prints version information', + } +} + +fn version_cmd() Command { + return Command{ + name: 'version' + description: 'Prints version information', + execute: version_func, + } +} + +fn version_func(version_cmd cli.Command) { + cmd := version_cmd.parent + version := '${cmd.name} v${cmd.version}' + println(version) +}