Stefan F.
Stefan F.

Reputation: 53

Q: Boost Program Options using std::filesystem::path as option fails when the given path contains spaces

I have a windows command line program using Boost.Program_Options. One option uses a std::filesystem::path variable.

namespace fs = std::filesystem;
namespace po = boost::program_options;

fs::path optionsFile;

po::options_description desc( "Options" );
desc.add_options()
        ("help,h", "Help screen")
        ("options,o", po::value<fs::path>( &optionsFile ), "file with options");

calling the program with -o c:\temp\options.txt or with -o "c:\temp\options.txt" works fine, but calling the program with -o "c:\temp\options 1.txt" fails with this error:

error: the argument( 'c:\temp\options 1.txt' ) for option '--options' is invalid

The content of argv in this case is:

This is the full code:

#include <boost/program_options.hpp>
#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;
namespace po = boost::program_options;

int wmain( int argc, wchar_t * argv[] )
{
    try
    {
        fs::path optionsFile;

        po::options_description desc( "Options" );
        desc.add_options()
            ("help,h", "Help screen")
            ("options,o", po::value<fs::path>( &optionsFile ), "File containing the command and the arguments");

        po::wcommand_line_parser parser{ argc, argv };
        parser.options( desc ).allow_unregistered().style(
            po::command_line_style::default_style |
            po::command_line_style::allow_slash_for_short );
        po::wparsed_options parsed_options = parser.run();

        po::variables_map vm;
        store( parsed_options, vm );
        notify( vm );

        if( vm.count( "help" ) )
        {
            std::cout << desc << '\n';
            return 0;
        }

        std::cout << "optionsFile = " << optionsFile << "\n";
    }
    catch( const std::exception & e )
    {
        std::cerr << "error: " << e.what() << "\n";
        return 1;
    }

    return 0;
}

How can I handle paths containing whitespace correctly? Is that even possible using std::filesystem::path or do I have to use std::wstring?

Upvotes: 3

Views: 956

Answers (1)

sehe
sehe

Reputation: 393134

Indeed I could reproduce this. Replacing fs::path with std::string fixed it.

Here's a side-by-side reproducer: Live On Coliru

#include <boost/program_options.hpp>
#include <filesystem>
#include <iostream>

namespace po = boost::program_options;

template <typename Path> static constexpr auto Type           = "[unknown]";
template <> constexpr auto Type<std::string>           = "std::string";
template <> constexpr auto Type<std::filesystem::path> = "fs::path";

template <typename Path>
bool do_test(int argc, char const* argv[]) try {
    Path optionsFile;

    po::options_description desc("Options");
    desc.add_options()            //
        ("help,h", "Help screen") //
        ("options,o", po::value<Path>(&optionsFile),
         "File containing the command and the arguments");

    po::command_line_parser parser{argc, argv};
    parser.options(desc).allow_unregistered().style(
            po::command_line_style::default_style |
            po::command_line_style::allow_slash_for_short);
    auto parsed_options = parser.run();

    po::variables_map vm;
    store(parsed_options, vm);
    notify(vm);

    if (vm.count("help")) {
        std::cout << desc << '\n';
        return true;
    }

    std::cout << "Using " << Type<Path> << "\toptionsFile = " << optionsFile << "\n";
    return true;
} catch (const std::exception& e) {
    std::cout << "Using " << Type<Path> << "\terror: " << e.what() << "\n";
    return false;
}

int main() {
    for (auto args : {
             std::vector{"Exepath", "-o", "c:\\temp\\options1.txt"},
             std::vector{"Exepath", "-o", "c:\\temp\\options 1.txt"},
         })
    {
        std::cout << "\n -- Input: ";
        for (auto& arg : args) {
            std::cout << " " << std::quoted(arg);
        }
        std::cout << "\n";
        int argc = args.size();
        args.push_back(nullptr);
        do_test<std::string>(argc, args.data());
        do_test<std::filesystem::path>(argc, args.data());
    }
} 

Prints

 -- Input:  "Exepath" "-o" "c:\\temp\\options1.txt"
Using std::string   optionsFile = c:\temp\options1.txt
Using fs::path  optionsFile = "c:\\temp\\options1.txt"

 -- Input:  "Exepath" "-o" "c:\\temp\\options 1.txt"
Using std::string   optionsFile = c:\temp\options 1.txt
Using fs::path  error: the argument ('c:\temp\options 1.txt') for option '--options' is invalid

