5
\$\begingroup\$

I've written a modern C++17 INI file reader to get access parameters values from an INI configuration file anywhere in a source file by simply included #include "Ini.hpp" in the headers.

The file parsing is done once at first parameter request with a default file path. The parsing is done with REGEX I've written on this purpose.

I may went too far when checking the different number ranges depending on the C++ number type (short, int, long, long long, ...).

Let me know what you think.

Ini.hpp

#include <fstream>
#include <unordered_map>
#include <string>
#include <regex>
#include <cfloat>
#include <climits>

// PARAMETERS_INI_FILE_PATH is a constant defined in the CMake configuration project file

struct Ini
{
    public:
        typedef std::unordered_map<std::string, std::unordered_map<std::string, std::string>> IniStructure;

        Ini() = delete;

        static void parse(const std::string &filePath = PARAMETERS_INI_FILE_PATH);
        template<typename T> static T get(const std::string &section, const std::string &key);

    private:
        static bool keyExists(const std::string &section, const std::string &key);
        static long long extractIntegerNumber(const std::string &section, const std::string &key);
        static long double extractFloatNumber(const std::string &section, const std::string &key);

        // [section][parameter name][parameter value]
        static inline IniStructure values;
        static inline std::string  iniPath;
};

Ini.cpp

#include "Ini.hpp"

// ------------------------------------------------- Static functions ------------------------------------------------ //

/**
 * Parse the INI file located to the given file path and store the values in Ini::values
 *
 * @param filePath - The path of the INI file to parse
 */
void Ini::parse(const std::string &filePath)
{
    std::ifstream fileReader(filePath, std::ifstream::in);
    std::string   fileContent((std::istreambuf_iterator<char>(fileReader)), std::istreambuf_iterator<char>());
    std::regex    sections(R"(\[([^\]\r\n]+)]((?:\r?\n(?:[^[\r\n].*)?)*))");
    std::regex    parameters(R"((\w+) ?= ?(\"([^\"]+)\"|([^\r\n\t\f\v;#]+)))");
    std::smatch   sectionMatch;

    iniPath = filePath;

    if (fileReader.fail()) {
        throw std::runtime_error("The file " + Ini::iniPath + " could not be opened");
    }

    while (regex_search(fileContent, sectionMatch, sections)) {
        std::unordered_map<std::string, std::string> sectionParameters;
        std::string sectionString = sectionMatch[2].str();

        for (std::sregex_iterator parameter(sectionString.begin(), sectionString.end(), parameters); parameter != std::sregex_iterator(); ++parameter) {
            std::smatch parameterMatch                 = *parameter;
            sectionParameters[parameterMatch[1].str()] = parameterMatch[3].matched ? parameterMatch[3].str() : parameterMatch[4].str();
            // parameterMatch[1] is the key, parameterMatch[3] is a trim quoted string (string without double quotes)
            // and parameterMatch[4] is a number
        }

        values[sectionMatch[1].str()] = sectionParameters;
        fileContent = sectionMatch.suffix();
    }
}

/**
 * Tells if the parameter exists in the given section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return True if the parameter exists in the given section at the given key, false otherwise
 */
bool Ini::keyExists(const std::string &section, const std::string &key)
{
    return values.find(section) != values.end() && values[section].find(key) != values[section].end();
}

/**
 * Extract the integer number of the parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested integer number value
 */
long long Ini::extractIntegerNumber(const std::string &section, const std::string &key)
{
    std::smatch intNumberMatch;
    std::regex  intNumber(R"(^\s*(\-?\d+)\s*$)");

    if (!keyExists(section, key)) {
        throw std::runtime_error("The key " + key + " does not exist in section " + section + " in " + Ini::iniPath);
    }

    if (std::regex_match(values[section][key], intNumberMatch, intNumber)) {
        return std::strtoll(intNumberMatch[1].str().c_str(), nullptr, 10);
    } else {
        throw std::runtime_error("The given parameter is not an integer number");
    }
}

