windowsboostboost-program-optionsstd-filesystem

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?


Solution

  • 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