diff --git a/README.md b/README.md index 066fd1f3..b1a353ea 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ An acceptable CLI parser library should be all of the following: - Easy to execute, with help, parse errors, etc. providing correct exit and details. - Easy to extend as part of a framework that provides "applications" to users. - Usable subcommand syntax, with support for multiple subcommands, nested subcommands, option groups, and optional fallthrough (explained later). -- Ability to add a configuration file (`ini` format), and produce it as well. +- Ability to add a configuration file (`ini` or `TOML`🚧 format), and produce it as well. - Produce real values that can be used directly in code, not something you have pay compute time to look up, for HPC applications. - Work with standard types, simple custom types, and extensible to exotic types. - Permissively licensed. @@ -411,7 +411,7 @@ will produce a check for a number less than or equal to 0. ##### Transforming Validators There are a few built in Validators that let you transform values if used with the `transform` function. If they also do some checks then they can be used `check` but some may do nothing in that case. - 🆕 `CLI::Bounded(min,max)` will bound values between min and max and values outside of that range are limited to min or max, it will fail if the value cannot be converted and produce a `ValidationError` -- 🆕 The `IsMember` Validator lets you specify a set of predefined options. You can pass any container or copyable pointer (including `std::shared_ptr`) to a container to this validator; the container just needs to be iterable and have a `::value_type`. The key type should be convertible from a string, You can use an initializer list directly if you like. If you need to modify the set later, the pointer form lets you do that; the type message and check will correctly refer to the current version of the set. The container passed in can be a set, vector, or a map like structure. If used in the `transform` method the output value will be the matching key as it could be modified by filters. +- 🆕 The `IsMember` Validator lets you specify a set of predefined options. You can pass any container or copyable pointer (including `std::shared_ptr`) to a container to this Validator; the container just needs to be iterable and have a `::value_type`. The key type should be convertible from a string, You can use an initializer list directly if you like. If you need to modify the set later, the pointer form lets you do that; the type message and check will correctly refer to the current version of the set. The container passed in can be a set, vector, or a map like structure. If used in the `transform` method the output value will be the matching key as it could be modified by filters. After specifying a set of options, you can also specify "filter" functions of the form `T(T)`, where `T` is the type of the values. The most common choices probably will be `CLI::ignore_case` an `CLI::ignore_underscore`, and `CLI::ignore_space`. These all work on strings but it is possible to define functions that work on other types. Here are some examples of `IsMember`: @@ -532,6 +532,7 @@ There are several options that are supported on the main app and subcommands and - `.ignore_underscore()`: Ignore any underscores in the subcommand name. Inherited by added subcommands, so is usually used on the main `App`. - `.allow_windows_style_options()`: Allow command line options to be parsed in the form of `/s /long /file:file_name.ext` This option does not change how options are specified in the `add_option` calls or the ability to process options in the form of `-s --long --file=file_name.ext`. - `.fallthrough()`: Allow extra unmatched options and positionals to "fall through" and be matched on a parent command. Subcommands always are allowed to fall through. +- `.configurable()`: 🚧 Allow the subcommand to be triggered from a configuration file. - `.disable()`: 🆕 Specify that the subcommand is disabled, if given with a bool value it will enable or disable the subcommand or option group. - `.disabled_by_default()`: 🆕 Specify that at the start of parsing the subcommand/option_group should be disabled. This is useful for allowing some Subcommands to trigger others. - `.enabled_by_default()`: 🆕 Specify that at the start of each parse the subcommand/option_group should be enabled. This is useful for allowing some Subcommands to disable others. @@ -689,7 +690,7 @@ app.set_config(option_name="", required=false) ``` -If this is called with no arguments, it will remove the configuration file option (like `set_help_flag`). Setting a configuration option is special. If it is present, it will be read along with the normal command line arguments. The file will be read if it exists, and does not throw an error unless `required` is `true`. Configuration files are in `ini` format by default (other formats can be added by an adept user). An example of a file: +If this is called with no arguments, it will remove the configuration file option (like `set_help_flag`). Setting a configuration option is special. If it is present, it will be read along with the normal command line arguments. The file will be read if it exists, and does not throw an error unless `required` is `true`. Configuration files are in `ini` format by default, The reader can also accept many files in [TOML] format 🚧. (other formats can be added by an adept user, some variations are available through customization points in the default formatter). An example of a file: ```ini ; Comments are supported, using a ; @@ -705,11 +706,26 @@ str_vector = "one" "two" "and three" in_subcommand = Wow sub.subcommand = true ``` + or equivalently in TOML 🚧 +```toml +# Comments are supported, using a # +# The default section is [default], case insensitive -Spaces before and after the name and argument are ignored. Multiple arguments are separated by spaces. One set of quotes will be removed, preserving spaces (the same way the command line works). Boolean options can be `true`, `on`, `1`, `yes`, 🆕 `enable`; or `false`, `off`, `0`, `no`, 🆕 `disable` (case insensitive). Sections (and `.` separated names) are treated as subcommands (note: this does not mean that subcommand was passed, it just sets the "defaults". You cannot set positional-only arguments or force subcommands to be present in the command line. +value = 1 +str = "A string" +vector = [1,2,3] +str_vector = ["one","two","and three"] + +# Sections map to subcommands +[subcommand] +in_subcommand = Wow +sub.subcommand = true +``` + +Spaces before and after the name and argument are ignored. Multiple arguments are separated by spaces. One set of quotes will be removed, preserving spaces (the same way the command line works). Boolean options can be `true`, `on`, `1`, `yes`, 🆕 `enable`; or `false`, `off`, `0`, `no`, 🆕 `disable` (case insensitive). Sections (and `.` separated names) are treated as subcommands (note: this does not necessarily mean that subcommand was passed, it just sets the "defaults"). You cannot set positional-only arguments. 🚧 Subcommands can be triggered from config files if the `configurable` flag was set on the subcommand. Then use `[subcommand]` notation will trigger a subcommand and cause it to act as if it were on the command line. To print a configuration file from the passed -arguments, use `.config_to_str(default_also=false, prefix="", write_description=false)`, where `default_also` will also show any defaulted arguments, `prefix` will add a prefix, and `write_description` will include option descriptions. +arguments, use `.config_to_str(default_also=false, prefix="", write_description=false)`, where `default_also` will also show any defaulted arguments, `prefix` will add a prefix, and `write_description` will include option descriptions. See [Config files](https://cliutils.github.io/CLI11/book/chapters/config.html) for some additional details. ### Inheriting defaults @@ -917,7 +933,7 @@ CLI11 was developed at the [University of Cincinnati][] to support of the [GooFi [doi-badge]: https://zenodo.org/badge/80064252.svg [doi-link]: https://zenodo.org/badge/latestdoi/80064252 [azure-badge]: https://dev.azure.com/CLIUtils/CLI11/_apis/build/status/CLIUtils.CLI11?branchName=master -[azure]: https://dev.azure.com/CLIUtils/CLI11/_build/|latest?definitionId=1&branchName=master +[azure]: https://dev.azure.com/CLIUtils/CLI11/_build/latest?definitionId=1&branchName=master [travis-badge]: https://img.shields.io/travis/CLIUtils/CLI11/master.svg?label=Linux/macOS [travis]: https://travis-ci.org/CLIUtils/CLI11 [appveyor-badge]: https://img.shields.io/appveyor/ci/HenrySchreiner/cli11/master.svg?label=Windows diff --git a/book/chapters/config.md b/book/chapters/config.md index 85c5b187..cfd570ac 100644 --- a/book/chapters/config.md +++ b/book/chapters/config.md @@ -2,15 +2,49 @@ ## Reading a configure file -You can tell your app to allow configure files with `set_config("--config")`. There are arguments: the first is the option name. If empty, it will clear the config flag. The second item is the default file name. If that is specified, the config will try to read that file. The third item is the help string, with a reasonable default, and the final argument is a boolean (default: false) that indicates that the configuration file is required and an error will be thrown if the file -is not found and this is set to true. +You can tell your app to allow configure files with `set_config("--config")`. There are arguments: the first is the option name. If empty, it will clear the config flag. The second item is the default file name. If that is specified, the config will try to read that file. The third item is the help string, with a reasonable default, and the final argument is a boolean (default: false) that indicates that the configuration file is required and an error will be thrown if the file is not found and this is set to true. + +### Extra fields +Sometimes configuration files are used for multiple purposes so CLI11 allows options on how to deal with extra fields + +```cpp +app.allow_config_extras(true); +``` +will allow capture the extras in the extras field of the app. (NOTE: This also sets the `allow_extras` in the app to true) + +```cpp +app.allow_config_extras(false); +``` +will generate an error if there are any extra fields + +for slightly finer control there is a scoped enumeration of the modes +or +```cpp +app.allow_config_extras(CLI::config_extras_mode::ignore); +``` +will completely ignore extra parameters in the config file. This mode is the default. + +```cpp +app.allow_config_extras(CLI::config_extras_mode::capture); +``` +will store the unrecognized options in the app extras fields. This option is the closest equivalent to `app.allow_config_extras(true);` with the exception that it does not also set the `allow_extras` flag so using this option without also setting `allow_extras(true)` will generate an error which may or may not be the desired behavior. + +```cpp +app.allow_config_extras(CLI::config_extras_mode::error); +``` +is equivalent to `app.allow_config_extras(false);` + +### Getting the used configuration file name +If it is needed to get the configuration file name used this can be obtained via +`app.get_config_ptr()->as()` or +`app["--config"]->as()` assuming `--config` was the configuration option name. ## Configure file format Here is an example configuration file, in INI format: ```ini -; Commments are supported, using a ; +; Comments are supported, using a ; ; The default section is [default], case insensitive value = 1 @@ -23,12 +57,66 @@ in_subcommand = Wow sub.subcommand = true ``` -Spaces before and after the name and argument are ignored. Multiple arguments are separated by spaces. One set of quotes will be removed, preserving spaces (the same way the command line works). Boolean options can be `true`, `on`, `1`, `yes`; or `false`, `off`, `0`, `no` (case insensitive). Sections (and `.` separated names) are treated as subcommands (note: this does not mean that subcommand was passed, it just sets the "defaults". +Spaces before and after the name and argument are ignored. Multiple arguments are separated by spaces. One set of quotes will be removed, preserving spaces (the same way the command line works). Boolean options can be `true`, `on`, `1`, `y`, `t`, `+`, `yes`, `enable`; or `false`, `off`, `0`, `no`, `n`, `f`, `-`, `disable`, (case insensitive). Sections (and `.` separated names) are treated as subcommands (note: this does not necessarily mean that subcommand was passed, it just sets the "defaults". If a subcommand is set to `configurable` then passing the subcommand using `[sub]` in a configuration file will trigger the subcommand.) + +CLI11 also supports configuration file in [TOML](https://github.com/toml-lang/toml) format. + +```toml +# Comments are supported, using a # +# The default section is [default], case insensitive + +value = 1 +str = "A string" +vector = [1,2,3] + +# Section map to subcommands +[subcommand] +in_subcommand = Wow +[subcommand.sub] +subcommand = true # could also be give as sub.subcommand=true +``` + +The main differences are in vector notation and comment character. Note: CLI11 is not a full TOML parser as it just reads values as strings. It is possible (but not recommended) to mix notation. ## Writing out a configure file To print a configuration file from the passed arguments, use `.config_to_str(default_also=false, prefix="", write_description=false)`, where `default_also` will also show any defaulted arguments, `prefix` will add a prefix, and `write_description` will include option descriptions. +### Customization of configure file output +The default config parser/generator has some customization points that allow variations on the INI format. The default formatter has a base configuration that matches the INI format. It defines 5 characters that define how different aspects of the configuration are handled +```cpp +/// the character used for comments +char commentChar = ';'; +/// the character used to start an array '\0' is a default to not use +char arrayStart = '\0'; +/// the character used to end an array '\0' is a default to not use +char arrayEnd = '\0'; +/// the character used to separate elements in an array +char arraySeparator = ' '; +/// the character used separate the name from the value +char valueDelimiter = '='; +``` + +These can be modified via setter functions + +- ` ConfigBase *comment(char cchar)` Specify the character to start a comment block +- `ConfigBase *arrayBounds(char aStart, char aEnd)` Specify the start and end characters for an array +- `ConfigBase *arrayDelimiter(char aSep)` Specify the delimiter character for an array +- `ConfigBase *valueSeparator(char vSep)` Specify the delimiter between a name and value + +For example to specify reading a configure file that used `:` to separate name and values + +```cpp +auto config_base=app.get_config_formatter_base(); +config_base->valueSeparator(':'); +``` + +The default configuration file will read TOML files, but will write out files in the INI format. To specify outputting TOML formatted files use +```cpp +app.config_formatter(std::make_shared()); +``` +which makes use of a predefined modification of the ConfigBase class which INI also uses. + ## Custom formats {% hint style='info' %} @@ -51,3 +139,9 @@ app.config_formatter(std::make_shared()); ``` See [`examples/json.cpp`](https://github.com/CLIUtils/CLI11/blob/master/examples/json.cpp) for a complete JSON config example. + + +## Triggering Subcommands +Configuration files can be used to trigger subcommands if a subcommand is set to configure. By default configuration file just set the default values of a subcommand. But if the `configure()` option is set on a subcommand then the if the subcommand is utilized via a `[subname]` block in the configuration file it will act as if it were called from the command line. Subsubcommands can be triggered via [subname.subsubname]. Using the `[[subname]]` will be as if the subcommand were triggered multiple times from the command line. This functionality can allow the configuration file to act as a scripting file. + +For custom configuration files this behavior can be triggered by specifying the parent subcommands in the structure and `++` as the name to open a new subcommand scope and `--` to close it. These names trigger the different callbacks of configurable subcommands. diff --git a/book/chapters/options.md b/book/chapters/options.md index 70b84c67..3bb2ff0d 100644 --- a/book/chapters/options.md +++ b/book/chapters/options.md @@ -126,7 +126,7 @@ app.add_flag("--CaSeLeSs"); app.get_group() // is "Required" ``` -Groups are mostly for visual organisation, but an empty string for a group name will hide the option. +Groups are mostly for visual organization, but an empty string for a group name will hide the option. ## Listing of specialty options: diff --git a/include/CLI/App.hpp b/include/CLI/App.hpp index 3871dbc5..fe93c39d 100644 --- a/include/CLI/App.hpp +++ b/include/CLI/App.hpp @@ -45,6 +45,10 @@ std::string simple(const App *app, const Error &e); std::string help(const App *app, const Error &e); } // namespace FailureMessage +/// enumeration of modes of how to deal with extras in config files + +enum class config_extras_mode : char { error = 0, ignore, capture }; + class App; using App_p = std::shared_ptr; @@ -73,8 +77,9 @@ class App { /// If true, allow extra arguments (ie, don't throw an error). INHERITABLE bool allow_extras_{false}; - /// If true, allow extra arguments in the ini file (ie, don't throw an error). INHERITABLE - bool allow_config_extras_{false}; + /// If ignore, allow extra arguments in the ini file (ie, don't throw an error). INHERITABLE + /// if error error on an extra argument, and if capture feed it to the app + config_extras_mode allow_config_extras_{config_extras_mode::ignore}; /// If true, return immediately on an unrecognized option (implies allow_extras) INHERITABLE bool prefix_command_{false}; @@ -194,12 +199,17 @@ class App { /// specify that positional arguments come at the end of the argument sequence not inheritable bool positionals_at_end_{false}; - /// If set to true the subcommand will start each parse disabled - bool disabled_by_default_{false}; - /// If set to true the subcommand will be reenabled at the start of each parse - bool enabled_by_default_{false}; + enum class startup_mode : char { stable, enabled, disabled }; + /// specify the startup mode for the app + /// stable=no change, enabled= startup enabled, disabled=startup disabled + startup_mode default_startup{startup_mode::stable}; + + /// if set to true the subcommand can be triggered via configuration files INHERITABLE + bool configurable_{false}; + /// If set to true positional options are validated before assigning INHERITABLE bool validate_positionals_{false}; + /// A pointer to the parent if this is a subcommand App *parent_{nullptr}; @@ -228,12 +238,6 @@ class App { /// @name Config ///@{ - /// The name of the connected config file - std::string config_name_{}; - - /// True if ini is required (throws if not present), if false simply keep going. - bool config_required_{false}; - /// Pointer to the config option Option *config_ptr_{nullptr}; @@ -266,6 +270,7 @@ class App { ignore_underscore_ = parent_->ignore_underscore_; fallthrough_ = parent_->fallthrough_; validate_positionals_ = parent_->validate_positionals_; + configurable_ = parent_->configurable_; allow_windows_style_options_ = parent_->allow_windows_style_options_; group_ = parent_->group_; footer_ = parent_->footer_; @@ -385,14 +390,23 @@ class App { /// Set the subcommand to be disabled by default, so on clear(), at the start of each parse it is disabled App *disabled_by_default(bool disable = true) { - disabled_by_default_ = disable; + if(disable) { + default_startup = startup_mode::disabled; + } else { + default_startup = (default_startup == startup_mode::enabled) ? startup_mode::enabled : startup_mode::stable; + } return this; } /// Set the subcommand to be enabled by default, so on clear(), at the start of each parse it is enabled (not /// disabled) App *enabled_by_default(bool enable = true) { - enabled_by_default_ = enable; + if(enable) { + default_startup = startup_mode::enabled; + } else { + default_startup = + (default_startup == startup_mode::disabled) ? startup_mode::disabled : startup_mode::stable; + } return this; } @@ -415,11 +429,20 @@ class App { return this; } - /// Remove the error when extras are left over on the command line. - /// Will also call App::allow_extras(). + /// ignore extras in config files App *allow_config_extras(bool allow = true) { - allow_extras(allow); - allow_config_extras_ = allow; + if(allow) { + allow_config_extras_ = config_extras_mode::capture; + allow_extras_ = true; + } else { + allow_config_extras_ = config_extras_mode::error; + } + return this; + } + + /// ignore extras in config files + App *allow_config_extras(config_extras_mode mode) { + allow_config_extras_ = mode; return this; } @@ -457,6 +480,12 @@ class App { return this; } + /// Specify that the subcommand can be triggered by a config file + App *configurable(bool value = true) { + configurable_ = value; + return this; + } + /// Ignore underscore. Subcommands inherit value. App *ignore_underscore(bool value = true) { if(value && !ignore_underscore_) { @@ -889,22 +918,24 @@ class App { /// Set a configuration ini file option, or clear it if no name passed Option *set_config(std::string option_name = "", std::string default_filename = "", - std::string help_message = "Read an ini file", + const std::string &help_message = "Read an ini file", bool config_required = false) { // Remove existing config if present if(config_ptr_ != nullptr) { remove_option(config_ptr_); - config_name_ = ""; - config_required_ = false; // Not really needed, but complete - config_ptr_ = nullptr; // need to remove the config_ptr completely + config_ptr_ = nullptr; // need to remove the config_ptr completely } // Only add config if option passed if(!option_name.empty()) { - config_name_ = default_filename; - config_required_ = config_required; - config_ptr_ = add_option(option_name, config_name_, help_message, !default_filename.empty()); + config_ptr_ = add_option(option_name, help_message); + if(config_required) { + config_ptr_->required(); + } + if(!default_filename.empty()) { + config_ptr_->default_str(std::move(default_filename)); + } config_ptr_->configurable(false); } @@ -989,12 +1020,12 @@ class App { } /// Check to see if a subcommand is part of this command (doesn't have to be in command line) /// returns the first subcommand if passed a nullptr - App *get_subcommand(App *subcom) const { + App *get_subcommand(const App *subcom) const { if(subcom == nullptr) throw OptionNotFound("nullptr passed"); for(const App_p &subcomptr : subcommands_) if(subcomptr.get() == subcom) - return subcom; + return subcomptr.get(); throw OptionNotFound(subcom->get_name()); } @@ -1342,7 +1373,7 @@ class App { } /// Check to see if given subcommand was selected - bool got_subcommand(App *subcom) const { + bool got_subcommand(const App *subcom) const { // get subcom needed to verify that this was a real subcommand return get_subcommand(subcom)->parsed_ > 0; } @@ -1482,6 +1513,11 @@ class App { /// Access the config formatter std::shared_ptr get_config_formatter() const { return config_formatter_; } + /// Access the config formatter as a configBase pointer + std::shared_ptr get_config_formatter_base() const { + return std::dynamic_pointer_cast(config_formatter_); + } + /// Get the app or subcommand description std::string get_description() const { return description_; } @@ -1601,6 +1637,9 @@ class App { /// Check the status of the allow windows style options bool get_positionals_at_end() const { return positionals_at_end_; } + /// Check the status of the allow windows style options + bool get_configurable() const { return configurable_; } + /// Get the group of this subcommand const std::string &get_group() const { return group_; } @@ -1635,15 +1674,15 @@ class App { bool get_immediate_callback() const { return immediate_callback_; } /// Get the status of disabled by default - bool get_disabled_by_default() const { return disabled_by_default_; } + bool get_disabled_by_default() const { return (default_startup == startup_mode::disabled); } /// Get the status of disabled by default - bool get_enabled_by_default() const { return enabled_by_default_; } + bool get_enabled_by_default() const { return (default_startup == startup_mode::enabled); } /// Get the status of validating positionals bool get_validate_positionals() const { return validate_positionals_; } /// Get the status of allow extras - bool get_allow_config_extras() const { return allow_config_extras_; } + config_extras_mode get_allow_config_extras() const { return allow_config_extras_; } /// Get a pointer to the help flag. Option *get_help_ptr() { return help_ptr_; } @@ -1823,11 +1862,10 @@ class App { /// set the correct fallthrough and prefix for nameless subcommands and manage the automatic enable or disable /// makes sure parent is set correctly void _configure() { - if(disabled_by_default_) { - disabled_ = true; - } - if(enabled_by_default_) { + if(default_startup == startup_mode::enabled) { disabled_ = false; + } else if(default_startup == startup_mode::disabled) { + disabled_ = true; } for(const App_p &app : subcommands_) { if(app->has_automatic_name_) { @@ -1909,27 +1947,33 @@ class App { // The parse function is now broken into several parts, and part of process - /// Read and process an ini file (main app only) - void _process_ini() { - // Process an INI file + /// Read and process a configuration file (main app only) + void _process_config_file() { if(config_ptr_ != nullptr) { - if(*config_ptr_) { - config_ptr_->run_callback(); - config_required_ = true; + bool config_required = config_ptr_->get_required(); + bool file_given = config_ptr_->count() > 0; + auto config_file = config_ptr_->as(); + if(config_file.empty()) { + if(config_required) { + throw FileError::Missing("no specified config file"); + } + return; } - if(!config_name_.empty()) { + + auto path_result = detail::check_path(config_file.c_str()); + if(path_result == detail::path_type::file) { try { - auto path_result = detail::check_path(config_name_.c_str()); - if(path_result == detail::path_type::file) { - std::vector values = config_formatter_->from_file(config_name_); - _parse_config(values); - } else if(config_required_) { - throw FileError::Missing(config_name_); + std::vector values = config_formatter_->from_file(config_file); + _parse_config(values); + if(!file_given) { + config_ptr_->add_result(config_file); } } catch(const FileError &) { - if(config_required_) + if(config_required || file_given) throw; } + } else if(config_required || file_given) { + throw FileError::Missing(config_file); } } } @@ -2145,7 +2189,7 @@ class App { /// Process callbacks and such. void _process() { - _process_ini(); + _process_config_file(); _process_env(); _process_callbacks(); _process_help_flags(); @@ -2244,7 +2288,7 @@ class App { /// Returns true if it managed to find the option, if false you'll need to remove the arg manually. void _parse_config(std::vector &args) { for(ConfigItem item : args) { - if(!_parse_single_config(item) && !allow_config_extras_) + if(!_parse_single_config(item) && allow_config_extras_ == config_extras_mode::error) throw ConfigError::Extras(item.fullname()); } } @@ -2254,16 +2298,37 @@ class App { if(level < item.parents.size()) { try { auto subcom = get_subcommand(item.parents.at(level)); - return subcom->_parse_single_config(item, level + 1); + auto result = subcom->_parse_single_config(item, level + 1); + + return result; } catch(const OptionNotFound &) { return false; } } - + // check for section open + if(item.name == "++") { + if(configurable_) { + increment_parsed(); + _trigger_pre_parse(2); + if(parent_ != nullptr) { + parent_->parsed_subcommands_.push_back(this); + } + } + return true; + } + // check for section close + if(item.name == "--") { + if(configurable_) { + _process_callbacks(); + _process_requirements(); + run_callback(); + } + return true; + } Option *op = get_option_no_throw("--" + item.name); if(op == nullptr) { // If the option was not present - if(get_allow_config_extras()) + if(get_allow_config_extras() == config_extras_mode::capture) // Should we worry about classifying the extras properly? missing_.emplace_back(detail::Classifier::NONE, item.fullname()); return false; diff --git a/include/CLI/Config.hpp b/include/CLI/Config.hpp index 011cbe9a..ed17e7eb 100644 --- a/include/CLI/Config.hpp +++ b/include/CLI/Config.hpp @@ -14,58 +14,325 @@ namespace CLI { -inline std::string -ConfigINI::to_config(const App *app, bool default_also, bool write_description, std::string prefix) const { - std::stringstream out; - for(const Option *opt : app->get_options({})) { +namespace detail { - // Only process option with a long-name and configurable - if(!opt->get_lnames().empty() && opt->get_configurable()) { - std::string name = prefix + opt->get_lnames()[0]; - std::string value; - - // Non-flags - if(opt->get_expected_min() != 0) { - - // If the option was found on command line - if(opt->count() > 0) - value = detail::ini_join(opt->results()); - - // If the option has a default and is requested by optional argument - else if(default_also && !opt->get_default_str().empty()) - value = opt->get_default_str(); - // Flag, one passed - } else if(opt->count() == 1) { - value = "true"; - - // Flag, multiple passed - } else if(opt->count() > 1) { - value = std::to_string(opt->count()); - - // Flag, not present - } else if(opt->count() == 0 && default_also) { - value = "false"; +inline std::string convert_arg_for_ini(const std::string &arg) { + if(arg.empty()) { + return std::string(2, '"'); + } + // some specifically supported strings + if(arg == "true" || arg == "false" || arg == "nan" || arg == "inf") { + return arg; + } + // floating point conversion can convert some hex codes, but don't try that here + if(arg.compare(0, 2, "0x") != 0 && arg.compare(0, 2, "0X") != 0) { + double val; + if(detail::lexical_cast(arg, val)) { + return arg; + } + } + // just quote a single non numeric character + if(arg.size() == 1) { + return std::string("'") + arg + '\''; + } + // handle hex, binary or octal arguments + if(arg.front() == '0') { + if(arg[1] == 'x') { + if(std::all_of(arg.begin() + 2, arg.end(), [](char x) { + return (x >= '0' && x <= '9') || (x >= 'A' && x <= 'F') || (x >= 'a' && x <= 'f'); + })) { + return arg; } - - if(!value.empty()) { - if(write_description && opt->has_description()) { - if(static_cast(out.tellp()) != 0) { - out << std::endl; - } - out << "; " << detail::fix_newlines("; ", opt->get_description()) << std::endl; - } - - // Don't try to quote anything that is not size 1 - if(opt->get_items_expected_max() != 1) - out << name << "=" << value << std::endl; - else - out << name << "=" << detail::add_quotes_if_needed(value) << std::endl; + } else if(arg[1] == 'o') { + if(std::all_of(arg.begin() + 2, arg.end(), [](char x) { return (x >= '0' && x <= '7'); })) { + return arg; + } + } else if(arg[1] == 'b') { + if(std::all_of(arg.begin() + 2, arg.end(), [](char x) { return (x == '0' || x == '1'); })) { + return arg; } } } + if(arg.find_first_of('"') == std::string::npos) { + return std::string("\"") + arg + '"'; + } else { + return std::string("'") + arg + '\''; + } +} - for(const App *subcom : app->get_subcommands({})) - out << to_config(subcom, default_also, write_description, prefix + subcom->get_name() + "."); +/// Comma separated join, adds quotes if needed +inline std::string +ini_join(const std::vector &args, char sepChar = ',', char arrayStart = '[', char arrayEnd = ']') { + std::string joined; + if(args.size() > 1 && arrayStart != '\0') { + joined.push_back(arrayStart); + } + std::size_t start = 0; + for(const auto &arg : args) { + if(start++ > 0) { + joined.push_back(sepChar); + if(isspace(sepChar) == 0) { + joined.push_back(' '); + } + } + joined.append(convert_arg_for_ini(arg)); + } + if(args.size() > 1 && arrayEnd != '\0') { + joined.push_back(arrayEnd); + } + return joined; +} + +inline std::vector generate_parents(const std::string §ion, std::string &name) { + std::vector parents; + if(detail::to_lower(section) != "default") { + if(section.find('.') != std::string::npos) { + parents = detail::split(section, '.'); + } else { + parents = {section}; + } + } + if(name.find('.') != std::string::npos) { + std::vector plist = detail::split(name, '.'); + name = plist.back(); + detail::remove_quotes(name); + plist.pop_back(); + parents.insert(parents.end(), plist.begin(), plist.end()); + } + + // clean up quotes on the parents + for(auto &parent : parents) { + detail::remove_quotes(parent); + } + return parents; +} + +/// assuming non default segments do a check on the close and open of the segments in a configItem structure +inline void checkParentSegments(std::vector &output, const std::string ¤tSection) { + + std::string estring; + auto parents = detail::generate_parents(currentSection, estring); + if(output.size() > 0 && output.back().name == "--") { + std::size_t msize = (parents.size() > 1U) ? parents.size() : 2; + while(output.back().parents.size() >= msize) { + output.push_back(output.back()); + output.back().parents.pop_back(); + } + + if(parents.size() > 1) { + std::size_t common = 0; + std::size_t mpair = (std::min)(output.back().parents.size(), parents.size() - 1); + for(std::size_t ii = 0; ii < mpair; ++ii) { + if(output.back().parents[ii] != parents[ii]) { + break; + } + ++common; + } + if(common == mpair) { + output.pop_back(); + } else { + while(output.back().parents.size() > common + 1) { + output.push_back(output.back()); + output.back().parents.pop_back(); + } + } + for(std::size_t ii = common; ii < parents.size() - 1; ++ii) { + output.emplace_back(); + output.back().parents.assign(parents.begin(), parents.begin() + static_cast(ii) + 1); + output.back().name = "++"; + } + } + } else if(parents.size() > 1) { + for(std::size_t ii = 0; ii < parents.size() - 1; ++ii) { + output.emplace_back(); + output.back().parents.assign(parents.begin(), parents.begin() + static_cast(ii) + 1); + output.back().name = "++"; + } + } + + // insert a section end which is just an empty items_buffer + output.emplace_back(); + output.back().parents = std::move(parents); + output.back().name = "++"; +} +} // namespace detail + +inline std::vector ConfigBase::from_config(std::istream &input) const { + std::string line; + std::string section = "default"; + + std::vector output; + bool defaultArray = (arrayStart == '\0' || arrayStart == ' ') && arrayStart == arrayEnd; + char aStart = (defaultArray) ? '[' : arrayStart; + char aEnd = (defaultArray) ? ']' : arrayEnd; + char aSep = (defaultArray && arraySeparator == ' ') ? ',' : arraySeparator; + + while(getline(input, line)) { + std::vector items_buffer; + std::string name; + + detail::trim(line); + std::size_t len = line.length(); + if(len > 1 && line.front() == '[' && line.back() == ']') { + if(section != "default") { + // insert a section end which is just an empty items_buffer + output.emplace_back(); + output.back().parents = detail::generate_parents(section, name); + output.back().name = "--"; + } + section = line.substr(1, len - 2); + // deal with double brackets for TOML + if(section.size() > 1 && section.front() == '[' && section.back() == ']') { + section = section.substr(1, section.size() - 2); + } + if(detail::to_lower(section) == "default") { + section = "default"; + } else { + detail::checkParentSegments(output, section); + } + continue; + } + if(len == 0) { + continue; + } + // comment lines + if(line.front() == ';' || line.front() == '#' || line.front() == commentChar) { + continue; + } + + // Find = in string, split and recombine + auto pos = line.find(valueDelimiter); + if(pos != std::string::npos) { + name = detail::trim_copy(line.substr(0, pos)); + std::string item = detail::trim_copy(line.substr(pos + 1)); + if(item.size() > 1 && item.front() == aStart && item.back() == aEnd) { + items_buffer = detail::split_up(item.substr(1, item.length() - 2), aSep); + } else if(defaultArray && item.find_first_of(aSep) != std::string::npos) { + items_buffer = detail::split_up(item, aSep); + } else if(defaultArray && item.find_first_of(' ') != std::string::npos) { + items_buffer = detail::split_up(item); + } else { + items_buffer = {item}; + } + } else { + name = detail::trim_copy(line); + items_buffer = {"true"}; + } + if(name.find('.') == std::string::npos) { + detail::remove_quotes(name); + } + // clean up quotes on the items + for(auto &it : items_buffer) { + detail::remove_quotes(it); + } + + std::vector parents = detail::generate_parents(section, name); + + if(!output.empty() && name == output.back().name && parents == output.back().parents) { + output.back().inputs.insert(output.back().inputs.end(), items_buffer.begin(), items_buffer.end()); + } else { + output.emplace_back(); + output.back().parents = std::move(parents); + output.back().name = std::move(name); + output.back().inputs = std::move(items_buffer); + } + } + if(section != "default") { + // insert a section end which is just an empty items_buffer + std::string ename; + output.emplace_back(); + output.back().parents = detail::generate_parents(section, ename); + output.back().name = "--"; + while(output.back().parents.size() > 1) { + output.push_back(output.back()); + output.back().parents.pop_back(); + } + } + return output; +} + +inline std::string +ConfigBase::to_config(const App *app, bool default_also, bool write_description, std::string prefix) const { + std::stringstream out; + std::string commentLead; + commentLead.push_back(commentChar); + commentLead.push_back(' '); + + std::vector groups = app->get_groups(); + bool defaultUsed = false; + groups.insert(groups.begin(), std::string("Options")); + if(write_description) { + out << commentLead << app->get_description() << '\n'; + } + for(auto &group : groups) { + if(group == "Options" || group.empty()) { + if(defaultUsed) { + continue; + } + defaultUsed = true; + } + if(write_description && group != "Options" && !group.empty()) { + out << '\n' << commentLead << group << " Options\n"; + } + for(const Option *opt : app->get_options({})) { + + // Only process option with a long-name and configurable + if(!opt->get_lnames().empty() && opt->get_configurable()) { + if(opt->get_group() != group) { + if(!(group == "Options" && opt->get_group() == "")) { + continue; + } + } + std::string name = prefix + opt->get_lnames()[0]; + std::string value = detail::ini_join(opt->reduced_results(), arraySeparator, arrayStart, arrayEnd); + + if(value.empty() && default_also) { + if(!opt->get_default_str().empty()) { + value = detail::convert_arg_for_ini(opt->get_default_str()); + } else if(opt->get_expected_min() == 0) { + value = "false"; + } + } + + if(!value.empty()) { + if(write_description && opt->has_description()) { + out << '\n'; + out << commentLead << detail::fix_newlines(commentLead, opt->get_description()) << '\n'; + } + out << name << valueDelimiter << value << '\n'; + } + } + } + } + auto subcommands = app->get_subcommands({}); + for(const App *subcom : subcommands) { + if(subcom->get_name().empty()) { + if(write_description && !subcom->get_group().empty()) { + out << '\n' << commentLead << subcom->get_group() << " Options\n"; + } + out << to_config(subcom, default_also, write_description, prefix); + } + } + + for(const App *subcom : subcommands) + if(!subcom->get_name().empty()) { + if(subcom->get_configurable() && app->got_subcommand(subcom)) { + if(!prefix.empty() || app->get_parent() == nullptr) { + out << '[' << prefix << subcom->get_name() << "]\n"; + } else { + std::string subname = app->get_name() + "." + subcom->get_name(); + auto p = app->get_parent(); + while(p->get_parent() != nullptr) { + subname = p->get_name() + "." + subname; + p = p->get_parent(); + } + out << '[' << subname << "]\n"; + } + out << to_config(subcom, default_also, write_description, ""); + } else { + out << to_config(subcom, default_also, write_description, prefix + subcom->get_name() + "."); + } + } return out.str(); } diff --git a/include/CLI/ConfigFwd.hpp b/include/CLI/ConfigFwd.hpp index 98b693f8..d2c7bc93 100644 --- a/include/CLI/ConfigFwd.hpp +++ b/include/CLI/ConfigFwd.hpp @@ -15,30 +15,6 @@ namespace CLI { class App; -namespace detail { - -/// Comma separated join, adds quotes if needed -inline std::string ini_join(std::vector args) { - std::ostringstream s; - std::size_t start = 0; - for(const auto &arg : args) { - if(start++ > 0) - s << " "; - - auto it = std::find_if(arg.begin(), arg.end(), [](char ch) { return std::isspace(ch, std::locale()); }); - if(it == arg.end()) - s << arg; - else if(arg.find_first_of('\"') == std::string::npos) - s << '\"' << arg << '\"'; - else - s << '\'' << arg << '\''; - } - - return s.str(); -} - -} // namespace detail - /// Holds values to load into Options struct ConfigItem { /// This is the list of parents @@ -91,56 +67,61 @@ class Config { virtual ~Config() = default; }; -/// This converter works with INI files -class ConfigINI : public Config { +/// This converter works with INI/TOML files; to write proper TOML files use ConfigTOML +class ConfigBase : public Config { + protected: + /// the character used for comments + char commentChar = ';'; + /// the character used to start an array '\0' is a default to not use + char arrayStart = '\0'; + /// the character used to end an array '\0' is a default to not use + char arrayEnd = '\0'; + /// the character used to separate elements in an array + char arraySeparator = ' '; + /// the character used separate the name from the value + char valueDelimiter = '='; + public: std::string to_config(const App * /*app*/, bool default_also, bool write_description, std::string prefix) const override; - std::vector from_config(std::istream &input) const override { - std::string line; - std::string section = "default"; - - std::vector output; - - while(getline(input, line)) { - std::vector items_buffer; - - detail::trim(line); - std::size_t len = line.length(); - if(len > 1 && line[0] == '[' && line[len - 1] == ']') { - section = line.substr(1, len - 2); - } else if(len > 0 && line[0] != ';') { - output.emplace_back(); - ConfigItem &out = output.back(); - - // Find = in string, split and recombine - auto pos = line.find('='); - if(pos != std::string::npos) { - out.name = detail::trim_copy(line.substr(0, pos)); - std::string item = detail::trim_copy(line.substr(pos + 1)); - items_buffer = detail::split_up(item); - } else { - out.name = detail::trim_copy(line); - items_buffer = {"ON"}; - } - - if(detail::to_lower(section) != "default") { - out.parents = {section}; - } - - if(out.name.find('.') != std::string::npos) { - std::vector plist = detail::split(out.name, '.'); - out.name = plist.back(); - plist.pop_back(); - out.parents.insert(out.parents.end(), plist.begin(), plist.end()); - } - - out.inputs.insert(std::end(out.inputs), std::begin(items_buffer), std::end(items_buffer)); - } - } - return output; + std::vector from_config(std::istream &input) const override; + /// Specify the configuration for comment characters + ConfigBase *comment(char cchar) { + commentChar = cchar; + return this; + } + /// Specify the start and end characters for an array + ConfigBase *arrayBounds(char aStart, char aEnd) { + arrayStart = aStart; + arrayEnd = aEnd; + return this; + } + /// Specify the delimiter character for an array + ConfigBase *arrayDelimiter(char aSep) { + arraySeparator = aSep; + return this; + } + /// Specify the delimiter between a name and value + ConfigBase *valueSeparator(char vSep) { + valueDelimiter = vSep; + return this; } }; +/// the default Config is the INI file format +using ConfigINI = ConfigBase; + +/// ConfigTOML generates a TOML compliant output +class ConfigTOML : public ConfigINI { + + public: + ConfigTOML() { + commentChar = '#'; + arrayStart = '['; + arrayEnd = ']'; + arraySeparator = ','; + valueDelimiter = '='; + } +}; } // namespace CLI diff --git a/include/CLI/Option.hpp b/include/CLI/Option.hpp index 4a6a49f6..eee91f53 100644 --- a/include/CLI/Option.hpp +++ b/include/CLI/Option.hpp @@ -938,10 +938,12 @@ class Option : public OptionBase