/**
 * Extract the floating point number of the parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested floating point number value
 */
long double Ini::extractFloatNumber(const std::string &section, const std::string &key)
{
    std::smatch floatNumberMatch;
    std::regex  floatNumber(R"(^\s*(\-?(?:(?:\d+(?:\.\d*)?)|(?:\d+(?:\.\d+)?e(?:\+|\-)\d+))f?)\s*$)");

    if (!keyExists(section, key)) {
        throw std::runtime_error("The key " + key + " does not exist in section " + section + " in " + Ini::iniPath);
    }

    if (std::regex_match(values[section][key], floatNumberMatch, floatNumber)) {
        return std::strtold(floatNumberMatch[1].str().c_str(), nullptr);
    } else {
        throw std::runtime_error("The given parameter is not a floating point number");
    }
}

// ---------------------------------------------- Template specialisation -------------------------------------------- //

/**
 * Throw an exception if the requested type is not defined
 */
template<typename T>
T Ini::get(const std::string &section, const std::string &key)
{
    throw std::runtime_error("The type of the given parameter is not defined");
}

/**
 * Get the boolean parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested bool value
 */
template<>
bool Ini::get(const std::string &section, const std::string &key)
{
    if (iniPath.empty()) { parse(); }

    return values[section][key] == "true" || values[section][key] == "1";
}

/**
 * Get the string parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested string value
 */
template<>
std::string Ini::get(const std::string &section, const std::string &key)
{
    if (iniPath.empty()) { parse(); }

    return values[section][key];
}

/**
 * Get the short number parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested short number value
 */
template<>
short Ini::get(const std::string &section, const std::string &key)
{
    if (iniPath.empty()) { parse(); }

    auto number = extractIntegerNumber(section, key);

    if (number >= -SHRT_MAX && number <= SHRT_MAX) {
        return static_cast<short>(number);
    } else {
        throw std::runtime_error("The number is out of range of a short integer");
    }
}

/**
 * Get the int number parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested int number value
 */
template<>
int Ini::get(const std::string &section, const std::string &key)
{
    if (iniPath.empty()) { parse(); }

    auto number = extractIntegerNumber(section, key);

    if (number >= -INT_MAX && number <= INT_MAX) {
        return static_cast<int>(number);
    } else {
        throw std::runtime_error("The number is out of range of a an integer");
    }
}

/**
 * Get the unsigned int number parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested unsigned int number value
 */
template<>
unsigned int Ini::get(const std::string &section, const std::string &key)
{
    if (iniPath.empty()) { parse(); }

    auto number = extractIntegerNumber(section, key);

    if (number < 0) {
        throw std::runtime_error("The number is negative so it cannot be unsigned");
    } else if (number <= UINT_MAX) {
        return static_cast<unsigned int>(number);
    } else {
        throw std::runtime_error("The number is out of range of a an unsigned integer");
    }
}

/**
 * Get the long int number parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested long int number value
 */
template<>
long Ini::get(const std::string &section, const std::string &key)
{
    if (iniPath.empty()) { parse(); }

    auto number = extractIntegerNumber(section, key);

    if (number >= -LONG_MAX && number <= LONG_MAX) {
        return number;
    } else {
        throw std::runtime_error("The number is out of range of a a long integer");
    }
}

/**
 * Get the long long int number parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested long long int number value
 */
template<>
long long Ini::get(const std::string &section, const std::string &key)
{
    if (iniPath.empty()) { parse(); }

    return extractIntegerNumber(section, key);
}

/**
 * Get the float number parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested float number value
 */
template<>
float Ini::get(const std::string &section, const std::string &key)
{
    if (iniPath.empty()) { parse(); }

    auto number = extractFloatNumber(section, key);

    if (number >= -FLT_MAX && number <= FLT_MAX) {
        return static_cast<float>(number);
    } else {
        throw std::runtime_error("The number is out of range of a float");
    }
}

/**
 * Get the double number parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested double number value
 */