The reason most likely is that extraction from the command line argument defaults to using operator>> on a stringstream¹. If that has skipws set (as all C++ istreams do by default), then whitespace stops the "parse" and the argument is rejected because it is not fully consumed.

However, modifying the code to include a validate overload that fires for paths, adding std::noskipws didn't help!

template <class CharT>
void validate(boost::any& v, std::vector<std::basic_string<CharT>> const& s,
              std::filesystem::path* p, int)
{
    assert(s.size() == 1);
    std::basic_stringstream<CharT> ss;

    for (auto& el : s)
        ss << el;

    path converted;
    ss >> std::noskipws >> converted;

    if (!ss.eof())
        throw std::runtime_error("Invalid path format");

    v = std::move(converted);
}

Apparently, operator>> for fs::path doesn't obey noskipws. A look at the docs confirms:

Performs stream input or output on the path p. std::quoted is used so that spaces do not cause truncation when later read by stream input operator.

This gives us the workaround:

Workaround

template <class CharT>
void validate(boost::any& v, std::vector<std::basic_string<CharT>> const& s,
              std::filesystem::path* p, int)
{
    assert(s.size() == 1);
    std::basic_stringstream<CharT> ss;

    for (auto& el : s)
        ss << std::quoted(el);

    path converted;
    ss >> std::noskipws >> converted;

    if (ss.peek(); !ss.eof())
        throw std::runtime_error("excess path characters");

    v = std::move(converted);
}

Here we balance the std::quoted quoting/escaping as required.

Live Demo

Proof Of Concept:

Live On Coliru

#include <boost/program_options.hpp>
#include <filesystem>
#include <iostream>

namespace std::filesystem {
    template <class CharT>
    void validate(boost::any& v, std::vector<std::basic_string<CharT>> const& s,
                  std::filesystem::path* p, int)
    {
        assert(s.size() == 1);
        std::basic_stringstream<CharT> ss;

        for (auto& el : s)
            ss << std::quoted(el);

        path converted;
        ss >> std::noskipws >> converted;

        if (ss.peek(); !ss.eof())
            throw std::runtime_error("excess path characters");

        v = std::move(converted);
    }
}

namespace po = boost::program_options;

template <typename Path> static constexpr auto Type    = "[unknown]";
template <> constexpr auto Type<std::string>           = "std::string";
template <> constexpr auto Type<std::filesystem::path> = "fs::path";

template <typename Path>
bool do_test(int argc, char const* argv[]) try {
    Path optionsFile;

    po::options_description desc("Options");
    desc.add_options()            //
        ("help,h", "Help screen") //
        ("options,o", po::value<Path>(&optionsFile),
         "File containing the command and the arguments");

    po::command_line_parser parser{argc, argv};
    parser.options(desc).allow_unregistered().style(
            po::command_line_style::default_style |
            po::command_line_style::allow_slash_for_short);
    auto parsed_options = parser.run();

    po::variables_map vm;
    store(parsed_options, vm);
    notify(vm);

    if (vm.count("help")) {
        std::cout << desc << '\n';
        return true;
    }

    std::cout << "Using " << Type<Path> << "\toptionsFile = " << optionsFile << "\n";
    return true;
} catch (const std::exception& e) {
    std::cout << "Using " << Type<Path> << "\terror: " << e.what() << "\n";
    return false;
}

int main() {
    for (auto args : {
             std::vector{"Exepath", "-o", "c:\\temp\\options1.txt"},
             std::vector{"Exepath", "-o", "c:\\temp\\options 1.txt"},
         })
    {
        std::cout << "\n -- Input: ";
        for (auto& arg : args) {
            std::cout << " " << std::quoted(arg);
        }
        std::cout << "\n";
        int argc = args.size();
        args.push_back(nullptr);
        do_test<std::string>(argc, args.data());
        do_test<std::filesystem::path>(argc, args.data());
    }
} 

Now prints

 -- Input:  "Exepath" "-o" "c:\\temp\\options1.txt"
Using std::string   optionsFile = c:\temp\options1.txt
Using fs::path  optionsFile = "c:\\temp\\options1.txt"

 -- Input:  "Exepath" "-o" "c:\\temp\\options 1.txt"
Using std::string   optionsFile = c:\temp\options 1.txt
Using fs::path  optionsFile = "c:\\temp\\options 1.txt"

¹ this actually happens inside boost::lexical_cast which comes from Boost Conversion

Upvotes: 1

Related Questions