diff --git a/AUTHORS.md b/AUTHORS.md index 1ff8ee74..b27f3b9b 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -35,6 +35,8 @@ See also a full list of 98teg Andrii Doroshenko (Xrayez) Daw11 + Hennadii Chernyshchyk (Shatur) + lupoDharkael Mariano Suligoy (MarianoGnu) RafaƂ Mikrut (qarmin) Twarit Waikar (ChronicallySerious) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e29744f..7ced3eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Added - Built-in implementation of Git version control plugin. +- A `CommandLineParser` class which allows to parse arguments from `OS.get_cmdline_args()`. - An experimental support for cross-language mixin using `MixinScript` (aka `MultiScript`). - A `PolyPath2D` node, which takes `Path2D` nodes to buffer curves into polygons. - A `Stopwatch` node, which complements Godot's `Timer` node. diff --git a/core/command_line_parser.cpp b/core/command_line_parser.cpp new file mode 100644 index 00000000..6bc2458c --- /dev/null +++ b/core/command_line_parser.cpp @@ -0,0 +1,917 @@ +#include "command_line_parser.h" + +#include "core/os/os.h" + +// CommandLineOption + +void CommandLineOption::_bind_methods() { + ClassDB::bind_method(D_METHOD("set_names", "names"), &CommandLineOption::set_names); + ClassDB::bind_method(D_METHOD("get_names"), &CommandLineOption::get_names); + + ClassDB::bind_method(D_METHOD("set_default_args", "args"), &CommandLineOption::set_default_args); + ClassDB::bind_method(D_METHOD("get_default_args"), &CommandLineOption::get_default_args); + + ClassDB::bind_method(D_METHOD("set_allowed_args", "args"), &CommandLineOption::set_allowed_args); + ClassDB::bind_method(D_METHOD("get_allowed_args"), &CommandLineOption::get_allowed_args); + + ClassDB::bind_method(D_METHOD("set_description", "description"), &CommandLineOption::set_description); + ClassDB::bind_method(D_METHOD("get_description"), &CommandLineOption::get_description); + + ClassDB::bind_method(D_METHOD("set_category", "category"), &CommandLineOption::set_category); + ClassDB::bind_method(D_METHOD("get_category"), &CommandLineOption::get_category); + + ClassDB::bind_method(D_METHOD("set_arg_text", "arg_text"), &CommandLineOption::set_arg_text); + ClassDB::bind_method(D_METHOD("get_arg_text"), &CommandLineOption::get_arg_text); + + ClassDB::bind_method(D_METHOD("set_arg_count", "count"), &CommandLineOption::set_arg_count); + ClassDB::bind_method(D_METHOD("get_arg_count"), &CommandLineOption::get_arg_count); + + ClassDB::bind_method(D_METHOD("set_hidden", "hidden"), &CommandLineOption::set_hidden); + ClassDB::bind_method(D_METHOD("is_hidden"), &CommandLineOption::is_hidden); + + ClassDB::bind_method(D_METHOD("set_positional", "positional"), &CommandLineOption::set_positional); + ClassDB::bind_method(D_METHOD("is_positional"), &CommandLineOption::is_positional); + + ClassDB::bind_method(D_METHOD("set_required", "required"), &CommandLineOption::set_required); + ClassDB::bind_method(D_METHOD("is_required"), &CommandLineOption::is_required); + + ClassDB::bind_method(D_METHOD("set_multitoken", "multitoken"), &CommandLineOption::set_multitoken); + ClassDB::bind_method(D_METHOD("is_multitoken"), &CommandLineOption::is_multitoken); + + ClassDB::bind_method(D_METHOD("set_as_meta", "meta"), &CommandLineOption::set_as_meta); + ClassDB::bind_method(D_METHOD("is_meta"), &CommandLineOption::is_meta); + + ClassDB::bind_method(D_METHOD("add_name", "name"), &CommandLineOption::add_name); + ClassDB::bind_method(D_METHOD("add_default_arg", "arg"), &CommandLineOption::add_default_arg); + ClassDB::bind_method(D_METHOD("add_allowed_arg", "arg"), &CommandLineOption::add_allowed_arg); + + ADD_PROPERTY(PropertyInfo(Variant::POOL_STRING_ARRAY, "names"), "set_names", "get_names"); + ADD_PROPERTY(PropertyInfo(Variant::POOL_STRING_ARRAY, "default_args"), "set_default_args", "get_default_args"); + ADD_PROPERTY(PropertyInfo(Variant::POOL_STRING_ARRAY, "allowed_args"), "set_allowed_args", "get_allowed_args"); + ADD_PROPERTY(PropertyInfo(Variant::STRING, "description"), "set_description", "get_description"); + ADD_PROPERTY(PropertyInfo(Variant::STRING, "category"), "set_category", "get_category"); + ADD_PROPERTY(PropertyInfo(Variant::STRING, "arg_text"), "set_arg_text", "get_arg_text"); + ADD_PROPERTY(PropertyInfo(Variant::INT, "arg_count"), "set_arg_count", "get_arg_count"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "hidden"), "set_hidden", "is_hidden"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "positional"), "set_positional", "is_positional"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "required"), "set_required", "is_required"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "multitoken"), "set_multitoken", "is_multitoken"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "meta"), "set_as_meta", "is_meta"); + + ADD_SIGNAL(MethodInfo("parsed", PropertyInfo(Variant::POOL_STRING_ARRAY, "values"))); +} + +void CommandLineOption::set_names(const PoolStringArray &p_names) { + _names.resize(0); + for (int i = 0; i < p_names.size(); ++i) { + add_name(p_names[i]); + } +} + +void CommandLineOption::add_name(const String &p_name) { + ERR_FAIL_COND_MSG(p_name.empty(), "Option name cannot be empty."); + ERR_FAIL_COND_MSG(p_name.find_char(' ') != -1, "Option name cannot contain spaces: " + p_name); + + _names.push_back(p_name); +} + +void CommandLineOption::add_default_arg(const String &p_arg) { + _default_args.push_back(p_arg); +} + +void CommandLineOption::add_allowed_arg(const String &p_arg) { + _allowed_args.push_back(p_arg); +} + +CommandLineOption::CommandLineOption(const PoolStringArray &p_names, int p_arg_count) : + _names(p_names), + _arg_count(p_arg_count) {} + +// CommandLineHelpFormat + +void CommandLineHelpFormat::_bind_methods() { + ClassDB::bind_method(D_METHOD("set_header", "header"), &CommandLineHelpFormat::set_header); + ClassDB::bind_method(D_METHOD("get_header"), &CommandLineHelpFormat::get_header); + + ClassDB::bind_method(D_METHOD("set_footer", "footer"), &CommandLineHelpFormat::set_footer); + ClassDB::bind_method(D_METHOD("get_footer"), &CommandLineHelpFormat::get_footer); + + ClassDB::bind_method(D_METHOD("set_usage_title", "name"), &CommandLineHelpFormat::set_usage_title); + ClassDB::bind_method(D_METHOD("get_usage_title"), &CommandLineHelpFormat::get_usage_title); + + ClassDB::bind_method(D_METHOD("set_left_pad", "size"), &CommandLineHelpFormat::set_left_pad); + ClassDB::bind_method(D_METHOD("get_left_pad"), &CommandLineHelpFormat::get_left_pad); + + ClassDB::bind_method(D_METHOD("set_right_pad", "size"), &CommandLineHelpFormat::set_right_pad); + ClassDB::bind_method(D_METHOD("get_right_pad"), &CommandLineHelpFormat::get_right_pad); + + ClassDB::bind_method(D_METHOD("set_line_length", "size"), &CommandLineHelpFormat::set_line_length); + ClassDB::bind_method(D_METHOD("get_line_length"), &CommandLineHelpFormat::get_line_length); + + ClassDB::bind_method(D_METHOD("set_min_description_length", "size"), &CommandLineHelpFormat::set_min_description_length); + ClassDB::bind_method(D_METHOD("get_min_description_length"), &CommandLineHelpFormat::get_min_description_length); + + ClassDB::bind_method(D_METHOD("set_autogenerate_usage", "generate"), &CommandLineHelpFormat::set_autogenerate_usage); + ClassDB::bind_method(D_METHOD("is_usage_autogenerated"), &CommandLineHelpFormat::is_usage_autogenerated); + + ADD_PROPERTY(PropertyInfo(Variant::STRING, "header"), "set_header", "get_header"); + ADD_PROPERTY(PropertyInfo(Variant::STRING, "footer"), "set_footer", "get_footer"); + ADD_PROPERTY(PropertyInfo(Variant::STRING, "usage_title"), "set_usage_title", "get_usage_title"); + ADD_PROPERTY(PropertyInfo(Variant::INT, "left_pad"), "set_left_pad", "get_left_pad"); + ADD_PROPERTY(PropertyInfo(Variant::INT, "right_pad"), "set_right_pad", "get_right_pad"); + ADD_PROPERTY(PropertyInfo(Variant::INT, "line_length"), "set_line_length", "get_line_length"); + ADD_PROPERTY(PropertyInfo(Variant::INT, "min_description_length"), "set_min_description_length", "get_min_description_length"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "autogenerate_usage"), "set_autogenerate_usage", "is_usage_autogenerated"); +} + +// Represents detected option prefix. +struct CommandLineParser::ParsedPrefix { + String string; + bool is_short = false; + + _FORCE_INLINE_ bool exists() const { + return !string.empty(); + } + + _FORCE_INLINE_ int length() const { + return string.length(); + } +}; + +// CommandLineParser + +// TODO: can be ported to PackedStringArray::find() method in Godot 4.0. +static int find_arg(const PoolStringArray &p_args, const String &p_arg) { + for (int i = 0; i < p_args.size(); ++i) { + if (p_args[i] == p_arg) { + return i; + } + } + return -1; +} + +// TODO: can be ported to PackedStringArray::has() method in Godot 4.0. +static bool has_arg(const PoolStringArray &p_args, const String &p_arg) { + for (int i = 0; i < p_args.size(); ++i) { + if (p_args[i] == p_arg) { + return true; + } + } + return false; +} + +// TODO: can be ported to PackedStringArray::join() method in Godot 4.0. +static String join_args(const PoolStringArray &p_args) { + Vector to_join; + for (int i = 0; i < p_args.size(); ++i) { + to_join.push_back(p_args[i]); + } + return String(",").join(to_join); +} + +bool CommandLineParser::_are_options_valid() const { + ERR_FAIL_COND_V_MSG(_short_prefixes.empty(), false, "Short prefixes cannot be empty"); + ERR_FAIL_COND_V_MSG(_long_prefixes.empty(), false, "Long prefixes cannot be empty"); + + for (int i = 0; i < _options.size(); ++i) { + const CommandLineOption *option = _options[i].ptr(); + const PoolStringArray default_args = option->get_default_args(); + + ERR_FAIL_COND_V_MSG(option->is_positional() && option->get_arg_count() == 0, false, + vformat("Option '%s' cannot be positional and take no arguments.", _to_string(option->get_names()))); + ERR_FAIL_COND_V_MSG(option->get_names().empty(), false, vformat("Option at index %d does not have any name.", i)); + + ERR_FAIL_COND_V_MSG(!default_args.empty() && default_args.size() != option->get_arg_count(), false, + vformat("Option '%s' has %d default arguments, but requires %d.", _to_string(option->get_names()), default_args.size(), option->get_arg_count())); + ERR_FAIL_COND_V_MSG(!default_args.empty() && option->is_required(), false, + vformat("Option '%s' cannot have default arguments and be required.", _to_string(option->get_names()))); + + const PoolStringArray allowed_args = option->get_allowed_args(); + for (int j = 0; j < default_args.size(); ++j) { + if (!allowed_args.empty() && find_arg(allowed_args, default_args[j]) == -1) { + ERR_PRINT(vformat("Option '%s' cannot have default argument '%s', because it's not allowed.", _to_string(option->get_names()), default_args[j])); + return false; + } + } + + // Compare with other options. + // TODO: all of this can be ported to PackedStringArray::operator==() in Godot 4.0. + for (int j = i + 1; j < _options.size(); ++j) { + const CommandLineOption *another_option = _options[j].ptr(); + + PoolStringArray opt_names = option->get_names(); + PoolStringArray another_opt_names = another_option->get_names(); + + bool same_name = true; + + if (opt_names.size() != another_opt_names.size()) { + same_name = false; + } else { + for (int k = 0; k < opt_names.size(); ++k) { + if (opt_names[k] != another_opt_names[k]) { + same_name = false; + break; + } + } + } + ERR_FAIL_COND_V_MSG(same_name, false, vformat("Found several options with the same name: '%s' and '%s'.", _to_string(option->get_names()), _to_string(another_option->get_names()))); + } + } + return true; +} + +void CommandLineParser::_read_default_args() { + for (int i = 0; i < _options.size(); ++i) { + const CommandLineOption *option = _options[i].ptr(); + if (!_parsed_values.has(option)) { + const PoolStringArray default_args = option->get_default_args(); + if (!default_args.empty()) { + _parsed_values[option] = default_args; + } + } + } +} + +int CommandLineParser::_validate_arguments(int p_current_idx) { + const String ¤t_arg = _args[p_current_idx]; + const ParsedPrefix prefix = _parse_prefix(current_arg); + + if (!prefix.exists()) { + return _validate_positional(current_arg, p_current_idx); + } + if (_allow_adjacent) { + const int separator = current_arg.find("=", prefix.length()); + if (separator != -1) { + return _validate_adjacent(current_arg, prefix.string, separator); + } + } + if (prefix.is_short) { + return _validate_short(current_arg, prefix.string, p_current_idx); + } + return _validate_long(current_arg, prefix.string, p_current_idx); +} + +int CommandLineParser::_validate_positional(const String &p_arg, int p_current_idx) { + for (int i = 0; i < _options.size(); ++i) { + const CommandLineOption *option = _options[i].ptr(); + + if (option->is_positional() && (option->is_multitoken() || !_parsed_values.has(option))) { + const int args_taken = _validate_option_args(option, _to_string(option->get_names()), p_current_idx); + if (args_taken > 0) { + _save_parsed_option(option, p_current_idx, args_taken); + } + return args_taken; + } + } + // No unparsed positional option found. + _error_text = vformat(RTR("Unexpected argument: '%s'."), p_arg); + return -1; +} + +int CommandLineParser::_validate_adjacent(const String &p_arg, const String &p_prefix, int p_separator) { + if (unlikely(p_separator == p_arg.length() - 1)) { + _error_text = vformat(RTR("Missing argument after '%s"), p_arg); + return -1; + } + const String &option_name = p_arg.substr(p_prefix.length(), p_separator - p_prefix.length()); + const CommandLineOption *option = _validate_option(option_name, p_prefix); + if (unlikely(!option)) { + return -1; + } + if (option->get_arg_count() != 1 && option->get_arg_count() != -1) { + _error_text = vformat(RTR("Argument separator '=' can be used only for single argument, but option '%s' accepts %d arguments"), option_name, option->get_arg_count()); + return -1; + } + const String &value = p_arg.substr(p_separator + 1); + if (unlikely(!_validate_option_arg(option, p_prefix + option_name, value))) { + return -1; + } + _save_parsed_option(option, p_prefix, value); + return 1; +} + +int CommandLineParser::_validate_short(const String &p_arg, const String &p_prefix, int p_current_idx) { + // Take each symbol as a option (to allow arguments like -aux). + for (int i = p_prefix.length(); i < p_arg.length(); i++) { + if (unlikely(!_allow_compound && i == p_prefix.length() + 1)) { + // With compound arguments disabled, the loop should only execute once. + _error_text = vformat(RTR("Unexpected text '%s' after '%s'"), p_arg.right(p_prefix.length() + 1), p_arg.left(p_prefix.length() + 1)); + return -1; + } + const String option_name = String::chr(p_arg[i]); + const CommandLineOption *option = _validate_option(option_name, p_prefix); + if (unlikely(!option)) { + return -1; + } + if (option->get_arg_count() != 0) { + const String sticky_arg = p_arg.substr(i + 1); // Handle sticky arguments (e.g. -ovalue), empty if not present. + const String display_name = p_prefix + option_name; + if (!sticky_arg.empty()) { + // Validate sticky argument first if present. + if (unlikely(!_allow_sticky)) { + _error_text = vformat(RTR("Missing space between '%s' and '%s"), p_prefix + option_name, sticky_arg); + return -1; + } + if (unlikely(!_validate_option_arg(option, display_name, sticky_arg))) { + return -1; + } + } + int args_taken = _validate_option_args(option, display_name, p_current_idx + 1, !sticky_arg.empty()); + if (args_taken != -1) { + _save_parsed_option(option, p_prefix, p_current_idx + 1, args_taken, sticky_arg); + ++args_taken; // Count option as taken argument. + } + return args_taken; + } + _save_parsed_option(option, p_prefix); + } + return 1; +} + +int CommandLineParser::_validate_long(const String &p_arg, const String &p_prefix, int p_current_idx) { + const CommandLineOption *option = _validate_option(p_arg.substr(p_prefix.length()), p_prefix); + if (unlikely(!option)) { + return -1; + } + int args_taken = _validate_option_args(option, p_arg, p_current_idx + 1); + if (args_taken != -1) { + _save_parsed_option(option, p_prefix, p_current_idx + 1, args_taken); + ++args_taken; // Count option as taken argument. + } + return args_taken; +} + +const CommandLineOption *CommandLineParser::_validate_option(const String &p_name, const String &p_prefix) { + const CommandLineOption *option = find_option(p_name).ptr(); + if (unlikely(!option)) { + _error_text = vformat(RTR("'%s' is not a valid option."), p_prefix + p_name); + // Try to suggest the correct option. + const String similar_name = _find_most_similar(p_name); + if (!similar_name.empty()) { + _error_text += "\n"; + _error_text += vformat(RTR("Perhaps you wanted to use: '%s'."), p_prefix + similar_name); + } + return nullptr; + } + if (unlikely(!option->is_multitoken() && _parsed_values.has(option))) { + _error_text = vformat(RTR("Option '%s' has been specified more than once."), p_prefix + p_name); + return nullptr; + } + return option; +} + +int CommandLineParser::_validate_option_args(const CommandLineOption *p_option, const String &p_display_name, int p_current_idx, bool p_skip_first) { + int validated_arg_count = 0; + int available_args = _args.size() - p_current_idx; + if (!_forwarding_args.empty()) { + available_args -= _forwarding_args.size() + 1; // Exclude forwarded args with separator. + } + + // Get all arguments left if specified value less then 0. + const int arg_count = p_option->get_arg_count() < 0 ? available_args : MIN(available_args, p_option->get_arg_count() - p_skip_first); + for (int i = 0; i < arg_count; ++i) { + const String &arg = _args[p_current_idx + i]; + + // Stop parsing on new option. + if (_parse_prefix(arg).exists()) { + break; + } + if (unlikely(!_validate_option_arg(p_option, p_display_name, arg))) { + return -1; + } + ++validated_arg_count; + } + + // The option has a certain number of required arguments, but got less. + if (unlikely(p_option->get_arg_count() >= 0 && p_option->get_arg_count() != validated_arg_count + p_skip_first)) { + _error_text = vformat(RTR("Option '%s' expects %d arguments, but %d was provided."), + p_display_name, p_option->get_arg_count(), validated_arg_count + p_skip_first); + return -1; + } + // Option that takes all arguments left should always have at least one. + if (unlikely(p_option->get_arg_count() < 0 && validated_arg_count == 0)) { + _error_text = vformat(RTR("Option '%s' expects at least one argument."), p_display_name); + return -1; + } + + return validated_arg_count; +} + +bool CommandLineParser::_validate_option_arg(const CommandLineOption *p_option, const String &p_display_name, const String &p_arg) { + if (unlikely(!p_option->get_allowed_args().empty() && !has_arg(p_option->get_allowed_args(), p_arg))) { + _error_text = vformat(RTR("Argument '%s' cannot be used for '%s', possible values: %s."), p_arg, p_display_name, join_args(p_option->get_allowed_args())); + return false; + } + return true; +} + +void CommandLineParser::_save_parsed_option(const CommandLineOption *p_option, const String &p_prefix, int p_idx, int p_arg_count, const String &p_additional_value) { + _parsed_count[p_option] += 1; + if (!p_prefix.empty()) { + _parsed_prefixes[p_option].push_back(p_prefix); + } + PoolStringArray &values = _parsed_values[p_option]; + if (!p_additional_value.empty()) { + values.push_back(p_additional_value); + } + for (int i = p_idx; i < p_idx + p_arg_count; ++i) { + values.push_back(_args[i]); + } +} + +void CommandLineParser::_save_parsed_option(const CommandLineOption *p_option, const String &p_prefix, const String &p_value) { + _save_parsed_option(p_option, p_prefix, 0, 0, p_value); +} + +void CommandLineParser::_save_parsed_option(const CommandLineOption *p_option, int p_idx, int p_arg_count) { + _save_parsed_option(p_option, String(), p_idx, p_arg_count); +} + +String CommandLineParser::_get_usage(const Vector> &p_printable_options, const String &p_title) const { + String usage = vformat(RTR("Usage: %s"), p_title.empty() ? OS::get_singleton()->get_executable_path().get_file() : p_title); + if (_contains_optional_options(p_printable_options)) { + usage += ' ' + RTR("[options]"); + } + + for (int i = 0; i < p_printable_options.size(); ++i) { + const CommandLineOption *option = p_printable_options[i].first; + if (!option->is_required()) { + continue; + } + const PoolStringArray names = option->get_names(); + usage += ' '; + if (option->is_positional()) { + usage += '['; + } + usage += _get_prefixed_longest_name(names); + if (option->is_positional()) { + usage += ']'; + } + if (option->get_arg_count() != 0) { + const String arg_text = option->get_arg_text(); + if (!arg_text.empty()) { + usage += ' ' + arg_text; + if (option->get_arg_count() < 0 || option->is_multitoken()) { + usage += "..."; + } + } + } + } + return usage; +} + +String CommandLineParser::_get_options_description(const OrderedHashMap &p_categories_data) const { + String description; + for (OrderedHashMap::ConstElement E = p_categories_data.front(); E; E = E.next()) { + const String &category = E.key(); + const PoolStringArray &lines = E.value(); + + description += '\n'; // Add a blank line for readability. + if (!category.empty()) { + description += '\n' + category + ":"; + } + for (int j = 0; j < lines.size(); ++j) { + description += '\n' + lines[j]; + } + } + return description; +} + +String CommandLineParser::_to_string(const PoolStringArray &p_names) const { + String string; + for (int i = 0; i < p_names.size(); ++i) { + if (i != 0) { + string += ", "; + } + const PoolStringArray &prefixes = p_names[i].length() == 1 ? _short_prefixes : _long_prefixes; + for (int j = 0; j < prefixes.size(); ++j) { + if (j != 0) { + string += ", "; + } + string += prefixes[j] + p_names[i]; + } + } + return string; +} + +String CommandLineParser::_get_prefixed_longest_name(const PoolStringArray &p_names) const { + int longest_idx = 0; + for (int i = 0, longest_size = 0; i < p_names.size(); ++i) { + const int current_size = p_names[i].size(); + if (current_size > longest_size) { + longest_size = current_size; + longest_idx = i; + } + } + if (p_names[longest_idx].size() > 0) { + return _long_prefixes[0] + p_names[longest_idx]; + } + return _short_prefixes[0] + p_names[longest_idx]; +} + +CommandLineParser::ParsedPrefix CommandLineParser::_parse_prefix(const String &p_arg) const { + // Check if argument is a negative number. + if (p_arg.is_valid_float()) { + return ParsedPrefix(); + } + // Find longest prefix to match to properly distinguish between prefixes + // such as `--` and `--no-`, so that shorter prefixes don't prematurely + // match option names that have longer prefixes. + int longest_idx = -1; + int longest_size = 0; + for (int i = 0; i < _long_prefixes.size(); ++i) { + const String &prefix = _long_prefixes[i]; + if (p_arg.begins_with(prefix)) { + if (prefix.length() > longest_size) { + longest_idx = i; + longest_size = prefix.length(); + } + } + } + if (longest_idx != -1) { + return ParsedPrefix{ _long_prefixes[longest_idx], false }; + } + for (int i = 0; i < _short_prefixes.size(); ++i) { + if (p_arg.begins_with(_short_prefixes[i])) { + return ParsedPrefix{ _short_prefixes[i], true }; + } + } + return ParsedPrefix(); +} + +String CommandLineParser::_find_most_similar(const String &p_name) const { + if (p_name.length() == 1) { + // Do not search for short names. + return String(); + } + String most_similar; + float max_similarity = _similarity_bias; // Start with this value to avoid returning unrelated names. + + for (int i = 0; i < _options.size(); ++i) { + const PoolStringArray flags = _options[i]->get_names(); + for (int j = 0; j < flags.size(); ++j) { + float similarity = flags[j].similarity(p_name); + if (max_similarity < similarity) { + most_similar = flags[j]; + max_similarity = similarity; + } + } + } + return most_similar; +} + +bool CommandLineParser::_contains_optional_options(const Vector> &p_printable_options) { + for (int i = 0; i < p_printable_options.size(); ++i) { + if (!p_printable_options[i].first->is_required()) { + return true; + } + } + return false; +} + +void CommandLineParser::_bind_methods() { + ClassDB::bind_method(D_METHOD("parse", "args"), &CommandLineParser::parse); + + ClassDB::bind_method(D_METHOD("add_option", "name", "description", "default_value", "allowed_values"), &CommandLineParser::add_option, DEFVAL(""), DEFVAL(""), DEFVAL(PoolStringArray())); + ClassDB::bind_method(D_METHOD("add_help_option"), &CommandLineParser::add_help_option); + ClassDB::bind_method(D_METHOD("add_version_option"), &CommandLineParser::add_version_option); + + ClassDB::bind_method(D_METHOD("append_option", "option"), &CommandLineParser::append_option); + ClassDB::bind_method(D_METHOD("set_option", "index", "option"), &CommandLineParser::set_option); + ClassDB::bind_method(D_METHOD("get_option", "index"), &CommandLineParser::get_option); + ClassDB::bind_method(D_METHOD("get_option_count"), &CommandLineParser::get_option_count); + ClassDB::bind_method(D_METHOD("remove_option", "index"), &CommandLineParser::remove_option); + ClassDB::bind_method(D_METHOD("find_option", "name"), &CommandLineParser::find_option); + + ClassDB::bind_method(D_METHOD("is_set", "option"), &CommandLineParser::is_set); + + ClassDB::bind_method(D_METHOD("get_value", "option"), &CommandLineParser::get_value); + ClassDB::bind_method(D_METHOD("get_value_list", "option"), &CommandLineParser::get_value_list); + + ClassDB::bind_method(D_METHOD("get_prefix", "option"), &CommandLineParser::get_prefix); + ClassDB::bind_method(D_METHOD("get_prefix_list", "option"), &CommandLineParser::get_prefix_list); + + ClassDB::bind_method(D_METHOD("get_occurrence_count", "option"), &CommandLineParser::get_occurrence_count); + + ClassDB::bind_method(D_METHOD("get_forwarding_args"), &CommandLineParser::get_forwarding_args); + ClassDB::bind_method(D_METHOD("get_args"), &CommandLineParser::get_args); + + ClassDB::bind_method(D_METHOD("get_help_text", "format"), &CommandLineParser::get_help_text, DEFVAL(Variant())); + ClassDB::bind_method(D_METHOD("get_error_text"), &CommandLineParser::get_error_text); + + ClassDB::bind_method(D_METHOD("clear"), &CommandLineParser::clear); + + ClassDB::bind_method(D_METHOD("set_long_prefixes", "prefixes"), &CommandLineParser::set_long_prefixes); + ClassDB::bind_method(D_METHOD("get_long_prefixes"), &CommandLineParser::get_long_prefixes); + + ClassDB::bind_method(D_METHOD("set_short_prefixes", "prefixes"), &CommandLineParser::set_short_prefixes); + ClassDB::bind_method(D_METHOD("get_short_prefixes"), &CommandLineParser::get_short_prefixes); + + ClassDB::bind_method(D_METHOD("set_similarity_bias", "bias"), &CommandLineParser::set_similarity_bias); + ClassDB::bind_method(D_METHOD("get_similarity_bias"), &CommandLineParser::get_similarity_bias); + + ClassDB::bind_method(D_METHOD("set_allow_forwarding_args", "allow"), &CommandLineParser::set_allow_forwarding_args); + ClassDB::bind_method(D_METHOD("are_forwarding_args_allowed"), &CommandLineParser::are_forwarding_args_allowed); + + ClassDB::bind_method(D_METHOD("set_allow_adjacent", "allow"), &CommandLineParser::set_allow_adjacent); + ClassDB::bind_method(D_METHOD("is_adjacent_allowed"), &CommandLineParser::is_adjacent_allowed); + + ClassDB::bind_method(D_METHOD("set_allow_sticky", "allow"), &CommandLineParser::set_allow_sticky); + ClassDB::bind_method(D_METHOD("is_sticky_allowed"), &CommandLineParser::is_sticky_allowed); + + ClassDB::bind_method(D_METHOD("set_allow_compound", "allow"), &CommandLineParser::set_allow_compound); + ClassDB::bind_method(D_METHOD("is_compound_allowed"), &CommandLineParser::is_compound_allowed); + + ADD_PROPERTY(PropertyInfo(Variant::POOL_STRING_ARRAY, "long_prefixes"), "set_long_prefixes", "get_long_prefixes"); + ADD_PROPERTY(PropertyInfo(Variant::POOL_STRING_ARRAY, "short_prefixes"), "set_short_prefixes", "get_short_prefixes"); + ADD_PROPERTY(PropertyInfo(Variant::REAL, "similarity_bias"), "set_similarity_bias", "get_similarity_bias"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "allow_forwarding_args"), "set_allow_forwarding_args", "are_forwarding_args_allowed"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "allow_adjacent"), "set_allow_adjacent", "is_adjacent_allowed"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "allow_sticky"), "set_allow_sticky", "is_sticky_allowed"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "allow_compound"), "set_allow_compound", "is_compound_allowed"); +} + +Error CommandLineParser::parse(const PoolStringArray &p_args) { + _args = p_args; + _forwarding_args.resize(0); + _parsed_values.clear(); + _parsed_prefixes.clear(); + _parsed_count.clear(); + + if (unlikely(!_are_options_valid())) { + _error_text = RTR("Option parser was defined with incorrect options."); + return ERR_PARSE_ERROR; + } + + int arg_count = _args.size(); + if (_allow_forwarding_args) { + int separator_pos = find_arg(_args, "--"); + if (separator_pos != -1) { + // Separator available. + arg_count = separator_pos; + if (separator_pos + 1 != _args.size()) { + // Arguments after available. + _forwarding_args = _args.subarray(separator_pos + 1, _args.size() - 1); + } + } + } + + for (int i = 0; i < arg_count;) { + const int taken_arguments = _validate_arguments(i); + if (unlikely(taken_arguments == -1)) { + return ERR_PARSE_ERROR; + } + i += taken_arguments; + } + + _read_default_args(); + + bool meta_options_parsed = false; + for (int i = 0; i < _options.size(); ++i) { + const CommandLineOption *option = _options[i].ptr(); + if (_parsed_values.has(option) && option->is_meta()) { + meta_options_parsed = true; + break; + } + } + if (!meta_options_parsed) { + for (int i = 0; i < _options.size(); ++i) { + const CommandLineOption *option = _options[i].ptr(); + if (unlikely(option->is_required() && !_parsed_values.has(option))) { + _error_text = vformat(RTR("Option '%s' is required but missing."), _to_string(option->get_names())); + return ERR_PARSE_ERROR; + } + } + } + for (int i = 0; i < _options.size(); ++i) { + CommandLineOption *option = _options.get(i).ptr(); + const Map::Element *E = _parsed_values.find(option); + if (E) { + option->emit_signal("parsed", E->value()); + } + } + return OK; +} + +void CommandLineParser::append_option(const Ref &p_option) { + ERR_FAIL_COND(p_option.is_null()); + _options.push_back(p_option); +} + +int CommandLineParser::get_option_count() const { + return _options.size(); +} + +Ref CommandLineParser::get_option(int p_idx) const { + ERR_FAIL_INDEX_V(p_idx, _options.size(), nullptr); + return _options[p_idx]; +} + +void CommandLineParser::set_option(int p_idx, const Ref &p_option) { + ERR_FAIL_INDEX(p_idx, _options.size()); + _options.set(p_idx, p_option); +} + +void CommandLineParser::remove_option(int p_idx) { + ERR_FAIL_INDEX(p_idx, _options.size()); + _options.remove(p_idx); +} + +Ref CommandLineParser::find_option(const String &p_name) const { + for (int i = 0; i < _options.size(); ++i) { + if (has_arg(_options[i]->get_names(), p_name)) { + return _options[i]; + } + } + return Ref(); +} + +Ref CommandLineParser::add_option(const String &p_name, const String &p_description, const String &p_default_value, const PoolStringArray &p_allowed_values) { + ERR_FAIL_COND_V_MSG(p_name.empty(), Ref(), "Option name cannot be empty."); + + Ref option = memnew(CommandLineOption); + option->add_name(p_name); + option->set_description(p_description); + + if (!p_default_value.empty()) { + option->add_default_arg(p_default_value); + } + if (!p_allowed_values.empty()) { + option->set_allowed_args(p_allowed_values); + } + _options.push_back(option); + + return option; +} + +Ref CommandLineParser::add_help_option() { + PoolVector names; + names.push_back("h"); + names.push_back("help"); + + Ref option = memnew(CommandLineOption(names, 0)); + option->set_category("General"); + option->set_description("Display this help message."); + option->set_as_meta(true); + append_option(option); + + return option; +} + +Ref CommandLineParser::add_version_option() { + PoolVector names; + names.push_back("v"); + names.push_back("version"); + + Ref option = memnew(CommandLineOption(names, 0)); + option->set_category("General"); + option->set_description("Display version information."); + option->set_as_meta(true); + append_option(option); + + return option; +} + +bool CommandLineParser::is_set(const Ref &p_option) const { + ERR_FAIL_COND_V(p_option.is_null(), false); + return _parsed_values.has(p_option.ptr()); +} + +String CommandLineParser::get_value(const Ref &p_option) const { + ERR_FAIL_COND_V(p_option.is_null(), String()); + ERR_FAIL_COND_V_MSG(p_option->get_arg_count() == 0, String(), vformat("Option '%s' does not accept arguments.", _to_string(p_option->get_names()))); + + const PoolStringArray args = get_value_list(p_option); + if (args.empty()) { + return String(); + } + return args[0]; +} + +PoolStringArray CommandLineParser::get_value_list(const Ref &p_option) const { + ERR_FAIL_COND_V(p_option.is_null(), PoolStringArray()); + ERR_FAIL_COND_V_MSG(p_option->get_arg_count() == 0, PoolStringArray(), vformat("Option '%s' does not accept arguments.", _to_string(p_option->get_names()))); + const Map::Element *E = _parsed_values.find(p_option.ptr()); + if (!E) { + return PoolStringArray(); + } + return E->value(); +} + +String CommandLineParser::get_prefix(const Ref &p_option) const { + ERR_FAIL_COND_V(p_option.is_null(), String()); + const PoolStringArray args = get_prefix_list(p_option); + if (args.empty()) { + return String(); + } + return args[0]; +} + +PoolStringArray CommandLineParser::get_prefix_list(const Ref &p_option) const { + ERR_FAIL_COND_V(p_option.is_null(), PoolStringArray()); + const Map::Element *E = _parsed_prefixes.find(p_option.ptr()); + if (!E) { + return PoolStringArray(); + } + return E->value(); +} + +int CommandLineParser::get_occurrence_count(const Ref &p_option) const { + ERR_FAIL_COND_V(p_option.is_null(), 0); + const Map::Element *E = _parsed_count.find(p_option.ptr()); + if (!E) { + return 0; + } + return E->value(); +} + +String CommandLineParser::get_help_text(const Ref &p_format) const { + ERR_FAIL_COND_V_MSG(_short_prefixes.empty(), String(), "Short prefixes cannot be empty"); + ERR_FAIL_COND_V_MSG(_long_prefixes.empty(), String(), "Long prefixes cannot be empty"); + + Ref format = p_format; + if (format.is_null()) { + format.instance(); + } + // Build the formated "-x, --xxxxx" and save the longest size to align the descriptions. + int options_length = 0; + Vector> printable_options; + for (int i = 0; i < _options.size(); ++i) { + const CommandLineOption *option = _options[i].ptr(); + if (option->is_hidden()) { + continue; + } + const PoolStringArray names = option->get_names(); + ERR_CONTINUE_MSG(names.empty(), vformat("Option at index %d does not have any name.", i)); + + String line = _to_string(names); + if (option->get_arg_count() != 0) { + line += ' ' + option->get_arg_text(); + } + options_length = MAX(options_length, line.length()); + printable_options.push_back(Pair(option, line)); + } + // Adjust max available line length from specified parameters. + options_length = MIN(options_length + format->get_left_pad() + format->get_right_pad(), format->get_line_length() - format->get_min_description_length()); + const int descriptions_length = format->get_line_length() - options_length; + + // Fill categories and their data. + OrderedHashMap categories_data; + for (int i = 0; i < printable_options.size(); ++i) { + String line = printable_options[i].second.rpad(options_length - format->get_left_pad()); + line = line.lpad(line.length() + format->get_left_pad()); + if (line.length() > options_length) { + // For long options, add a new padded line to display the description on a new line. + line += '\n'; + line = line.rpad(line.length() + options_length); + } + + const CommandLineOption *option = printable_options[i].first; + int description_pos = line.length() + 1; + line += option->get_description(); + if (!option->get_allowed_args().empty()) { + line += vformat(RTR(" Allowed values: %s."), join_args(option->get_allowed_args())); + } + + // Split long descriptions into multiply lines. + while (line.length() - description_pos > descriptions_length) { + int split_pos = line.rfind(" ", description_pos + descriptions_length); // Find last space to split by words. + if (split_pos < description_pos) { + // Word is too long, just split it at maximum size. + split_pos = description_pos + descriptions_length - 1; + line = line.insert(split_pos, "\n"); + } else { + // Replace found space with line break. + line.set(split_pos, '\n'); + } + // Pad to the description column. + line = line.insert(split_pos + 1, String().rpad(options_length)); + // Shift position to the next unprocessed line. + description_pos = split_pos + 1 + options_length; + } + categories_data[option->get_category()].push_back(line); + } + // Start generating help. + String help_text; + if (!format->get_header().empty()) { + help_text += format->get_header(); + } + if (format->is_usage_autogenerated()) { + help_text += '\n' + _get_usage(printable_options, format->get_usage_title()); + } + help_text += _get_options_description(categories_data); + if (!format->get_footer().empty()) { + help_text += '\n' + format->get_footer(); + } + help_text += '\n'; + return help_text; +} + +void CommandLineParser::clear() { + _options.clear(); + _args.resize(0); + _forwarding_args.resize(0); + _parsed_values.clear(); + _parsed_prefixes.clear(); + _error_text.clear(); +} diff --git a/core/command_line_parser.h b/core/command_line_parser.h new file mode 100644 index 00000000..8164fc01 --- /dev/null +++ b/core/command_line_parser.h @@ -0,0 +1,245 @@ +#pragma once + +#include "core/ordered_hash_map.h" +#include "core/reference.h" + +class CommandLineOption : public Reference { + GDCLASS(CommandLineOption, Reference); + + // Names for the options. e.g --help or -h. + PoolStringArray _names; + // List of default values for each argument, empty if any value is allowed. + PoolStringArray _default_args; + // List of allowed values for each argument, empty if any value is allowed. + PoolStringArray _allowed_args; + // Option's description that will be displayed in help. + String _description; + // Option's category, options sharing the same category are grouped together in help. + String _category; + // Name for the option's arguments that will be displayed in help. + String _arg_text = RTR(""); + // Make the option visible in help. + bool _hidden = false; + // If true, arguments can be specified without an option name. + bool _positional = false; + // If true, argument must always be provided. + bool _required = false; + // If true, the option can be specified several times. + bool _multitoken = false; + // Arguments count required for the option, -1 for all arguments left. + int _arg_count = 1; + // Options marked as meta, such as --help, --version etc. + // Allows to skip some validation checks such as required options. + bool _meta = false; + +protected: + static void _bind_methods(); + +public: + void set_names(const PoolStringArray &p_names); + PoolStringArray get_names() const { return _names; } + + void set_default_args(const PoolStringArray &p_args) { _default_args = p_args; } + PoolStringArray get_default_args() const { return _default_args; } + + void set_allowed_args(const PoolStringArray &p_args) { _allowed_args = p_args; } + PoolStringArray get_allowed_args() const { return _allowed_args; } + + void set_description(const String &p_description) { _description = p_description; } + String get_description() const { return _description; } + + void set_category(const String &p_category) { _category = p_category; } + String get_category() const { return _category; } + + void set_arg_text(const String &p_arg_text) { _arg_text = p_arg_text; } + String get_arg_text() const { return _arg_text; } + + void set_arg_count(int p_count) { _arg_count = p_count; } + int get_arg_count() const { return _arg_count; } + + void set_hidden(bool p_hidden) { _hidden = p_hidden; } + bool is_hidden() const { return _hidden; } + + void set_positional(bool p_positional) { _positional = p_positional; } + bool is_positional() const { return _positional; } + + void set_required(bool p_required) { _required = p_required; } + bool is_required() const { return _required; } + + void set_multitoken(bool p_multitoken) { _multitoken = p_multitoken; } + bool is_multitoken() const { return _multitoken; } + + void set_as_meta(bool p_meta) { _meta = p_meta; } + bool is_meta() const { return _meta; } + + // Utility methods. + void add_name(const String &p_name); + void add_default_arg(const String &p_arg); + void add_allowed_arg(const String &p_arg); + + CommandLineOption() = default; + explicit CommandLineOption(const PoolStringArray &p_names, int p_arg_count = 1); +}; + +class CommandLineHelpFormat : public Reference { + GDCLASS(CommandLineHelpFormat, Reference); + + String _help_header; + String _help_footer; + String _usage_title; + + int _left_help_pad = 2; + int _right_help_pad = 4; + int _help_line_length = 80; + int _min_description_length = _help_line_length / 2; + + bool _autogenerate_usage = true; + +protected: + static void _bind_methods(); + +public: + void set_header(const String &p_header) { _help_header = p_header; } + String get_header() const { return _help_header; } + + void set_footer(const String &p_footer) { _help_footer = p_footer; } + String get_footer() const { return _help_footer; } + + void set_usage_title(const String &p_title) { _usage_title = p_title; } + String get_usage_title() const { return _usage_title; } + + void set_left_pad(int p_size) { _left_help_pad = p_size; } + int get_left_pad() const { return _left_help_pad; } + + void set_right_pad(int p_size) { _right_help_pad = p_size; } + int get_right_pad() const { return _right_help_pad; } + + void set_line_length(int p_length) { _help_line_length = p_length; } + int get_line_length() const { return _help_line_length; } + + void set_min_description_length(int p_length) { _min_description_length = p_length; } + int get_min_description_length() const { return _min_description_length; } + + void set_autogenerate_usage(bool p_generate) { _autogenerate_usage = p_generate; } + bool is_usage_autogenerated() const { return _autogenerate_usage; } +}; + +class CommandLineParser : public Reference { + GDCLASS(CommandLineParser, Reference); + + struct ParsedPrefix; + + Vector> _options; + + PoolStringArray _args; + PoolStringArray _forwarding_args; + + PoolStringArray _long_prefixes; + PoolStringArray _short_prefixes; + + Map _parsed_values; + Map _parsed_prefixes; + Map _parsed_count; + + String _error_text; + + float _similarity_bias = 0.3; + + bool _allow_forwarding_args = false; + bool _allow_adjacent = true; + bool _allow_sticky = true; + bool _allow_compound = true; + + // Parser main helpers. + bool _are_options_valid() const; + void _read_default_args(); + int _validate_arguments(int p_current_idx); // Returns number of arguments taken, -1 on validation error. + + // Helpers for the function above that parse a specific case. + int _validate_positional(const String &p_arg, int p_current_idx); + int _validate_adjacent(const String &p_arg, const String &p_prefix, int p_separator); + int _validate_short(const String &p_arg, const String &p_prefix, int p_current_idx); + int _validate_long(const String &p_arg, const String &p_prefix, int p_current_idx); + + // Validation helpers. + const CommandLineOption *_validate_option(const String &p_name, const String &p_prefix); + int _validate_option_args(const CommandLineOption *p_option, const String &p_display_name, int p_current_idx, bool p_skip_first = false); + bool _validate_option_arg(const CommandLineOption *p_option, const String &p_display_name, const String &p_arg); + + // Save information about parsed option. + void _save_parsed_option(const CommandLineOption *p_option, const String &p_prefix, int p_idx, int p_arg_count, const String &p_additional_value = String()); + void _save_parsed_option(const CommandLineOption *p_option, const String &p_prefix, const String &p_value = String()); + void _save_parsed_option(const CommandLineOption *p_option, int p_idx, int p_arg_count); + + // Help text printers. + String _get_usage(const Vector> &p_printable_options, const String &p_title) const; + String _get_options_description(const OrderedHashMap &p_categories_data) const; + + // Other utilies. + String _to_string(const PoolStringArray &p_names) const; // Returns all option names separated by commas with all prefix variants. + String _get_prefixed_longest_name(const PoolStringArray &p_names) const; // Returns longest name with first available prefix (long or short). + ParsedPrefix _parse_prefix(const String &p_arg) const; + String _find_most_similar(const String &p_name) const; + static bool _contains_optional_options(const Vector> &p_printable_options); + +protected: + static void _bind_methods(); + +public: + Error parse(const PoolStringArray &p_args); + + Ref add_option(const String &p_name, const String &p_description = "", const String &p_default_value = "", const PoolStringArray &p_allowed_values = PoolStringArray()); + Ref add_help_option(); + Ref add_version_option(); + + void append_option(const Ref &p_option); + void set_option(int p_idx, const Ref &p_option); + Ref get_option(int p_idx) const; + int get_option_count() const; + void remove_option(int p_idx); + Ref find_option(const String &p_name) const; + + bool is_set(const Ref &p_option) const; + + String get_value(const Ref &p_option) const; + PoolStringArray get_value_list(const Ref &p_option) const; + + String get_prefix(const Ref &p_option) const; + PoolStringArray get_prefix_list(const Ref &p_option) const; + + int get_occurrence_count(const Ref &p_option) const; + + PoolStringArray get_forwarding_args() const { return _forwarding_args; } + PoolStringArray get_args() const { return _args; } + + String get_help_text(const Ref &p_format = Ref()) const; + String get_error_text() const { return _error_text; } + + void set_long_prefixes(const PoolStringArray &p_prefixes) { _long_prefixes = p_prefixes; } + PoolStringArray get_long_prefixes() const { return _long_prefixes; } + + void set_short_prefixes(const PoolStringArray &p_prefixes) { _short_prefixes = p_prefixes; } + PoolStringArray get_short_prefixes() const { return _short_prefixes; } + + void set_similarity_bias(float p_bias) { _similarity_bias = CLAMP(p_bias, 0.0f, 1.0f); } + float get_similarity_bias() const { return _similarity_bias; } + + void set_allow_forwarding_args(bool p_allow) { _allow_forwarding_args = p_allow; } + bool are_forwarding_args_allowed() const { return _allow_forwarding_args; } + + void set_allow_adjacent(bool p_allow) { _allow_adjacent = p_allow; } + bool is_adjacent_allowed() const { return _allow_adjacent; } + + void set_allow_sticky(bool p_allow) { _allow_sticky = p_allow; } + bool is_sticky_allowed() const { return _allow_sticky; } + + void set_allow_compound(bool p_allow) { _allow_compound = p_allow; } + bool is_compound_allowed() const { return _allow_compound; } + + void clear(); + + CommandLineParser() { + _long_prefixes.push_back("--"); + _short_prefixes.push_back("-"); + }; +}; diff --git a/core/register_core_types.cpp b/core/register_core_types.cpp index 501d8a4d..f2ba2a1c 100644 --- a/core/register_core_types.cpp +++ b/core/register_core_types.cpp @@ -24,6 +24,10 @@ static void _variant_resource_preview_init(); #endif void register_core_types() { + ClassDB::register_class(); + ClassDB::register_class(); + ClassDB::register_class(); + #ifdef GOOST_GoostEngine _goost = memnew(GoostEngine); ClassDB::register_class(); @@ -38,6 +42,7 @@ void register_core_types() { ClassDB::register_class(); ClassDB::register_class(); + #if defined(TOOLS_ENABLED) && defined(GOOST_VariantResource) EditorNode::add_init_callback(_variant_resource_preview_init); #endif diff --git a/doc/CommandLineHelpFormat.xml b/doc/CommandLineHelpFormat.xml new file mode 100644 index 00000000..42881b28 --- /dev/null +++ b/doc/CommandLineHelpFormat.xml @@ -0,0 +1,41 @@ + + + + This class contains formatting options for constructing a help message in [CommandLineParser]. + + + The class is used to pass formatting parameters to the [method CommandLineParser.get_help_text] method. + + + + + + + + If [code]true[/code], the usage text will be automatically generated according to passed options. + + + Contains text to be displayed at the end of the help text. + + + Contains text to be displayed at the beginning of the help text. + + + The amount of indentation in spaces to the left of the options in the help text. + + + The maximum length of the line with option and description in help text. If the line exceeds this length, then its description will be split into several lines. See also [member min_description_length]. + + + The minimum description size used to split description when the line with option and description is too large. See also [member line_length]. + + + The amount of indentation in spaces to the right of the options in the help text. + + + Title that will be displayed in usage text. If empty, the name of the current executable file will be used. + + + + + diff --git a/doc/CommandLineOption.xml b/doc/CommandLineOption.xml new file mode 100644 index 00000000..6ecc560f --- /dev/null +++ b/doc/CommandLineOption.xml @@ -0,0 +1,82 @@ + + + + Defines a command-line option for [CommandLineParser]. + + + This class is used to define an option on the command line. The same option is allowed to have multiple aliases. It is also used to describe how the option is used: it may be a flag (e.g. [code]-v[/code]) or take a value (e.g. [code]-o file[/code]). + + + + + + + + + Appends a new allowed argument to the list of [member allowed_args]. + + + + + + + Appends a new default argument to the list of [member default_args]. + + + + + + + Appends a new name (alias) to the list of [member names]. + + + + + + A set of values that are allowed by the option. If a different value is passed, the parsing will fail. Empty if any value is allowed. + + + The number of arguments required for the option. A value less than 0 means all remaining values up to the next option or the end of the argument list. + + + Name for the option arguments that will be displayed in the help message. For example: [code]--input <filename>[/code], where [code]<filename>[/code] is [code]arg_text[/code]. + + + Category name, options sharing the same category are grouped together in the help message. + + + A set of values that will be used by default if no value specified. + + + Description that will be displayed in the help message. + + + If [code]true[/code], the option will [b]not[/b] be displayed in the help message. + + + If [code]true[/code], the option will be treated as a meta option, such as [code]--help[/code] or [code]--version[/code]. If [CommandLineParser] parses such option, any [member required] options will not throw an error if they are not specified on the command-line. + + + If [code]true[/code], option can be specified multiple times. Total count can be obtained using [method CommandLineParser.get_occurrence_count]. + + + Specifies all valid names (aliases) for this option. + + + If [code]true[/code], option can be specified without a name. In this case, the first unparsed option marked as [member positional] will be selected. + + + If [code]true[/code], [method CommandLineParser.parse] will return an error if the option was not specified in the argument list. + + + + + + + Emitted after calling [method CommandLineParser.parse] if it returns [constant OK]. The [code]values[/code] list contains all values that were passed to the option. + + + + + + diff --git a/doc/CommandLineParser.xml b/doc/CommandLineParser.xml new file mode 100644 index 00000000..01f2f93a --- /dev/null +++ b/doc/CommandLineParser.xml @@ -0,0 +1,211 @@ + + + + Enables parsing of command-line arguments. + + + [CommandLineParser] allows to define a set of [CommandLineOption]s, parse the command-line arguments, store parsed options and respective values. The class is mainly useful for parsing arguments from [method OS.get_cmdline_args], but can be used for other purposes as well. + Options on the command line are recognized as starting with any of the specified [member long_prefixes] or [member short_prefixes] values. Long options consist of more than one character. Short options always consist of one character and can be written in a compound form, for instance [code]-abc[/code] is equivalent to [code]-a -b -c[/code]. + Option values can be specified either separated by a space or by an [code]=[/code] sign (if the option takes only 1 argument). Short options can also have sticky values (without a space in-between, e.g. [code]-Dvalue[/code]). + Values can also be specified without an option name and the first option that is marked as [member CommandLineOption.positional] will be assigned automatically. For example, if the [code]input-file[/code] argument is marked as positional, then you can write either [code]--input-file=filename[/code] or just [code]filename[/code]. + The option [code]--[/code] (without any symbol after) is a special case and means that all following options will be captured as valid and can be obtained later using [method get_forwarding_args]. + Example: + [codeblock] + var parser = CommandLineParser.new() + + func _init(): + var help = parser.add_help_option() + var version = parser.add_version_option() + var input = parser.add_option("input", "Specify filename to process.") + + if parser.parse(["--input", "/path/to/file.txt"]) != OK: + print(parser.get_error_text()) + return + + if parser.is_set(help): + print(parser.get_help_text()) + return + + if parser.is_set(version): + print(GoostEngine.get_version_info().string) + return + + print("Parsed value from '--input': ", parser.get_value(input)) + [/codeblock] + + + + + + + + Adds the help option (-h, --help) with the default description. The option is automatically marked as [member CommandLineOption.meta]. + + + + + + + + + + Instantiates a new [CommandLineOption] with a single name and adds it to the parser. Description, default value, and a list of allowed values can be optionally specified. Note that this method provides common parameters only, you can create [CommandLineOption] and use [method append_option] manually if you need to configure other properties for a new option, or modify the properties of the option returned by this method instead before parsing command-line arguments with [method parse]. + + + + + + Adds the version option (-v, --version) with the default description. The option is automatically marked as [member CommandLineOption.meta]. + + + + + + + Adds an existing option to look for while parsing. Prefer to use [method add_option] for defining new command-line options. + + + + + + Clears all parsed data and specified options. + + + + + + + Finds an option by name, returns [code]null[/code] if it doesn't exist. + + + + + + Returns all arguments that were passed by [method parse]. + + + + + + Returns a human-readable description of the last parser error that occurred. + + + + + + Returns all arguments that were forwarded after parsing (e.g. all arguments after [code]--[/code]). + + + + + + + Returns a string containing the complete help information generated from the added options. If [code]format[/code] is not [code]null[/code], the returned text will be formatted with a custom format defined via [CommandLineHelpFormat] object, otherwise the default format is used. + + + + + + + Returns the number of times the option was parsed. Mostly useful for options which are defined as multitoken, see [member CommandLineOption.multitoken]. + + + + + + + Returns the option at index position [code]index[/code]. Negative indices can be used to count from the back, like in Python (-1 is the last element, -2 the second to last, etc.). + + + + + + Returns the number of options in the parser. + + + + + + + Returns first used prefix for [code]option[/code]. Example: for parsed [code]--help[/code] will return [code]--[/code]. Convenient helper for [method get_prefix_list]. + + + + + + + Returns all used prefixes for [code]option[/code]. + + + + + + + Returns first specified value for [code]option[/code]. Example: for parsed [code]--input filename.png[/code] will return [code]filename.png[/code]. Convenient helper for [method get_value_list]. + + + + + + + Returns all specified values for [code]option[/code]. + + + + + + + Returns [code]true[/code] if [code]option[/code] was specified. If the option does not accept any arguments ([member CommandLineOption.arg_count] is equal to [code]0[/code]), this is the only way to check whether the option was specified on the command-line. + + + + + + + Parses command-line [code]args[/code]. Returns [constant OK] upon success, or [constant ERR_PARSE_ERROR] upon failure. In case of user error, refer to [method get_error_text] which returns human-readable error message which can be printed to a user. + The method can accept command-line arguments returned by [method OS.get_cmdline_args]. + [b]Note[/b]: this method accepts a list of strings. Do not attempt to parse a single string containing command-line arguments with this method. You should split the string into components prior to parsing using [method String.split], if needed. + + + + + + + Removes option at specified [code]index[/code]. + + + + + + + + Replaces option at specified [code]index[/code] with another [code]option[/code]. + + + + + + If [code]true[/code], values for options can delimited by [code]=[/code] sign. Example: [code]--input=filename.png[/code]. + + + If [code]true[/code], short options can be specified without a space. Example: [code]-aux[/code] will be equivalent to [code]-a -u -x[/code]. + + + If [code]true[/code], all arguments after [code]--[/code] will be treated as forwarding arguments and will be available using the [method get_forwarding_args]. Such arguments won't be parsed as options. + + + If [code]true[/code], values for short options can be specified without a space. Example: [code]-ifilename.png[/code]. + + + A list of prefixes after which an argument will be considered a long option. If two or more prefixes share the same set of characters (e.g. [code]--[/code] and [code]--no-[/code]), longest prefixes have higher precedence for parsing. For instance, [code]--no-debug[/code] won't be parsed as a separate [code]no-debug[/code] option, but as [code]debug[/code]. + + + A list of prefixes after which an argument will be considered a short option. + + + If a user entered a wrong option, the parser will suggest the most similar one in the error text returned by [method get_error_text]. If the most similar option found has a similarity value lower than the specified one, then it won't be suggested. Similarity is determined by [method String.similarity]. + + + + + diff --git a/goost.h b/goost.h index 590151fa..b0d01b50 100644 --- a/goost.h +++ b/goost.h @@ -1,5 +1,6 @@ #pragma once +#include "core/command_line_parser.h" #include "core/goost_engine.h" #include "core/image/goost_image.h" #include "core/image/goost_image_bind.h" diff --git a/goost.py b/goost.py index 600dfeb2..e7d708c3 100644 --- a/goost.py +++ b/goost.py @@ -147,6 +147,9 @@ def add_depencency(self, goost_class): # Only rightmost child components are specified. classes = { "EditorVCSInterfaceGit": "vcs", # modules/git + "CommandLineHelpFormat": "core", + "CommandLineOption": "core", + "CommandLineParser": "core", "GoostEngine": "core", "GoostGeometry2D": "geometry", "GoostImage": "image", @@ -203,6 +206,7 @@ def add_depencency(self, goost_class): # compile/link errors or failing unit tests, it's likely a dependency issue. # If so, define them here explicitly so that they're automatically enabled. class_dependencies = { + "CommandLineParser": ["CommandLineOption", "CommandLineHelpFormat"], "GoostEngine" : "InvokeState", "GoostGeometry2D" : ["PolyBoolean2D", "PolyDecomp2D", "PolyOffset2D"], "LightTexture" : "GradientTexture2D", diff --git a/tests/project/goost/core/test_command_line_parser.gd b/tests/project/goost/core/test_command_line_parser.gd new file mode 100644 index 00000000..c340484e --- /dev/null +++ b/tests/project/goost/core/test_command_line_parser.gd @@ -0,0 +1,363 @@ +extends "res://addons/gut/test.gd" + +var cmd: CommandLineParser +var opt: CommandLineOption + +func before_each(): + cmd = CommandLineParser.new() + + +func test_parse(): + var input: CommandLineOption = cmd.add_option("input", "Path to a file.", "file.txt") + input.add_name("i") # Add short name, if needed. + print(cmd.get_help_text()) + + watch_signals(input) + + # Parse with long prefix. + var err = cmd.parse(["--input", "my_file.txt"]) + if err: + print(cmd.get_error_text()) + assert_eq(err, OK) + + assert_signal_emitted(input, "parsed") + + var i = cmd.find_option("input") + assert_eq(input, i) + + assert_eq(cmd.get_value(i), "my_file.txt") + + # Parse with short prefix. + assert_eq(cmd.parse(["-i", "my_file.txt"]), OK) + + +func test_parse_multiple_options(): + var input = cmd.add_option("input", "Path to a file.") + var verbose = cmd.add_option("verbose", "Run in verbose mode.", "no", ["yes", "no"]) + var debug = cmd.add_option("debug", "Run in debug mode.", "yes", ["yes", "no"]) + + assert_true(input.default_args.empty()) + assert_true("no" in verbose.default_args) + assert_true("yes" in debug.default_args) + + var err = cmd.parse(["--input", "path", "--debug", "no", "--verbose", "yes"]) + if err: + print(cmd.get_error_text()) + assert_eq(err, OK) + + assert_eq(cmd.get_value(input), "path") + assert_eq(cmd.get_value(verbose), "yes") + assert_eq(cmd.get_value(debug), "no") + + Engine.print_error_messages = false + + err = cmd.parse(["--input", "path", "--debug", "nono", "--verbose", "yeess"]) + assert_eq(err, ERR_PARSE_ERROR, "Should detect invalid arguments.") + + Engine.print_error_messages = true + + +func test_same_options(): + var _opt + _opt = cmd.add_option("verbose") + _opt = cmd.add_option("verbose") + + Engine.print_error_messages = false + + var err = cmd.parse(["--verbose=yes"]) + assert_eq(err, ERR_PARSE_ERROR) + + Engine.print_error_messages = true + + +func test_same_options_multitoken(): + var k = cmd.add_option("k") + k.multitoken = true + + var err = cmd.parse(["-k -k -k -k"]) + assert_eq(err, OK) + + +func test_long_prefixes_precedence(): + var debug = cmd.add_option("debug") + debug.arg_count = 0 + + cmd.long_prefixes = ["--no-", "--"] + + assert_eq(cmd.parse(["--debug"]), OK) + assert_true(cmd.is_set(debug)) + assert_eq(cmd.get_prefix(debug), "--") + + assert_eq(cmd.parse(["--no-debug"]), OK) + assert_true(cmd.is_set(debug)) + assert_eq(cmd.get_prefix(debug), "--no-") + + cmd.long_prefixes = ["--", "--no-"] # Swap precedence. + + assert_eq(cmd.parse(["--debug"]), OK) + assert_true(cmd.is_set(debug)) + assert_eq(cmd.get_prefix(debug), "--") + + assert_eq(cmd.parse(["--no-debug"]), OK, + "Precedence of `--` and `--no-` prefixes should not matter.") + assert_true(cmd.is_set(debug)) + + assert_eq(cmd.parse([]), OK) + assert_false(cmd.is_set(debug)) + + +func test_prefix_and_names_collision(): + var debug = cmd.add_option("debug") + debug.arg_count = 0 + + cmd.long_prefixes = ["--debug"] + assert_eq(cmd.parse(["--debug"]), ERR_PARSE_ERROR, + "Option should not be recognized: prefix and name are the same.") + + cmd.long_prefixes = ["++"] + assert_eq(cmd.parse(["++"]), ERR_PARSE_ERROR, + "Option should not be recognized: prefix and name are the same.") + + +func test_default_not_allowed_arg(): + var filetype = cmd.add_option("filetype", "Input to file") + filetype.add_default_arg("png") + filetype.add_allowed_arg("jpg") + + Engine.print_error_messages = false + + assert_eq(cmd.parse([]), ERR_PARSE_ERROR, "Default argument `png` is not allowed one, should fail.") + assert_eq(cmd.get_value(filetype), "") + + Engine.print_error_messages = true + + +func test_unrecognized_options(): + var _input = cmd.add_option("input", "Input to file", "file.txt") + + Engine.print_error_messages = false + + assert_eq(cmd.parse(["--debug"]), ERR_PARSE_ERROR, "Parsing unknown options is not supported.") + + Engine.print_error_messages = true + + +func test_get_help_with_required_option(): + var help = cmd.add_help_option() + assert_true(help.meta) + var version = cmd.add_version_option() + assert_true(version.meta) + + var input = cmd.add_option("input", "Input to file") + input.required = true + + assert_eq(cmd.parse(["--help"]), OK, + "Parsing with required option should not fail if `help` option is found.") + + assert_eq(cmd.parse(["--version"]), OK, + "Parsing with required option should not fail if `version` option is found.") + + +func test_several_positional_options(): + var opts = [] + for i in 10: + var opt_pos = cmd.add_option("input-%s" % i) + opt_pos.positional = true + opts.append(opt_pos) + + var args = [] + for i in 10: + args.append("%s.txt" % i) + + print(cmd.get_help_text()) + assert_eq(cmd.parse(args), OK) + + for i in 10: + assert_eq(cmd.get_value(opts[i]), "%s.txt" % i) + + assert_eq(cmd.parse(["0.txt", "1.txt", "--input-2", "2.txt"]), OK) + assert_eq(cmd.get_value(opts[2]), "2.txt") + + +func add_test_option(arg_count): + opt = CommandLineOption.new() + opt.names = ["i", "input"] + opt.arg_count = arg_count + cmd.append_option(opt) + + +func test_builtin_options(): + var o: CommandLineOption + + o = cmd.add_help_option() + assert_eq(cmd.get_option_count(), 1) + assert_eq(cmd.get_option(0), o) + + o = cmd.add_version_option() + assert_eq(cmd.get_option_count(), 2) + assert_eq(cmd.get_option(1), o) + + assert_eq(cmd.parse([]), OK) + + +func test_options(): + add_test_option(1) + + var count = cmd.get_option_count() + assert_eq(count, 1) + + cmd.remove_option(0) + assert_eq(cmd.get_option_count(), count - 1) + + cmd.append_option(opt) + assert_eq(cmd.get_option_count(), count) + + assert_eq(cmd.find_option(opt.names[0]), opt, "Should find option by existing name/alias.") + assert_eq(cmd.find_option(opt.names[1]), opt, "Should find option by existing name/alias.") + + +func test_validation(): + add_test_option(1) + + opt.default_args = ["a"] + + assert_eq(cmd.parse([]), OK) + + Engine.print_error_messages = false + + var arg_count = opt.arg_count + opt.arg_count = arg_count + 1 + assert_eq(cmd.parse([]), ERR_PARSE_ERROR, "Requires more args, but only one default value was given.") + opt.arg_count = arg_count + + var names = opt.names + opt.names = [] + assert_eq(cmd.parse([]), ERR_PARSE_ERROR, "No names, should fail.") + opt.names = names + + var default_args = opt.default_args + opt.default_args = ["a", "b"] + assert_eq(cmd.parse([]), ERR_PARSE_ERROR, "Different number of arguments and default arguments, should fail.") + opt.default_args = default_args + + var required = opt.required + opt.required = not required + assert_eq(cmd.parse([]), ERR_PARSE_ERROR, "Required and have default arguments, should fail.") + opt.required = required + + cmd.append_option(opt) + assert_eq(cmd.parse([]), ERR_PARSE_ERROR, "Same name, should fail.") + cmd.remove_option(1) + + Engine.print_error_messages = true + + assert_eq(cmd.parse([]), OK, + "Parsing with a valid option (after reverting all changes) should be successful.") + + +func test_forwarding_args(): + assert_eq(cmd.parse(["--", "arg1", "arg2"]), ERR_PARSE_ERROR, + "Parsing forwarded arguments should fail if disabled.") + + cmd.allow_forwarding_args = true + assert_eq(cmd.parse(["--"]), OK, "Forwarding zero arguments should succeed.") + assert_true(cmd.get_forwarding_args().empty()) + + assert_eq(cmd.parse(["--", "arg1", "arg2"]), OK, + "Forwarding two arguments should succeed with `allow_forwarding_args = true`") + assert_eq(cmd.get_forwarding_args(), PoolStringArray(["arg1", "arg2"])) + + +func test_short_options(): + add_test_option(0) + + assert_eq(cmd.parse(["-u"]), ERR_PARSE_ERROR, "Unknown option, should fail.") + assert_eq(cmd.parse(["-i", "-i"]), ERR_PARSE_ERROR, "Same options, should fail.") + + var new_opt = CommandLineOption.new() + new_opt.names = ["a"] + new_opt.arg_count = 0 + cmd.append_option(new_opt) + + assert_eq(cmd.parse(["-ai"]), OK, "Two compound options, should succeed.") + assert_eq(cmd.parse(["-ia"]), OK, "Two compound options, should succeed") + assert_true(cmd.is_set(opt), "Compound, should succeed") + assert_true(cmd.is_set(new_opt), "Compound, should succeed") + + cmd.allow_compound = false + + assert_eq(cmd.parse(["-ai"]), ERR_PARSE_ERROR, "Should fail, not allowed if `allow_compound = false`.") + + +func test_short_option_not_short(): + var new_opt = CommandLineOption.new() + new_opt.names = ["aaaaa", "a", "aaaa"] + new_opt.arg_count = 0 + cmd.append_option(new_opt) + + assert_eq(cmd.parse(["-aaaa"]), ERR_PARSE_ERROR) + + +func test_long_options(): + var file = cmd.add_option("file") + file.arg_count = 0 + + assert_eq(cmd.parse(["--test"]), ERR_PARSE_ERROR, "Unknown option, should fail.") + assert_eq(cmd.parse(["--file", "--file"]), ERR_PARSE_ERROR, "Same options, should fail.") + assert_eq(cmd.parse(["--file", "value"]), ERR_PARSE_ERROR, "Option should not accept arguments.") + + +func test_one_arg(): + add_test_option(1) + + var new_opt = CommandLineOption.new() + new_opt.names = ["a"] + new_opt.arg_count = 0 + cmd.append_option(new_opt) + + assert_eq(cmd.parse(["-ai", "arg"]), OK, "Compound, should succeed.") + assert_eq(cmd.get_value(opt), "arg") + + assert_true(cmd.is_set(new_opt)) + + Engine.print_error_messages = false + assert_eq(cmd.get_value(new_opt), "", "Option should not accept arguments.") + Engine.print_error_messages = true + + cmd.allow_compound = false + assert_eq(cmd.parse(["-ai", "arg"]), ERR_PARSE_ERROR, "Compound options should fail if `allow_compound = false`.") + + assert_eq(cmd.parse(["-iarg"]), OK, "Sticky, should succeed.") + + cmd.allow_sticky = false + assert_eq(cmd.parse(["-iarg"]), ERR_PARSE_ERROR, "Compound options should fail if `allow_sticky = false`.") + + +func test_positional(): + add_test_option(-1) # Any number of values. + opt.positional = true + + var sum = cmd.add_option("sum") + sum.arg_count = 0 + + assert_eq(cmd.parse(["1", "2", "3"]), OK) + var values = cmd.get_value_list(opt) + assert_eq(values.size(), 3) + assert_eq(values[0], "1") + assert_eq(values[1], "2") + assert_eq(values[2], "3") + assert_false(cmd.is_set(sum)) + + assert_eq(cmd.parse(["--input", "1", "2", "3", "--sum"]), OK) + values = cmd.get_value_list(opt) + assert_eq(values.size(), 3) + assert_eq(values[0], "1") + assert_eq(values[1], "2") + assert_eq(values[2], "3") + assert_true(cmd.is_set(sum)) + + if cmd.is_set(sum): + var total = 0 + for v in values: + total += str2var(v) + assert_eq(total, 6)