template<>
double Ini::get(const std::string &section, const std::string &key)
{
    if (iniPath.empty()) { parse(); }

    auto number = extractFloatNumber(section, key);

    if (number >= -DBL_MAX && number <= DBL_MAX) {
        return static_cast<double>(number);
    } else {
        throw std::runtime_error("The number is out of range of a double");
    }
}

/**
 * Get the long double number parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested long double number value
 */
template<>
long double Ini::get(const std::string &section, const std::string &key)
{
    if (iniPath.empty()) { parse(); }

    return extractFloatNumber(section, key);
}

main.cpp

#include <iostream>
#include "Ini.hpp"

int main(int argc, char **argv)
{
    try {
        auto myNumber = Ini::get<int>("Section", "parameter1");
        auto myString = Ini::get<std::string>("Section", "parameter2");

        std::cout << "Get the string " << myString << " and the number " << myNumber << " from the INI config file." << std::endl;

    } catch (const std::exception &e) {
        std::cerr << e.what() << std::endl;

        return 1;
    }

    return 0;
}

example.ini

[Section]
parameter1 = 10
; comment
parameter2 = My string parameter ; comment
\$\endgroup\$
1
  • 1
    \$\begingroup\$ Yep I did couple of tests in a online regex tester, I'll put some demos. I didn't find a native C++ INI parser in the std like there is in PHP for example. \$\endgroup\$ Nov 3, 2020 at 9:17

2 Answers 2

5
\$\begingroup\$

General issues to the whole concept:

  • There is a reason why ini file format is considered deprecated by Microsoft. File formats like json and xml have tree-like structure making them a lot more flexible that ini file format that has only single-depth sections.

Suppose you want a class to obtain a value from the configuration file. Okay, that's simple just fix a section for the class and let it take value from a given key. But wait, what if you have more than a single instance of the class and you want different configurations for it? Do you want to manually decide the section for each class?

With tree-like configuration file formats you can just go to a subsection and obtain values from there. And everything is organized automatically.

If you still want to operate with INI file format you can just extend it by saying that / acts as a subsection. So,

[SECTION]
SUBSECTION/KEY = VALUE

is equivalent to

[SECTION/SUBSECTION]
KEY = VALUE

and both resulting in SECTION/SUBSECTION/KEY = VALUE.

  • Making ini parser into a singleton creates a lot of issues.
  1. What do you think will happen if you parse several ini files and some of them share the same sections?

  2. What will happen if in one thread you access data while another thread parses an ini file? You need to make the class thread-safe.

  3. What if you don't want to expose all your ini files to every section in the code? Say you want to instantiate a class from a different version of a specific ini file that has same fields but different values as the one you work on currently?

I am a strong believer in Context Pattern - you create a class with some dynamic shared data and forward it to all necessary class instances (only slightly alter handles to it so they access information from the correct places). Singletons are fine for services/functions that require relatively long instantiation. Configuration file is more of a shared data and not a singleton.

  • Okay, let's move to various technical issues:
  1. You define template method get in .cpp file. That's not gonna work. They must be defined in header else using it for new types causes compilation errors so the throw will never be triggered. Honestly, you want a compilation error instead of a runtime error but did you ever read a template error message? They are all incomprehensible.

    Instead you should write the unspecified get in header and put a static_assert inside.

  2. The interface is very clunky. Frequently, people have default parameters and just want to see if the value was overridden in the configuration file and update it if necessary. Think how easy it would be with this interface. They will have to wrap every single call into try/catch. That's horrible.

    Why not simply return a std::optional<T> instead of throwing for every little thing? Check boost::property_tree how they have it. It is written a lot more sensibly.

  3. The methods that just want to get values modify the data, e.g.,

         bool Ini::get(const std::string &section, const std::string &key)
         {
             return values[section][key] == "true" || values[section][key] == "1";
         }
    

    Here, if values[section] doesn't exist it will be created and same for values[section][key]. It is definitely not what you want with "get data" methods. In addition, it will cause data-races and UB issues when you try to get data from the ini class by different threads.

  4. Since you want a modern C++ code, why do you use such outdated MACROS? for integer and float limits? There is std::numeric_limits<T> for this purpose and you can write a single definition for all integral values instead of specializing it for each type.

  5. Accessing keys of unordered_map<string, unordered_map<string,string>> is inefficient and inconvenient. To access a single key you have to generate hash twice one for each map. Consider using unordered_map<string,string> and store access keys in format section/key instead. This way you'll also be able to forward section + key in a single string instead of two. Furthermore, for input consider using string_view although, I am unsure if unordered_map<string,string> can find elements directly from string_view keys.

  6. I believe it will be more convenient if the default ini file wasn't a universal constant but instead depend on the name of the executable? no? Like you have two executables A.exe and B.exe then A.exe will automatically try to load A.ini and B.exe will automatically try to load B.ini. Isn't it better?

  7. Ini::parse(const std::string &filePath) If you already use C++17 then please use dedicated class std::filesystem::path instead of std::string. Also the you should utilize std::move as values[sectionMatch[1].str()] = sectionParameters; will cause the unordered map sectionParameters to be copied which is slow for such a class. Furthermore, it is completely unnecessary as you could've just written values[sectionMatch[1].str()] = std::move(sectionParameters); which would steal all its content instead of copying.

  8. Honestly, I don't understand the regular expressions and I have some doubts that it will properly parse ini files - you should add checks on this part and test it properly. Furthermore, you ought to put out warnings/errors when the file you read doesn't follow ini specification and I don't see it done.

\$\endgroup\$
3
  • \$\begingroup\$ Thank you for this long and precise review. INI files intent to be simpler than verbose XML one, I really like JSON format since I was a web developer and used this format quite a lot in JS but JSON format lack of comments in it to describe what's going on. This class is not TS, it should be you're right ! Using a static_assert would be better than error throwing that's a good point. This code came from a C++14 CUDA project and the project can now handle C++17 with NVCC 11 so I will use the std::filesystem. I will get most of your recommendations and post a 2nd version today. \$\endgroup\$ Nov 3, 2020 at 9:13
  • \$\begingroup\$ I'm curious of one thing, I tried as you suggested to put a static assert in header to have compilation error when a type is not implemented with std::is_same_v. It works when I build in debug mode but not in release (-O3). I kept the template specialization in .cpp file. Must template specialization be located in header file ? To be clear, if I put static_assert in header file in release mode, compilation warns about undefined specialization but fails to get the correct one in function call at execution. The unspecified template is called. \$\endgroup\$ Nov 5, 2020 at 11:15
  • \$\begingroup\$ @RomainLaneuville you need to either make definition in header or declare via extern template that there is a .cpp definition elsewhere. Generally, I thought to inform you later that you shouldn't write get like this. Instead, you should make get template from classes T and Parser (defaulted to some default_parser<T>) and let the Parser class convert from the string to T. It is much easier to specialise classes as opposed to functions. \$\endgroup\$
    – ALX23z
    Nov 5, 2020 at 12:01
0
\$\begingroup\$

Based on ALX23z recommendations, I did the following changes :

  • The class is now thread safe
  • There is only one get method that parsing all the types
  • The class is now using std::numeric_limits<T> instead of old C style constants
  • The class is now using std::filesystem::path instead of std::string for file path parameter

I kept the typedef std::unordered_map<std::string, std::unordered_map<std::string, std::string>> IniStructure which I think is more readable when accessing parameter as values[section][parameter] and the parameter access time is not critical as it is not intended to be accessed multiple times like in a loop.

This class does not handle multiple INI files parsing.

I tried to use a static_assert to warn about undefined type at compilation time but the compilation fails before reaching the assertion.

Here is the new version :

Ini.hpp

#include <filesystem>
#include <fstream>
#include <string>
#include <unordered_map>
#include <regex>
#include <type_traits>
#include <limits>
#include <mutex>

template<typename T> struct unsupportedType : std::false_type { };

struct Ini
{
    public:
        typedef std::unordered_map<std::string, std::unordered_map<std::string, std::string>> IniStructure;

        Ini() = delete;

        static void parse(const std::filesystem::path &filePath = PARAMETERS_INI_FILE_PATH);

        template<typename T> static T get(const std::string &section, const std::string &key);

    private:
        static void checkParameter(const std::string &section, const std::string &key);
        static long long extractIntegerNumber(const std::string &section, const std::string &key);
        static long double extractFloatNumber(const std::string &section, const std::string &key);
        static void checkInit();

        // IniStructure[section][parameter name] = parameter value
        static inline IniStructure          values;
        static inline std::filesystem::path iniPath;
        static inline std::mutex            mutex;
        static inline bool                  initialized = false;
};

Ini.cpp

#include "Ini.hpp"

// ------------------------------------------------- Static functions ------------------------------------------------ //

/**
 * Parse the INI file located to the given file path and store the values in Ini::values
 *
 * @param filePath - The path of the INI file to parse
 */
void Ini::parse(const std::filesystem::path &filePath)
{
    std::ifstream fileReader(filePath, std::ifstream::in);
    std::string   fileContent((std::istreambuf_iterator<char>(fileReader)), std::istreambuf_iterator<char>());
    std::regex    sections(R"(\[([^\]\r\n]+)]((?:\r?\n(?:[^[\r\n].*)?)*))");
    std::regex    parameters(R"((\w+) ?= ?(\"([^\"]+)\"|([^\r\n\t\f\v;#]+)))");
    std::smatch   sectionMatch;

    iniPath = filePath;

    if (fileReader.fail()) {
        throw std::runtime_error("The file " + Ini::iniPath.string() + " could not be opened");
    }

    while (regex_search(fileContent, sectionMatch, sections)) {
        std::unordered_map<std::string, std::string> sectionParameters;
        std::string sectionString = sectionMatch[2].str();

        for (std::sregex_iterator parameter(sectionString.begin(), sectionString.end(), parameters); parameter != std::sregex_iterator(); ++parameter) {
            std::smatch parameterMatch                 = *parameter;
            sectionParameters[parameterMatch[1].str()] = parameterMatch[3].matched ? parameterMatch[3].str() : parameterMatch[4].str();
            // parameterMatch[1] is the key, parameterMatch[3] is a trim quoted string (string without double quotes)
            // and parameterMatch[4] is a number
        }

        values[sectionMatch[1].str()] = std::move(sectionParameters);
        fileContent = sectionMatch.suffix();
    }

    initialized = true;
}

/**
 * Tells if the parameter exists in the given section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @throw Runtime exception if either the section does not exist or the key does not exists in the given section
 */
void Ini::checkParameter(const std::string &section, const std::string &key)
{
    if (values.find(section) == values.end()) {
        throw std::runtime_error("The section " + section + " does not exist in " + Ini::iniPath.string());
    }

    if (values[section].find(key) == values[section].end()) {
        throw std::runtime_error("The key " + key + " does not exist in section " + section + " in " + Ini::iniPath.string());
    }
}

/**
 * Extract the integer number of the parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested integer number value
 */
long long Ini::extractIntegerNumber(const std::string &section, const std::string &key)
{
    std::smatch intNumberMatch;
    std::regex  intNumber(R"(^\s*(\-?\d+)\s*$)");

    if (std::regex_match(values[section][key], intNumberMatch, intNumber)) {
        return std::strtoll(intNumberMatch[1].str().c_str(), nullptr, 10);
    } else {
        throw std::runtime_error("The given parameter is not an integer number");
    }
}

/**
 * Extract the floating point number of the parameter in the given INI section at the given key
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested floating point number value
 */
long double Ini::extractFloatNumber(const std::string &section, const std::string &key)
{
    std::smatch floatNumberMatch;
    std::regex  floatNumber(R"(^\s*(\-?(?:(?:\d+(?:\.\d*)?)|(?:\d+(?:\.\d+)?e(?:\+|\-)\d+))f?)\s*$)");

    if (std::regex_match(values[section][key], floatNumberMatch, floatNumber)) {
        return std::strtold(floatNumberMatch[1].str().c_str(), nullptr);
    } else {
        throw std::runtime_error("The given parameter is not a floating point number");
    }
}

/**
 * Check if the INI file is already parsed, if not parse the INI file. This operation is thread safe.
 */
void Ini::checkInit()
{
    std::lock_guard<std::mutex> lock(mutex);

    if (!initialized) {
        parse();
    }
}

// ---------------------------------------------- Template specialisation -------------------------------------------- //

/**
 * Get the parameter in the given INI section at the given key, extract the value depending on the given type
 *
 * @param section - The INI section to get the parameter from
 * @param key - The INI parameter key
 *
 * @return The requested parameter value
 */
template<typename T> T Ini::get(const std::string &section, const std::string &key) {
    checkInit();
    checkParameter(section, key);

    if constexpr (std::is_same_v<T, bool>) {
        return values[section][key] == "true" || values[section][key] == "1";
    } else if constexpr (std::is_same_v<T, std::string>) {
        return values[section][key];
    } else if constexpr (std::is_same_v<T, short> || std::is_same_v<T, int> || std::is_same_v<T, unsigned int>
                         || std::is_same_v<T, long> || std::is_same_v<T, long long>
    ) {
        auto number = extractIntegerNumber(section, key);

        if (std::is_same_v<T, unsigned int> && number < 0) {
            throw std::runtime_error("The number is negative so it cannot be unsigned");
        } else if (number >= std::numeric_limits<T>::lowest() && number <= std::numeric_limits<T>::max()) {
            return static_cast<T>(number);
        } else {
            throw std::runtime_error("The number is out of range of the given integral type");
        }
    } else if constexpr (std::is_same_v<T, float> || std::is_same_v<T, double> || std::is_same_v<T, long double>) {
        auto number = extractFloatNumber(section, key);

        if (number >= std::numeric_limits<T>::lowest() && number <= std::numeric_limits<T>::max()) {
            return static_cast<T>(number);
        } else {
            throw std::runtime_error("The number is out of range of the given floating type");
        }
    } else {
        // @todo This does not warn at compile, compilation fails before reaching this
        static_assert(unsupportedType<T>::value, "The INI parser cannot read parameter of the given C++ type");
    }
}

// Forward template declarations
template bool Ini::get<bool>(const std::string &section, const std::string &key);
template std::string Ini::get<std::string>(const std::string &section, const std::string &key);
template short Ini::get<short>(const std::string &section, const std::string &key);
template int Ini::get<int>(const std::string &section, const std::string &key);
template unsigned int Ini::get<unsigned int>(const std::string &section, const std::string &key);
template long Ini::get<long>(const std::string &section, const std::string &key);
template long long Ini::get<long long>(const std::string &section, const std::string &key);
template float Ini::get<float>(const std::string &section, const std::string &key);
template double Ini::get<double>(const std::string &section, const std::string &key);
template long double Ini::get<long double>(const std::string &section, const std::string &key);
\$\endgroup\$
3
  • \$\begingroup\$ Hi, if you want the new code reviewed, you should create a new question on this site. \$\endgroup\$
    – G. Sliepen
    Nov 5, 2020 at 19:50
  • \$\begingroup\$ I just posted the new "solution" after taking some reviews into consideration, i'm not going to post the code x times after each iteration. \$\endgroup\$ Nov 5, 2020 at 21:22
  • 4
    \$\begingroup\$ Ok, that's fine. However, would you consider marking the answer from @ALX23z as the accepted answer? That is a real review after all, and allowed you to create the revised code you posted here. \$\endgroup\$
    – G. Sliepen
    Nov 5, 2020 at 21:42

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.