diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 04fccf5..56ae26b 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Compute next patch version id: next diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7416478..52663c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: name: Format check runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -52,7 +52,7 @@ jobs: - {os: windows-latest, compiler: msvc, cc: "", cxx: ""} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Ninja (Linux) if: runner.os == 'Linux' diff --git a/.github/workflows/deploy-nightly-docs.yml b/.github/workflows/deploy-nightly-docs.yml index 4b054ab..08cf337 100644 --- a/.github/workflows/deploy-nightly-docs.yml +++ b/.github/workflows/deploy-nightly-docs.yml @@ -22,7 +22,7 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 2dc7c2f..bacba88 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: cachix/install-nix-action@v27 with: diff --git a/.github/workflows/release-docs.yml b/.github/workflows/release-docs.yml index c614443..78d3b77 100644 --- a/.github/workflows/release-docs.yml +++ b/.github/workflows/release-docs.yml @@ -17,7 +17,7 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f15a194..f86f3c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install clang run: sudo apt-get install -y clang diff --git a/.github/workflows/windows-unicode.yml b/.github/workflows/windows-unicode.yml index 1149191..8b6a8df 100644 --- a/.github/workflows/windows-unicode.yml +++ b/.github/workflows/windows-unicode.yml @@ -13,7 +13,7 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: lukka/get-cmake@latest diff --git a/.gitignore b/.gitignore index 3f5e0d3..c7797da 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # build artifacts build/ +build-bench/ *.o *.a *.so diff --git a/bench/CMakeLists.txt b/bench/CMakeLists.txt new file mode 100644 index 0000000..5b0ed8b --- /dev/null +++ b/bench/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.20) + +project(cli20_bench LANGUAGES CXX) + +set(BENCH_CXX_STANDARD "20" CACHE STRING "C++ standard used for benchmark builds") +set_property(CACHE BENCH_CXX_STANDARD PROPERTY STRINGS 20 26) + +set(CMAKE_CXX_STANDARD "${BENCH_CXX_STANDARD}") +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +include(cmake/BenchLibraries.cmake) + +add_subdirectory(runtime) +add_subdirectory(size) diff --git a/bench/cmake/BenchLibraries.cmake b/bench/cmake/BenchLibraries.cmake new file mode 100644 index 0000000..b5fc372 --- /dev/null +++ b/bench/cmake/BenchLibraries.cmake @@ -0,0 +1,46 @@ +include_guard(GLOBAL) + +get_filename_component(CLI20_REPOSITORY_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) + +add_library(bench_common INTERFACE) +target_include_directories(bench_common INTERFACE + "${CLI20_REPOSITORY_ROOT}/include" + "${CLI20_REPOSITORY_ROOT}/bench/include" +) +target_compile_features(bench_common INTERFACE cxx_std_20) + +add_library(bench_warnings INTERFACE) +target_compile_options(bench_warnings INTERFACE + $,/W4,-Wall;-Wextra;-Wpedantic> +) + +find_package(benchmark REQUIRED) +if(POLICY CMP0167) + cmake_policy(SET CMP0167 NEW) +endif() +find_package(Boost REQUIRED COMPONENTS program_options) + +find_path(BENCH_CLI11_INCLUDE_DIR CLI/CLI.hpp REQUIRED) +find_path(BENCH_ARGPARSE_INCLUDE_DIR argparse/argparse.hpp REQUIRED) +find_path(BENCH_CXXOPTS_INCLUDE_DIR cxxopts.hpp REQUIRED) + +add_library(bench_cli11 INTERFACE) +target_include_directories(bench_cli11 INTERFACE "${BENCH_CLI11_INCLUDE_DIR}") + +add_library(bench_argparse INTERFACE) +target_include_directories(bench_argparse INTERFACE "${BENCH_ARGPARSE_INCLUDE_DIR}") + +add_library(bench_cxxopts INTERFACE) +target_include_directories(bench_cxxopts INTERFACE "${BENCH_CXXOPTS_INCLUDE_DIR}") + +add_library(bench_boost_program_options INTERFACE) +target_link_libraries(bench_boost_program_options INTERFACE Boost::program_options) + +function(bench_add_size_executable target source library_target) + add_executable("${target}" "${source}") + target_link_libraries("${target}" PRIVATE + bench_common + bench_warnings + "${library_target}" + ) +endfunction() diff --git a/bench/compile_time/cases/many_options/argparse.cc b/bench/compile_time/cases/many_options/argparse.cc new file mode 100644 index 0000000..b0db921 --- /dev/null +++ b/bench/compile_time/cases/many_options/argparse.cc @@ -0,0 +1 @@ +#include "../../../size/many_options/argparse.cc" diff --git a/bench/compile_time/cases/many_options/boost.cc b/bench/compile_time/cases/many_options/boost.cc new file mode 100644 index 0000000..512daf2 --- /dev/null +++ b/bench/compile_time/cases/many_options/boost.cc @@ -0,0 +1 @@ +#include "../../../size/many_options/boost.cc" diff --git a/bench/compile_time/cases/many_options/cli11.cc b/bench/compile_time/cases/many_options/cli11.cc new file mode 100644 index 0000000..e1e6303 --- /dev/null +++ b/bench/compile_time/cases/many_options/cli11.cc @@ -0,0 +1 @@ +#include "../../../size/many_options/cli11.cc" diff --git a/bench/compile_time/cases/many_options/cli20.cc b/bench/compile_time/cases/many_options/cli20.cc new file mode 100644 index 0000000..497f96f --- /dev/null +++ b/bench/compile_time/cases/many_options/cli20.cc @@ -0,0 +1 @@ +#include "../../../size/many_options/cli20.cc" diff --git a/bench/compile_time/cases/many_options/cxxopts.cc b/bench/compile_time/cases/many_options/cxxopts.cc new file mode 100644 index 0000000..675a63b --- /dev/null +++ b/bench/compile_time/cases/many_options/cxxopts.cc @@ -0,0 +1 @@ +#include "../../../size/many_options/cxxopts.cc" diff --git a/bench/compile_time/cases/simple/argparse.cc b/bench/compile_time/cases/simple/argparse.cc new file mode 100644 index 0000000..9da3aea --- /dev/null +++ b/bench/compile_time/cases/simple/argparse.cc @@ -0,0 +1 @@ +#include "../../../size/simple/argparse.cc" diff --git a/bench/compile_time/cases/simple/boost.cc b/bench/compile_time/cases/simple/boost.cc new file mode 100644 index 0000000..9838f59 --- /dev/null +++ b/bench/compile_time/cases/simple/boost.cc @@ -0,0 +1 @@ +#include "../../../size/simple/boost.cc" diff --git a/bench/compile_time/cases/simple/cli11.cc b/bench/compile_time/cases/simple/cli11.cc new file mode 100644 index 0000000..8123b53 --- /dev/null +++ b/bench/compile_time/cases/simple/cli11.cc @@ -0,0 +1 @@ +#include "../../../size/simple/cli11.cc" diff --git a/bench/compile_time/cases/simple/cli20.cc b/bench/compile_time/cases/simple/cli20.cc new file mode 100644 index 0000000..246d02d --- /dev/null +++ b/bench/compile_time/cases/simple/cli20.cc @@ -0,0 +1 @@ +#include "../../../size/simple/cli20.cc" diff --git a/bench/compile_time/cases/simple/cxxopts.cc b/bench/compile_time/cases/simple/cxxopts.cc new file mode 100644 index 0000000..1f5df00 --- /dev/null +++ b/bench/compile_time/cases/simple/cxxopts.cc @@ -0,0 +1 @@ +#include "../../../size/simple/cxxopts.cc" diff --git a/bench/compile_time/cases/subcommand/argparse.cc b/bench/compile_time/cases/subcommand/argparse.cc new file mode 100644 index 0000000..82b9ec0 --- /dev/null +++ b/bench/compile_time/cases/subcommand/argparse.cc @@ -0,0 +1 @@ +#include "../../../size/subcommand/argparse.cc" diff --git a/bench/compile_time/cases/subcommand/boost.cc b/bench/compile_time/cases/subcommand/boost.cc new file mode 100644 index 0000000..0778f0a --- /dev/null +++ b/bench/compile_time/cases/subcommand/boost.cc @@ -0,0 +1 @@ +#include "../../../size/subcommand/boost.cc" diff --git a/bench/compile_time/cases/subcommand/cli11.cc b/bench/compile_time/cases/subcommand/cli11.cc new file mode 100644 index 0000000..12f7e40 --- /dev/null +++ b/bench/compile_time/cases/subcommand/cli11.cc @@ -0,0 +1 @@ +#include "../../../size/subcommand/cli11.cc" diff --git a/bench/compile_time/cases/subcommand/cli20.cc b/bench/compile_time/cases/subcommand/cli20.cc new file mode 100644 index 0000000..acc62e4 --- /dev/null +++ b/bench/compile_time/cases/subcommand/cli20.cc @@ -0,0 +1 @@ +#include "../../../size/subcommand/cli20.cc" diff --git a/bench/compile_time/cases/subcommand/cxxopts.cc b/bench/compile_time/cases/subcommand/cxxopts.cc new file mode 100644 index 0000000..1d4c1c6 --- /dev/null +++ b/bench/compile_time/cases/subcommand/cxxopts.cc @@ -0,0 +1 @@ +#include "../../../size/subcommand/cxxopts.cc" diff --git a/bench/compile_time/scripts/compile-one.sh b/bench/compile_time/scripts/compile-one.sh new file mode 100755 index 0000000..fcbea53 --- /dev/null +++ b/bench/compile_time/scripts/compile-one.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "usage: $0 CASE LIBRARY OUTPUT_DIR" >&2 + exit 2 +fi + +case_name="$1" +library="$2" +output_dir="$3" + +repo_root="$(cd "$(dirname "$0")/../../.." && pwd)" +source_file="$repo_root/bench/compile_time/cases/$case_name/$library.cc" +object_file="$output_dir/${case_name}_${library}.o" + +if [ ! -f "$source_file" ]; then + echo "unknown compile-time case: $case_name/$library" >&2 + exit 2 +fi + +mkdir -p "$output_dir" + +: "${CXX:=c++}" +: "${BENCH_CXX_STANDARD:=20}" +: "${CXXFLAGS:=-std=c++$BENCH_CXX_STANDARD -O2}" + +exec "$CXX" $CXXFLAGS \ + -I"$repo_root/include" \ + -I"$repo_root/bench/include" \ + -c "$source_file" \ + -o "$object_file" diff --git a/bench/compile_time/scripts/run.sh b/bench/compile_time/scripts/run.sh new file mode 100755 index 0000000..59ec134 --- /dev/null +++ b/bench/compile_time/scripts/run.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../../.." && pwd)" +cxx_standard="${BENCH_CXX_STANDARD:-20}" +out_dir="${1:-$repo_root/bench/results/compile_time/cxx$cxx_standard}" +hyperfine_runs="${HYPERFINE_RUNS:-5}" +hyperfine_warmup="${HYPERFINE_WARMUP:-1}" + +cases=(simple many_options subcommand) +libraries=(cli20 cli11 argparse cxxopts boost) + +mkdir -p "$out_dir" +result_file="$out_dir/results.tsv" +json_dir="$out_dir/json" +object_dir="$out_dir/objects" +printf "standard\tcase\tlibrary\tseconds\n" > "$result_file" + +for case_name in "${cases[@]}"; do + for library in "${libraries[@]}"; do + mkdir -p "$json_dir" "$object_dir" + object_file="$object_dir/${case_name}_${library}.o" + json_file="$json_dir/${case_name}_${library}.json" + hyperfine \ + --warmup "$hyperfine_warmup" \ + --runs "$hyperfine_runs" \ + --prepare "rm -f '$object_file'" \ + --export-json "$json_file" \ + "bash $repo_root/bench/compile_time/scripts/compile-one.sh '$case_name' '$library' '$object_dir'" \ + >/dev/null + elapsed="$(python3 -c 'import json, sys; print("{:.6f}".format(json.load(open(sys.argv[1]))["results"][0]["mean"]))' "$json_file")" + printf "c++%s\t%s\t%s\t%s\n" "$cxx_standard" "$case_name" "$library" "$elapsed" | + tee -a "$result_file" + done +done diff --git a/bench/include/bench/allocation_counter.hh b/bench/include/bench/allocation_counter.hh new file mode 100644 index 0000000..3fb6ba7 --- /dev/null +++ b/bench/include/bench/allocation_counter.hh @@ -0,0 +1,78 @@ +#pragma once + +#include + +#include +#include + +namespace bench { + +struct AllocationStats { + std::size_t allocations{}; + std::size_t bytes{}; +}; + +class AllocationCounter { + public: + AllocationCounter() { + reset(); + enabled() = true; + } + + AllocationCounter(const AllocationCounter&) = delete; + auto operator=(const AllocationCounter&) -> AllocationCounter& = delete; + + ~AllocationCounter() { enabled() = false; } + + static auto reset() -> void { + allocations().store(0, std::memory_order_relaxed); + bytes().store(0, std::memory_order_relaxed); + } + + static auto record(std::size_t n) -> void { + if (!enabled()) return; + allocations().fetch_add(1, std::memory_order_relaxed); + bytes().fetch_add(n, std::memory_order_relaxed); + } + + [[nodiscard]] static auto snapshot() -> AllocationStats { + return { + .allocations = allocations().load(std::memory_order_relaxed), + .bytes = bytes().load(std::memory_order_relaxed), + }; + } + + private: + static auto enabled() -> bool& { + static thread_local bool value = false; + return value; + } + + static auto allocations() -> std::atomic& { + static std::atomic value{}; + return value; + } + + static auto bytes() -> std::atomic& { + static std::atomic value{}; + return value; + } +}; + +inline auto set_allocation_counters(benchmark::State& state, + std::size_t allocations, std::size_t bytes) + -> void { + state.counters["allocs"] = benchmark::Counter( + static_cast(allocations), benchmark::Counter::kAvgIterations); + state.counters["bytes"] = benchmark::Counter( + static_cast(bytes), benchmark::Counter::kAvgIterations); +} + +template +auto measure_allocations(Fn&& fn) -> AllocationStats { + AllocationCounter counter; + fn(); + return counter.snapshot(); +} + +} // namespace bench diff --git a/bench/include/bench/argv.hh b/bench/include/bench/argv.hh new file mode 100644 index 0000000..ffeaaec --- /dev/null +++ b/bench/include/bench/argv.hh @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include + +namespace bench { + +struct Argv { + std::vector storage; + std::vector raw; + std::vector views; + + Argv(std::initializer_list args) { + storage.reserve(args.size()); + raw.reserve(args.size()); + views.reserve(args.size()); + for (std::string_view arg : args) { + storage.emplace_back(arg); + } + refresh(); + } + + auto refresh() -> void { + raw.clear(); + views.clear(); + for (auto& arg : storage) { + raw.push_back(arg.data()); + views.emplace_back(arg); + } + } + + [[nodiscard]] auto argc() const -> int { return static_cast(raw.size()); } + + [[nodiscard]] auto argv() -> char** { return raw.data(); } + + [[nodiscard]] auto span() const -> std::span { + return views; + } +}; + +} // namespace bench diff --git a/bench/include/bench/many_options.hh b/bench/include/bench/many_options.hh new file mode 100644 index 0000000..1711442 --- /dev/null +++ b/bench/include/bench/many_options.hh @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace bench { + +enum class Mode { fast, balanced, precise }; + +inline auto mode_name(Mode mode) -> std::string_view { + switch (mode) { + case Mode::fast: + return "fast"; + case Mode::balanced: + return "balanced"; + case Mode::precise: + return "precise"; + } + return "unknown"; +} + +inline auto parse_mode(std::string_view value) -> std::optional { + if (value == "fast") return Mode::fast; + if (value == "balanced") return Mode::balanced; + if (value == "precise") return Mode::precise; + return std::nullopt; +} + +inline auto parse_mode_or_throw(const std::string& value) -> Mode { + if (auto mode = parse_mode(value)) return *mode; + throw std::runtime_error("invalid mode: " + value); +} + +inline auto operator>>(std::istream& in, Mode& mode) -> std::istream& { + std::string value; + in >> value; + if (auto parsed = parse_mode(value)) { + mode = *parsed; + } else { + in.setstate(std::ios::failbit); + } + return in; +} + +inline auto operator<<(std::ostream& out, Mode mode) -> std::ostream& { + return out << mode_name(mode); +} + +struct ManyValues { + std::string name; + std::string output; + std::string target; + std::string define; + int jobs{}; + int retries{}; + int port{}; + int depth{}; + double ratio{}; + double timeout{}; + double threshold{}; + double scale{}; + std::filesystem::path config; + std::filesystem::path cache; + Mode mode{}; + Mode color{}; +}; + +} // namespace bench diff --git a/bench/results/.gitignore b/bench/results/.gitignore new file mode 100644 index 0000000..bf27f31 --- /dev/null +++ b/bench/results/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!.gitkeep diff --git a/bench/results/.gitkeep b/bench/results/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/bench/results/.gitkeep @@ -0,0 +1 @@ + diff --git a/bench/runtime/CMakeLists.txt b/bench/runtime/CMakeLists.txt new file mode 100644 index 0000000..509d201 --- /dev/null +++ b/bench/runtime/CMakeLists.txt @@ -0,0 +1,17 @@ +add_executable(bench_runtime + allocation_count.cc + simple.cc + many_options.cc + subcommand.cc +) + +target_link_libraries(bench_runtime PRIVATE + bench_common + bench_warnings + bench_cli11 + bench_argparse + bench_cxxopts + bench_boost_program_options + benchmark::benchmark + benchmark::benchmark_main +) diff --git a/bench/runtime/allocation_count.cc b/bench/runtime/allocation_count.cc new file mode 100644 index 0000000..2c131ae --- /dev/null +++ b/bench/runtime/allocation_count.cc @@ -0,0 +1,100 @@ +#include +#include + +#include "bench/allocation_counter.hh" + +namespace { + +auto allocate(std::size_t size) -> void* { + if (void* ptr = std::malloc(size == 0 ? 1 : size)) { + bench::AllocationCounter::record(size); + return ptr; + } + throw std::bad_alloc{}; +} + +auto allocate_aligned(std::size_t size, std::align_val_t alignment) -> void* { + void* ptr = nullptr; + const auto align = static_cast(alignment); + if (posix_memalign(&ptr, align, size == 0 ? align : size) == 0) { + bench::AllocationCounter::record(size); + return ptr; + } + throw std::bad_alloc{}; +} + +} // namespace + +auto operator new(std::size_t size) -> void* { return allocate(size); } + +auto operator new[](std::size_t size) -> void* { return allocate(size); } + +auto operator new(std::size_t size, const std::nothrow_t&) noexcept -> void* { + try { + return allocate(size); + } catch (...) { + return nullptr; + } +} + +auto operator new[](std::size_t size, const std::nothrow_t&) noexcept -> void* { + try { + return allocate(size); + } catch (...) { + return nullptr; + } +} + +auto operator new(std::size_t size, std::align_val_t alignment) -> void* { + return allocate_aligned(size, alignment); +} + +auto operator new[](std::size_t size, std::align_val_t alignment) -> void* { + return allocate_aligned(size, alignment); +} + +auto operator new(std::size_t size, std::align_val_t alignment, + const std::nothrow_t&) noexcept -> void* { + try { + return allocate_aligned(size, alignment); + } catch (...) { + return nullptr; + } +} + +auto operator new[](std::size_t size, std::align_val_t alignment, + const std::nothrow_t&) noexcept -> void* { + try { + return allocate_aligned(size, alignment); + } catch (...) { + return nullptr; + } +} + +void operator delete(void* ptr) noexcept { std::free(ptr); } + +void operator delete[](void* ptr) noexcept { std::free(ptr); } + +void operator delete(void* ptr, std::size_t) noexcept { std::free(ptr); } + +void operator delete[](void* ptr, std::size_t) noexcept { std::free(ptr); } + +void operator delete(void* ptr, const std::nothrow_t&) noexcept { + std::free(ptr); +} + +void operator delete[](void* ptr, const std::nothrow_t&) noexcept { + std::free(ptr); +} + +void operator delete(void* ptr, std::align_val_t) noexcept { std::free(ptr); } + +void operator delete[](void* ptr, std::align_val_t) noexcept { std::free(ptr); } + +void operator delete(void* ptr, std::size_t, std::align_val_t) noexcept { + std::free(ptr); +} + +void operator delete[](void* ptr, std::size_t, std::align_val_t) noexcept { + std::free(ptr); +} diff --git a/bench/runtime/many_options.cc b/bench/runtime/many_options.cc new file mode 100644 index 0000000..c8e5923 --- /dev/null +++ b/bench/runtime/many_options.cc @@ -0,0 +1,321 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "bench/allocation_counter.hh" +#include "bench/argv.hh" + +namespace bench { + +namespace po = boost::program_options; + +enum class Mode { fast, balanced, precise }; + +inline auto mode_name(Mode mode) -> std::string_view { + switch (mode) { + case Mode::fast: + return "fast"; + case Mode::balanced: + return "balanced"; + case Mode::precise: + return "precise"; + } + return "unknown"; +} + +inline auto parse_mode(std::string_view value) -> std::optional { + if (value == "fast") return Mode::fast; + if (value == "balanced") return Mode::balanced; + if (value == "precise") return Mode::precise; + return std::nullopt; +} + +inline auto parse_mode_or_throw(const std::string& value) -> Mode { + if (auto mode = parse_mode(value)) return *mode; + throw std::runtime_error("invalid mode: " + value); +} + +inline void validate(boost::any& out, const std::vector& values, + Mode*, int) { + po::validators::check_first_occurrence(out); + const auto& value = po::validators::get_single_string(values); + if (auto mode = parse_mode(value)) { + out = *mode; + return; + } + throw po::validation_error(po::validation_error::invalid_option_value, value); +} + +inline auto operator>>(std::istream& in, Mode& mode) -> std::istream& { + std::string value; + in >> value; + if (auto parsed = parse_mode(value)) { + mode = *parsed; + } else { + in.setstate(std::ios::failbit); + } + return in; +} + +inline auto operator<<(std::ostream& out, Mode mode) -> std::ostream& { + return out << mode_name(mode); +} + +struct ManyValues { + std::string name; + std::string output; + std::string target; + std::string define; + int jobs{}; + int retries{}; + int port{}; + int depth{}; + double ratio{}; + double timeout{}; + double threshold{}; + double scale{}; + std::filesystem::path config; + std::filesystem::path cache; + Mode mode{}; + Mode color{}; +}; + +} // namespace bench + +namespace { + +struct Cli20ManyOptions { + cli::StringOption<"name"> name; + cli::StringOption<"output"> output; + cli::StringOption<"target"> target; + cli::StringOption<"define"> define; + cli::IntOption<"jobs"> jobs; + cli::IntOption<"retries"> retries; + cli::IntOption<"port"> port; + cli::IntOption<"depth"> depth; + cli::DoubleOption<"ratio"> ratio; + cli::DoubleOption<"timeout"> timeout; + cli::DoubleOption<"threshold"> threshold; + cli::DoubleOption<"scale"> scale; + cli::PathOption<"config"> config; + cli::PathOption<"cache"> cache; + cli::Arg<"mode", cli::conversion::choice | + cli::pack::set_once> + mode; + cli::Arg<"color", cli::conversion::choice | + cli::pack::set_once> + color; +}; + +auto many_argv() -> bench::Argv& { + static bench::Argv args{ + "prog", "--name", "cli20", "--output", "out.o", + "--target", "native", "--define", "NDEBUG", "--jobs", + "12", "--retries", "3", "--port", "8080", + "--depth", "7", "--ratio", "0.75", "--timeout", + "2.5", "--threshold", "0.001", "--scale", "1.25", + "--config", "config.toml", "--cache", "build/cache", "--mode", + "balanced", "--color", "fast"}; + return args; +} + +void add_cli11_options(CLI::App& app, bench::ManyValues& values) { + static const std::map modes{ + {"fast", bench::Mode::fast}, + {"balanced", bench::Mode::balanced}, + {"precise", bench::Mode::precise}, + }; + app.add_option("--name", values.name); + app.add_option("--output", values.output); + app.add_option("--target", values.target); + app.add_option("--define", values.define); + app.add_option("--jobs", values.jobs); + app.add_option("--retries", values.retries); + app.add_option("--port", values.port); + app.add_option("--depth", values.depth); + app.add_option("--ratio", values.ratio); + app.add_option("--timeout", values.timeout); + app.add_option("--threshold", values.threshold); + app.add_option("--scale", values.scale); + app.add_option("--config", values.config); + app.add_option("--cache", values.cache); + app.add_option("--mode", values.mode) + ->transform(CLI::CheckedTransformer(modes)); + app.add_option("--color", values.color) + ->transform(CLI::CheckedTransformer(modes)); +} + +void add_argparse_options(argparse::ArgumentParser& program) { + program.add_argument("--name"); + program.add_argument("--output"); + program.add_argument("--target"); + program.add_argument("--define"); + program.add_argument("--jobs").scan<'i', int>(); + program.add_argument("--retries").scan<'i', int>(); + program.add_argument("--port").scan<'i', int>(); + program.add_argument("--depth").scan<'i', int>(); + program.add_argument("--ratio").scan<'g', double>(); + program.add_argument("--timeout").scan<'g', double>(); + program.add_argument("--threshold").scan<'g', double>(); + program.add_argument("--scale").scan<'g', double>(); + program.add_argument("--config").action([](const std::string& value) { + return std::filesystem::path{value}; + }); + program.add_argument("--cache").action( + [](const std::string& value) { return std::filesystem::path{value}; }); + program.add_argument("--mode").action(bench::parse_mode_or_throw); + program.add_argument("--color").action(bench::parse_mode_or_throw); +} + +void add_cxxopts_options(cxxopts::Options& options) { + options.add_options()("name", "name", cxxopts::value())( + "output", "output", cxxopts::value())( + "target", "target", cxxopts::value())( + "define", "define", cxxopts::value())("jobs", "jobs", + cxxopts::value())( + "retries", "retries", cxxopts::value())("port", "port", + cxxopts::value())( + "depth", "depth", cxxopts::value())("ratio", "ratio", + cxxopts::value())( + "timeout", "timeout", cxxopts::value())("threshold", "threshold", + cxxopts::value())( + "scale", "scale", cxxopts::value())( + "config", "config", cxxopts::value())( + "cache", "cache", cxxopts::value())( + "mode", "mode", cxxopts::value())( + "color", "color", cxxopts::value()); +} + +void add_boost_options(boost::program_options::options_description& options, + bench::ManyValues& values) { + namespace po = boost::program_options; + options.add_options()("name", po::value(&values.name))( + "output", po::value(&values.output))( + "target", po::value(&values.target))( + "define", po::value(&values.define))( + "jobs", po::value(&values.jobs))( + "retries", po::value(&values.retries))("port", + po::value(&values.port))( + "depth", po::value(&values.depth))("ratio", + po::value(&values.ratio))( + "timeout", po::value(&values.timeout))( + "threshold", po::value(&values.threshold))( + "scale", po::value(&values.scale))( + "config", po::value(&values.config))( + "cache", po::value(&values.cache))( + "mode", po::value(&values.mode))( + "color", po::value(&values.color)); +} + +void BM_Cli20ManyOptions(benchmark::State& state) { + auto& args = many_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + auto result = cli::Parser{}.parse(args.span(), 1); + benchmark::DoNotOptimize(result); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_Cli11ManyOptions(benchmark::State& state) { + auto& args = many_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + CLI::App app{"many_options"}; + bench::ManyValues values; + add_cli11_options(app, values); + app.parse(args.argc(), args.argv()); + benchmark::DoNotOptimize(values); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_ArgparseManyOptions(benchmark::State& state) { + auto& args = many_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + argparse::ArgumentParser program{"many_options"}; + add_argparse_options(program); + program.parse_args(args.argc(), args.argv()); + benchmark::DoNotOptimize(program); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_CxxoptsManyOptions(benchmark::State& state) { + auto& args = many_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + cxxopts::Options options{"many_options"}; + add_cxxopts_options(options); + auto result = options.parse(args.argc(), args.argv()); + benchmark::DoNotOptimize(result); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_BoostManyOptions(benchmark::State& state) { + auto& args = many_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + boost::program_options::options_description options{"many_options"}; + bench::ManyValues values; + add_boost_options(options, values); + boost::program_options::variables_map parsed; + boost::program_options::store( + boost::program_options::command_line_parser(args.argc(), args.argv()) + .options(options) + .run(), + parsed); + boost::program_options::notify(parsed); + benchmark::DoNotOptimize(values); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +BENCHMARK(BM_Cli20ManyOptions); +BENCHMARK(BM_Cli11ManyOptions); +BENCHMARK(BM_ArgparseManyOptions); +BENCHMARK(BM_CxxoptsManyOptions); +BENCHMARK(BM_BoostManyOptions); + +} // namespace diff --git a/bench/runtime/simple.cc b/bench/runtime/simple.cc new file mode 100644 index 0000000..1454d3f --- /dev/null +++ b/bench/runtime/simple.cc @@ -0,0 +1,146 @@ +#include + +#include +#include +#include +#include +#include + +#include "bench/allocation_counter.hh" +#include "bench/argv.hh" +#include "cli/argument.hh" +#include "cli/parser.hh" + +namespace { + +namespace po = boost::program_options; + +struct Cli20Simple { + cli::Flag<"verbose", 'v'> verbose; + cli::StringOption<"output", 'o'> output; + cli::Positional input{{.presence = cli::required}}; +}; + +auto simple_argv() -> bench::Argv& { + static bench::Argv args{"prog", "-v", "-o", "out.txt", "input.txt"}; + return args; +} + +void BM_Cli20Simple(benchmark::State& state) { + auto& args = simple_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + auto result = cli::Parser{}.parse(args.span(), 1); + benchmark::DoNotOptimize(result); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_Cli11Simple(benchmark::State& state) { + auto& args = simple_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + CLI::App app{"simple"}; + bool verbose = false; + std::string output; + std::string input; + app.add_flag("-v,--verbose", verbose); + app.add_option("-o,--output", output); + app.add_option("input", input)->required(); + app.parse(args.argc(), args.argv()); + benchmark::DoNotOptimize(verbose); + benchmark::DoNotOptimize(output); + benchmark::DoNotOptimize(input); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_ArgparseSimple(benchmark::State& state) { + auto& args = simple_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + argparse::ArgumentParser program{"simple"}; + program.add_argument("-v", "--verbose").flag(); + program.add_argument("-o", "--output"); + program.add_argument("input"); + program.parse_args(args.argc(), args.argv()); + benchmark::DoNotOptimize(program); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_CxxoptsSimple(benchmark::State& state) { + auto& args = simple_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + cxxopts::Options options{"simple"}; + options.add_options()("v,verbose", "verbose", + cxxopts::value()->default_value("false"))( + "o,output", "output", cxxopts::value())( + "input", "input", cxxopts::value()); + options.parse_positional({"input"}); + auto result = options.parse(args.argc(), args.argv()); + benchmark::DoNotOptimize(result); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_BoostSimple(benchmark::State& state) { + auto& args = simple_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + bool verbose = false; + std::string output; + std::string input; + po::options_description options{"simple"}; + options.add_options()("verbose,v", po::bool_switch(&verbose))( + "output,o", po::value(&output))( + "input", po::value(&input)->required()); + po::positional_options_description positional; + positional.add("input", 1); + po::variables_map values; + po::store(po::command_line_parser(args.argc(), args.argv()) + .options(options) + .positional(positional) + .run(), + values); + po::notify(values); + benchmark::DoNotOptimize(verbose); + benchmark::DoNotOptimize(output); + benchmark::DoNotOptimize(input); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +BENCHMARK(BM_Cli20Simple); +BENCHMARK(BM_Cli11Simple); +BENCHMARK(BM_ArgparseSimple); +BENCHMARK(BM_CxxoptsSimple); +BENCHMARK(BM_BoostSimple); + +} // namespace diff --git a/bench/runtime/subcommand.cc b/bench/runtime/subcommand.cc new file mode 100644 index 0000000..f8b9a48 --- /dev/null +++ b/bench/runtime/subcommand.cc @@ -0,0 +1,149 @@ +#include + +#include +#include +#include +#include +#include + +#include "bench/allocation_counter.hh" +#include "bench/argv.hh" +#include "cli/argument.hh" +#include "cli/parser.hh" + +namespace { + +namespace po = boost::program_options; + +struct Cli20Build { + cli::StringOption<"profile"> profile; +}; + +struct Cli20Test { + cli::Flag<"verbose", 'v'> verbose; +}; + +struct Cli20Subcommand { + cli::Command<"build", Cli20Build> build; + cli::Command<"test", Cli20Test> test; +}; + +auto subcommand_argv() -> bench::Argv& { + static bench::Argv args{"prog", "build", "--profile", "release"}; + return args; +} + +void BM_Cli20Subcommand(benchmark::State& state) { + auto& args = subcommand_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + auto result = cli::Parser{}.parse(args.span(), 1); + benchmark::DoNotOptimize(result); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_Cli11Subcommand(benchmark::State& state) { + auto& args = subcommand_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + CLI::App app{"subcommand"}; + std::string profile; + bool verbose = false; + auto* build = app.add_subcommand("build"); + build->add_option("--profile", profile); + auto* test = app.add_subcommand("test"); + test->add_flag("-v,--verbose", verbose); + app.parse(args.argc(), args.argv()); + benchmark::DoNotOptimize(profile); + benchmark::DoNotOptimize(verbose); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_ArgparseSubcommand(benchmark::State& state) { + auto& args = subcommand_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + argparse::ArgumentParser program{"subcommand"}; + argparse::ArgumentParser build{"build"}; + build.add_argument("--profile"); + argparse::ArgumentParser test{"test"}; + test.add_argument("-v", "--verbose").flag(); + program.add_subparser(build); + program.add_subparser(test); + program.parse_args(args.argc(), args.argv()); + benchmark::DoNotOptimize(program); + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_CxxoptsSubcommand(benchmark::State& state) { + auto& args = subcommand_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + const std::string command = args.storage[1]; + if (command == "build") { + cxxopts::Options options{"build"}; + options.add_options()("profile", "profile", + cxxopts::value()); + auto result = options.parse(args.argc() - 1, args.argv() + 1); + benchmark::DoNotOptimize(result); + } + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +void BM_BoostSubcommand(benchmark::State& state) { + auto& args = subcommand_argv(); + std::size_t allocations = 0; + std::size_t bytes = 0; + for (auto _ : state) { + const auto stats = bench::measure_allocations([&] { + const std::string command = args.storage[1]; + if (command == "build") { + std::string profile; + po::options_description options{"build"}; + options.add_options()("profile", po::value(&profile)); + po::variables_map values; + po::store(po::command_line_parser(args.argc() - 1, args.argv() + 1) + .options(options) + .run(), + values); + po::notify(values); + benchmark::DoNotOptimize(profile); + } + }); + allocations += stats.allocations; + bytes += stats.bytes; + } + bench::set_allocation_counters(state, allocations, bytes); +} + +BENCHMARK(BM_Cli20Subcommand); +BENCHMARK(BM_Cli11Subcommand); +BENCHMARK(BM_ArgparseSubcommand); +BENCHMARK(BM_CxxoptsSubcommand); +BENCHMARK(BM_BoostSubcommand); + +} // namespace diff --git a/bench/scripts/export-doc-data.py b/bench/scripts/export-doc-data.py new file mode 100644 index 0000000..ed85ee9 --- /dev/null +++ b/bench/scripts/export-doc-data.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import csv +import json +import re +import sys +from pathlib import Path + + +LIBRARY_NAMES = { + "Cli20": "cli20", + "Cli11": "cli11", + "Argparse": "argparse", + "Cxxopts": "cxxopts", + "Boost": "boost", +} + +CASE_NAMES = { + "Simple": "simple", + "ManyOptions": "many_options", + "Subcommand": "subcommand", +} + +RUNTIME_RE = re.compile( + r"^BM_(Cli20|Cli11|Argparse|Cxxopts|Boost)(Simple|ManyOptions|Subcommand)$" +) + + +def read_runtime(path: Path, standard: str) -> list[dict[str, object]]: + data = json.loads(path.read_text()) + rows: list[dict[str, object]] = [] + for bench in data.get("benchmarks", []): + match = RUNTIME_RE.match(str(bench.get("name", ""))) + if not match: + continue + library_name, case_name = match.groups() + rows.append( + { + "standard": standard, + "case": CASE_NAMES[case_name], + "library": LIBRARY_NAMES[library_name], + "timeNs": bench.get("real_time", 0), + "cpuNs": bench.get("cpu_time", 0), + "allocations": bench.get("allocs", 0), + "allocatedBytes": bench.get("bytes", 0), + } + ) + return rows + + +def read_size(path: Path) -> list[dict[str, object]]: + if not path.exists(): + return [] + with path.open(newline="") as file: + rows = csv.DictReader(file, delimiter="\t") + return [ + { + "standard": row["standard"].replace("++", "xx"), + "case": row["case"], + "library": row["library"], + "unstrippedBytes": int(row["unstripped_bytes"]), + "strippedBytes": int(row["stripped_bytes"]), + } + for row in rows + if row.get("stripped_bytes", "").isdigit() + ] + + +def read_compile_time(path: Path) -> list[dict[str, object]]: + if not path.exists(): + return [] + with path.open(newline="") as file: + rows = csv.DictReader(file, delimiter="\t") + return [ + { + "standard": row["standard"].replace("++", "xx"), + "case": row["case"], + "library": row["library"], + "seconds": float(row["seconds"]), + } + for row in rows + ] + + +def collect(result_dir: Path) -> dict[str, object]: + runtime_rows: list[dict[str, object]] = [] + size_rows: list[dict[str, object]] = [] + compile_rows: list[dict[str, object]] = [] + + for runtime_path in sorted(result_dir.glob("runtime_cxx*.json")): + standard = runtime_path.stem.removeprefix("runtime_") + runtime_rows.extend(read_runtime(runtime_path, standard)) + + for size_path in sorted(result_dir.glob("size_cxx*.tsv")): + size_rows.extend(read_size(size_path)) + + for compile_path in sorted(result_dir.glob("compile_time/cxx*/results.tsv")): + compile_rows.extend(read_compile_time(compile_path)) + + standards = sorted( + { + str(row["standard"]) + for row in [*runtime_rows, *size_rows, *compile_rows] + } + ) + cases = ["simple", "many_options", "subcommand"] + libraries = ["cli20", "cli11", "argparse", "cxxopts", "boost"] + + return { + "schemaVersion": 1, + "standards": standards, + "cases": cases, + "libraries": libraries, + "runtime": runtime_rows, + "size": size_rows, + "compileTime": compile_rows, + } + + +def main() -> None: + if len(sys.argv) != 3: + print("usage: export-doc-data.py RESULT_DIR OUTPUT_JSON", file=sys.stderr) + raise SystemExit(2) + + result_dir = Path(sys.argv[1]).resolve() + output = Path(sys.argv[2]).resolve() + data = collect(result_dir) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") + + +if __name__ == "__main__": + main() diff --git a/bench/scripts/run-runtime.sh b/bench/scripts/run-runtime.sh new file mode 100755 index 0000000..3b9ba13 --- /dev/null +++ b/bench/scripts/run-runtime.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +cxx_standard="${BENCH_CXX_STANDARD:-20}" +build_dir="${BUILD_DIR:-$repo_root/build-bench/cxx$cxx_standard}" +results_dir="${RESULTS_DIR:-$repo_root/bench/results}" + +cmake -S "$repo_root/bench" -B "$build_dir" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBENCH_CXX_STANDARD="$cxx_standard" +cmake --build "$build_dir" --target bench_runtime + +mkdir -p "$results_dir" +"$build_dir/runtime/bench_runtime" \ + --benchmark_out="$results_dir/runtime_cxx$cxx_standard.json" \ + --benchmark_out_format=json \ + "$@" diff --git a/bench/scripts/run-size.sh b/bench/scripts/run-size.sh new file mode 100755 index 0000000..dcc4a5a --- /dev/null +++ b/bench/scripts/run-size.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +cxx_standard="${BENCH_CXX_STANDARD:-20}" +build_dir="${BUILD_DIR:-$repo_root/build-bench/cxx$cxx_standard}" +results_dir="${RESULTS_DIR:-$repo_root/bench/results}" +strip_cmd="${STRIP:-strip}" + +cmake -S "$repo_root/bench" -B "$build_dir" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBENCH_CXX_STANDARD="$cxx_standard" +cmake --build "$build_dir" --target all + +mkdir -p "$results_dir" +result_file="$results_dir/size_cxx$cxx_standard.tsv" +stripped_dir="$results_dir/stripped/cxx$cxx_standard" +mkdir -p "$stripped_dir" +printf "standard\tcase\tlibrary\tunstripped_bytes\tstripped_bytes\n" > "$result_file" + +for binary in "$build_dir"/size/bench_size_*; do + [ -x "$binary" ] || continue + name="$(basename "$binary")" + rest="${name#bench_size_}" + library="${rest##*_}" + case_name="${rest%_$library}" + bytes="$(wc -c < "$binary" | tr -d ' ')" + stripped="$stripped_dir/$name" + cp "$binary" "$stripped" + if "$strip_cmd" "$stripped" >/dev/null 2>&1; then + stripped_bytes="$(wc -c < "$stripped" | tr -d ' ')" + else + stripped_bytes="strip_failed" + fi + printf "c++%s\t%s\t%s\t%s\t%s\n" \ + "$cxx_standard" "$case_name" "$library" "$bytes" "$stripped_bytes" | + tee -a "$result_file" +done diff --git a/bench/scripts/summarize.py b/bench/scripts/summarize.py new file mode 100755 index 0000000..c63578c --- /dev/null +++ b/bench/scripts/summarize.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import csv +import json +import sys +from argparse import ArgumentParser +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +RESULTS = Path(__import__("os").environ.get("RESULTS_DIR", ROOT / "bench" / "results")) + + +def read_tsv(paths: list[Path]) -> list[list[str]]: + rows: list[list[str]] = [] + header: list[str] | None = None + for path in paths: + if not path.exists(): + continue + with path.open(newline="") as f: + file_rows = list(csv.reader(f, delimiter="\t")) + if not file_rows: + continue + if header is None: + header = file_rows[0] + rows.append(header) + rows.extend(file_rows[1:]) + return rows + + +def markdown_table(rows: list[tuple[str, ...]] | list[list[str]], title: str) -> str: + if not rows: + return "" + out = [f"## {title}", ""] + out.append("| " + " | ".join(rows[0]) + " |") + out.append("| " + " | ".join("---" for _ in rows[0]) + " |") + for row in rows[1:]: + out.append("| " + " | ".join(row) + " |") + out.append("") + return "\n".join(out) + + +def tsv_table(paths: list[Path], title: str) -> str: + return markdown_table(read_tsv(paths), title) + + +def runtime_table(paths: list[Path]) -> str: + rows = [("standard", "name", "time_ns", "cpu_ns", "allocs", "bytes")] + for path in paths: + if not path.exists(): + continue + standard = path.stem.removeprefix("runtime_") + data = json.loads(path.read_text()) + for bench in data.get("benchmarks", []): + rows.append( + ( + standard, + bench.get("name", ""), + f"{bench.get('real_time', 0):.2f}", + f"{bench.get('cpu_time', 0):.2f}", + f"{bench.get('allocs', 0):.2f}", + f"{bench.get('bytes', 0):.2f}", + ) + ) + return markdown_table(rows, "Runtime") + + +def main() -> None: + parser = ArgumentParser() + parser.add_argument("-o", "--output", type=Path) + args = parser.parse_args() + + sections = [ + "# cli20 benchmark report", + "", + runtime_table(sorted(RESULTS.glob("runtime_cxx*.json"))), + tsv_table(sorted(RESULTS.glob("size_cxx*.tsv")), "Binary Size"), + tsv_table( + sorted(RESULTS.glob("compile_time/cxx*/results.tsv")), + "Compile Time", + ), + ] + report = "\n".join(section for section in sections if section) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(report + "\n") + sys.stdout.write(report + "\n") + + +if __name__ == "__main__": + main() diff --git a/bench/size/CMakeLists.txt b/bench/size/CMakeLists.txt new file mode 100644 index 0000000..d785833 --- /dev/null +++ b/bench/size/CMakeLists.txt @@ -0,0 +1,21 @@ +set(BENCH_SIZE_CASES simple many_options subcommand) +set(BENCH_SIZE_LIBS cli20 cli11 argparse cxxopts boost) + +foreach(case_name IN LISTS BENCH_SIZE_CASES) + foreach(lib_name IN LISTS BENCH_SIZE_LIBS) + set(target_name "bench_size_${case_name}_${lib_name}") + set(source_file "${case_name}/${lib_name}.cc") + if(lib_name STREQUAL "cli20") + set(link_target bench_common) + elseif(lib_name STREQUAL "cli11") + set(link_target bench_cli11) + elseif(lib_name STREQUAL "argparse") + set(link_target bench_argparse) + elseif(lib_name STREQUAL "cxxopts") + set(link_target bench_cxxopts) + elseif(lib_name STREQUAL "boost") + set(link_target bench_boost_program_options) + endif() + bench_add_size_executable("${target_name}" "${source_file}" "${link_target}") + endforeach() +endforeach() diff --git a/bench/size/many_options/argparse.cc b/bench/size/many_options/argparse.cc new file mode 100644 index 0000000..50c956a --- /dev/null +++ b/bench/size/many_options/argparse.cc @@ -0,0 +1,47 @@ +#include + +enum class Mode { fast, balanced, precise }; + +inline auto parse_mode(std::string_view value) -> std::optional { + if (value == "fast") return Mode::fast; + if (value == "balanced") return Mode::balanced; + if (value == "precise") return Mode::precise; + return std::nullopt; +} + +inline auto parse_mode_or_throw(const std::string& value) -> Mode { + if (auto mode = parse_mode(value)) return *mode; + throw std::runtime_error("invalid mode: " + value); +} + +auto main(int argc, char** argv) -> int { + argparse::ArgumentParser program{"many_options"}; + program.add_argument("--name"); + program.add_argument("--output"); + program.add_argument("--target"); + program.add_argument("--define"); + program.add_argument("--jobs").scan<'i', int>(); + program.add_argument("--retries").scan<'i', int>(); + program.add_argument("--port").scan<'i', int>(); + program.add_argument("--depth").scan<'i', int>(); + program.add_argument("--ratio").scan<'g', double>(); + program.add_argument("--timeout").scan<'g', double>(); + program.add_argument("--threshold").scan<'g', double>(); + program.add_argument("--scale").scan<'g', double>(); + program.add_argument("--config") + .action([](const std::string& value) -> std::filesystem::path { + return std::filesystem::path{value}; + }); + program.add_argument("--cache").action( + [](const std::string& value) -> std::filesystem::path { + return std::filesystem::path{value}; + }); + program.add_argument("--mode").action(parse_mode_or_throw); + program.add_argument("--color").action(parse_mode_or_throw); + try { + program.parse_args(argc, argv); + } catch (const std::runtime_error&) { + return 1; + } + return 0; +} diff --git a/bench/size/many_options/boost.cc b/bench/size/many_options/boost.cc new file mode 100644 index 0000000..fafc816 --- /dev/null +++ b/bench/size/many_options/boost.cc @@ -0,0 +1,79 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace po = boost::program_options; + +enum class Mode { fast, balanced, precise }; + +inline auto parse_mode(std::string_view value) -> std::optional { + if (value == "fast") return Mode::fast; + if (value == "balanced") return Mode::balanced; + if (value == "precise") return Mode::precise; + return std::nullopt; +} + +inline void validate(boost::any& out, const std::vector& values, + Mode*, int) { + po::validators::check_first_occurrence(out); + const auto& value = po::validators::get_single_string(values); + if (auto mode = parse_mode(value)) { + out = *mode; + return; + } + throw po::validation_error(po::validation_error::invalid_option_value, value); +} + +struct ManyValues { + std::string name; + std::string output; + std::string target; + std::string define; + int jobs{}; + int retries{}; + int port{}; + int depth{}; + double ratio{}; + double timeout{}; + double threshold{}; + double scale{}; + std::filesystem::path config; + std::filesystem::path cache; + Mode mode{}; + Mode color{}; +}; + +auto main(int argc, char** argv) -> int { + po::options_description options{"many_options"}; + ManyValues values; + options.add_options()("name", po::value(&values.name))( + "output", po::value(&values.output))( + "target", po::value(&values.target))( + "define", po::value(&values.define))( + "jobs", po::value(&values.jobs))( + "retries", po::value(&values.retries))("port", + po::value(&values.port))( + "depth", po::value(&values.depth))("ratio", + po::value(&values.ratio))( + "timeout", po::value(&values.timeout))( + "threshold", po::value(&values.threshold))( + "scale", po::value(&values.scale))( + "config", po::value(&values.config))( + "cache", po::value(&values.cache))( + "mode", po::value(&values.mode))("color", + po::value(&values.color)); + try { + po::variables_map parsed; + po::store(po::command_line_parser(argc, argv).options(options).run(), + parsed); + po::notify(parsed); + } catch (const po::error&) { + return 1; + } + return 0; +} diff --git a/bench/size/many_options/cli11.cc b/bench/size/many_options/cli11.cc new file mode 100644 index 0000000..b74a428 --- /dev/null +++ b/bench/size/many_options/cli11.cc @@ -0,0 +1,56 @@ +#include + +enum class Mode { fast, balanced, precise }; + +struct ManyValues { + std::string name; + std::string output; + std::string target; + std::string define; + int jobs{}; + int retries{}; + int port{}; + int depth{}; + double ratio{}; + double timeout{}; + double threshold{}; + double scale{}; + std::filesystem::path config; + std::filesystem::path cache; + Mode mode{}; + Mode color{}; +}; + +auto main(int argc, char** argv) -> int { + CLI::App app{"many_options"}; + ManyValues values; + static const std::map modes{ + {"fast", Mode::fast}, + {"balanced", Mode::balanced}, + {"precise", Mode::precise}, + }; + app.add_option("--name", values.name); + app.add_option("--output", values.output); + app.add_option("--target", values.target); + app.add_option("--define", values.define); + app.add_option("--jobs", values.jobs); + app.add_option("--retries", values.retries); + app.add_option("--port", values.port); + app.add_option("--depth", values.depth); + app.add_option("--ratio", values.ratio); + app.add_option("--timeout", values.timeout); + app.add_option("--threshold", values.threshold); + app.add_option("--scale", values.scale); + app.add_option("--config", values.config); + app.add_option("--cache", values.cache); + app.add_option("--mode", values.mode) + ->transform(CLI::CheckedTransformer(modes)); + app.add_option("--color", values.color) + ->transform(CLI::CheckedTransformer(modes)); + try { + app.parse(argc, argv); + } catch (const CLI::ParseError& e) { + return app.exit(e); + } + return 0; +} diff --git a/bench/size/many_options/cli20.cc b/bench/size/many_options/cli20.cc new file mode 100644 index 0000000..0067072 --- /dev/null +++ b/bench/size/many_options/cli20.cc @@ -0,0 +1,38 @@ +#include + +enum class Mode { fast, balanced, precise }; + +inline auto parse_mode(std::string_view value) -> std::optional { + if (value == "fast") return Mode::fast; + if (value == "balanced") return Mode::balanced; + if (value == "precise") return Mode::precise; + return std::nullopt; +} + +struct Args { + cli::StringOption<"name"> name; + cli::StringOption<"output"> output; + cli::StringOption<"target"> target; + cli::StringOption<"define"> define; + cli::IntOption<"jobs"> jobs; + cli::IntOption<"retries"> retries; + cli::IntOption<"port"> port; + cli::IntOption<"depth"> depth; + cli::DoubleOption<"ratio"> ratio; + cli::DoubleOption<"timeout"> timeout; + cli::DoubleOption<"threshold"> threshold; + cli::DoubleOption<"scale"> scale; + cli::PathOption<"config"> config; + cli::PathOption<"cache"> cache; + cli::Arg<"mode", + cli::conversion::choice | cli::pack::set_once> + mode; + cli::Arg<"color", + cli::conversion::choice | cli::pack::set_once> + color; +}; + +auto main(int argc, char** argv) -> int { + auto parsed = cli::Parser{}.parse(argc, argv); + return parsed.has_value() ? 0 : 1; +} diff --git a/bench/size/many_options/cxxopts.cc b/bench/size/many_options/cxxopts.cc new file mode 100644 index 0000000..f3404e8 --- /dev/null +++ b/bench/size/many_options/cxxopts.cc @@ -0,0 +1,68 @@ +#include + +enum class Mode { fast, balanced, precise }; + +inline auto mode_name(Mode mode) -> std::string_view { + switch (mode) { + case Mode::fast: + return "fast"; + case Mode::balanced: + return "balanced"; + case Mode::precise: + return "precise"; + } + return "unknown"; +} + +inline auto parse_mode(std::string_view value) -> std::optional { + if (value == "fast") return Mode::fast; + if (value == "balanced") return Mode::balanced; + if (value == "precise") return Mode::precise; + return std::nullopt; +} + +inline auto parse_mode_or_throw(const std::string& value) -> Mode { + if (auto mode = parse_mode(value)) return *mode; + throw std::runtime_error("invalid mode: " + value); +} + +inline auto operator>>(std::istream& in, Mode& mode) -> std::istream& { + std::string value; + in >> value; + if (auto parsed = parse_mode(value)) { + mode = *parsed; + } else { + in.setstate(std::ios::failbit); + } + return in; +} + +inline auto operator<<(std::ostream& out, Mode mode) -> std::ostream& { + return out << mode_name(mode); +} + +auto main(int argc, char** argv) -> int { + cxxopts::Options options{"many_options"}; + options.add_options()("name", "name", cxxopts::value())( + "output", "output", cxxopts::value())( + "target", "target", cxxopts::value())( + "define", "define", cxxopts::value())("jobs", "jobs", + cxxopts::value())( + "retries", "retries", cxxopts::value())("port", "port", + cxxopts::value())( + "depth", "depth", cxxopts::value())("ratio", "ratio", + cxxopts::value())( + "timeout", "timeout", cxxopts::value())("threshold", "threshold", + cxxopts::value())( + "scale", "scale", cxxopts::value())( + "config", "config", cxxopts::value())( + "cache", "cache", cxxopts::value())( + "mode", "mode", cxxopts::value())("color", "color", + cxxopts::value()); + try { + (void)options.parse(argc, argv); + } catch (const cxxopts::exceptions::exception&) { + return 1; + } + return 0; +} diff --git a/bench/size/simple/argparse.cc b/bench/size/simple/argparse.cc new file mode 100644 index 0000000..6414088 --- /dev/null +++ b/bench/size/simple/argparse.cc @@ -0,0 +1,14 @@ +#include + +auto main(int argc, char** argv) -> int { + argparse::ArgumentParser program{"simple"}; + program.add_argument("-v", "--verbose").flag(); + program.add_argument("-o", "--output"); + program.add_argument("input"); + try { + program.parse_args(argc, argv); + } catch (const std::runtime_error&) { + return 1; + } + return 0; +} diff --git a/bench/size/simple/boost.cc b/bench/size/simple/boost.cc new file mode 100644 index 0000000..6175fcc --- /dev/null +++ b/bench/size/simple/boost.cc @@ -0,0 +1,28 @@ +#include +#include + +namespace po = boost::program_options; + +auto main(int argc, char** argv) -> int { + bool verbose = false; + std::string output; + std::string input; + po::options_description options{"simple"}; + options.add_options()("verbose,v", po::bool_switch(&verbose))( + "output,o", po::value(&output))( + "input", po::value(&input)->required()); + po::positional_options_description positional; + positional.add("input", 1); + try { + po::variables_map values; + po::store(po::command_line_parser(argc, argv) + .options(options) + .positional(positional) + .run(), + values); + po::notify(values); + } catch (const po::error&) { + return 1; + } + return 0; +} diff --git a/bench/size/simple/cli11.cc b/bench/size/simple/cli11.cc new file mode 100644 index 0000000..d94e2e4 --- /dev/null +++ b/bench/size/simple/cli11.cc @@ -0,0 +1,18 @@ +#include +#include + +auto main(int argc, char** argv) -> int { + CLI::App app{"simple"}; + bool verbose = false; + std::string output; + std::string input; + app.add_flag("-v,--verbose", verbose); + app.add_option("-o,--output", output); + app.add_option("input", input)->required(); + try { + app.parse(argc, argv); + } catch (const CLI::ParseError& e) { + return app.exit(e); + } + return 0; +} diff --git a/bench/size/simple/cli20.cc b/bench/size/simple/cli20.cc new file mode 100644 index 0000000..1d75099 --- /dev/null +++ b/bench/size/simple/cli20.cc @@ -0,0 +1,15 @@ +#include + +#include "cli/argument.hh" +#include "cli/parser.hh" + +struct Args { + cli::Flag<"verbose", 'v'> verbose; + cli::StringOption<"output", 'o'> output; + cli::Positional input{{.presence = cli::required}}; +}; + +auto main(int argc, char** argv) -> int { + auto parsed = cli::Parser{}.parse(argc, argv); + return parsed.has_value() ? 0 : 1; +} diff --git a/bench/size/simple/cxxopts.cc b/bench/size/simple/cxxopts.cc new file mode 100644 index 0000000..e11266a --- /dev/null +++ b/bench/size/simple/cxxopts.cc @@ -0,0 +1,17 @@ +#include +#include + +auto main(int argc, char** argv) -> int { + cxxopts::Options options{"simple"}; + options.add_options()("v,verbose", "verbose", + cxxopts::value()->default_value("false"))( + "o,output", "output", cxxopts::value())( + "input", "input", cxxopts::value()); + options.parse_positional({"input"}); + try { + (void)options.parse(argc, argv); + } catch (const cxxopts::exceptions::exception&) { + return 1; + } + return 0; +} diff --git a/bench/size/subcommand/argparse.cc b/bench/size/subcommand/argparse.cc new file mode 100644 index 0000000..b665797 --- /dev/null +++ b/bench/size/subcommand/argparse.cc @@ -0,0 +1,17 @@ +#include + +auto main(int argc, char** argv) -> int { + argparse::ArgumentParser program{"subcommand"}; + argparse::ArgumentParser build{"build"}; + build.add_argument("--profile"); + argparse::ArgumentParser test{"test"}; + test.add_argument("-v", "--verbose").flag(); + program.add_subparser(build); + program.add_subparser(test); + try { + program.parse_args(argc, argv); + } catch (const std::runtime_error&) { + return 1; + } + return 0; +} diff --git a/bench/size/subcommand/boost.cc b/bench/size/subcommand/boost.cc new file mode 100644 index 0000000..5cb9231 --- /dev/null +++ b/bench/size/subcommand/boost.cc @@ -0,0 +1,36 @@ +#include +#include + +namespace po = boost::program_options; + +auto main(int argc, char** argv) -> int { + if (argc < 2) return 1; + const std::string command = argv[1]; + try { + if (command == "build") { + std::string profile; + po::options_description options{"build"}; + options.add_options()("profile", po::value(&profile)); + po::variables_map values; + po::store( + po::command_line_parser(argc - 1, argv + 1).options(options).run(), + values); + po::notify(values); + return 0; + } + if (command == "test") { + bool verbose = false; + po::options_description options{"test"}; + options.add_options()("verbose,v", po::bool_switch(&verbose)); + po::variables_map values; + po::store( + po::command_line_parser(argc - 1, argv + 1).options(options).run(), + values); + po::notify(values); + return 0; + } + } catch (const po::error&) { + return 1; + } + return 1; +} diff --git a/bench/size/subcommand/cli11.cc b/bench/size/subcommand/cli11.cc new file mode 100644 index 0000000..4a73dff --- /dev/null +++ b/bench/size/subcommand/cli11.cc @@ -0,0 +1,18 @@ +#include +#include + +auto main(int argc, char** argv) -> int { + CLI::App app{"subcommand"}; + std::string profile; + bool verbose = false; + auto* build = app.add_subcommand("build"); + build->add_option("--profile", profile); + auto* test = app.add_subcommand("test"); + test->add_flag("-v,--verbose", verbose); + try { + app.parse(argc, argv); + } catch (const CLI::ParseError& e) { + return app.exit(e); + } + return 0; +} diff --git a/bench/size/subcommand/cli20.cc b/bench/size/subcommand/cli20.cc new file mode 100644 index 0000000..16c03dd --- /dev/null +++ b/bench/size/subcommand/cli20.cc @@ -0,0 +1,20 @@ +#include "cli/argument.hh" +#include "cli/parser.hh" + +struct BuildArgs { + cli::StringOption<"profile"> profile; +}; + +struct TestArgs { + cli::Flag<"verbose", 'v'> verbose; +}; + +struct Args { + cli::Command<"build", BuildArgs> build; + cli::Command<"test", TestArgs> test; +}; + +auto main(int argc, char** argv) -> int { + auto parsed = cli::Parser{}.parse(argc, argv); + return parsed.has_value() ? 0 : 1; +} diff --git a/bench/size/subcommand/cxxopts.cc b/bench/size/subcommand/cxxopts.cc new file mode 100644 index 0000000..cb0c734 --- /dev/null +++ b/bench/size/subcommand/cxxopts.cc @@ -0,0 +1,25 @@ +#include +#include + +auto main(int argc, char** argv) -> int { + if (argc < 2) return 1; + const std::string command = argv[1]; + try { + if (command == "build") { + cxxopts::Options options{"build"}; + options.add_options()("profile", "profile", cxxopts::value()); + (void)options.parse(argc - 1, argv + 1); + return 0; + } + if (command == "test") { + cxxopts::Options options{"test"}; + options.add_options()("v,verbose", "verbose", + cxxopts::value()->default_value("false")); + (void)options.parse(argc - 1, argv + 1); + return 0; + } + } catch (const cxxopts::exceptions::exception&) { + return 1; + } + return 1; +} diff --git a/docs/docs/actions.mdx b/docs/docs/actions.mdx index a89a92e..f46d615 100644 --- a/docs/docs/actions.mdx +++ b/docs/docs/actions.mdx @@ -384,7 +384,7 @@ struct DivisibleBy { cli::detail::validation_failed_error( ctx.index, cli::detail::to_error_subject(input.value), - std::format("must be divisible by {}", Divisor))); + "must be divisible by " + std::to_string(Divisor))); return input; } }; diff --git a/docs/docs/benchmarks.mdx b/docs/docs/benchmarks.mdx new file mode 100644 index 0000000..c280826 --- /dev/null +++ b/docs/docs/benchmarks.mdx @@ -0,0 +1,48 @@ +--- +sidebar_position: 5 +title: Benchmarks +--- + +import BenchmarkCharts from '@site/src/components/BenchmarkCharts'; + +# Benchmarks + +This page compares cli20 against CLI11, argparse, cxxopts, and +Boost.Program_options across three representative schemas. All benchmark +metrics on this page are **smaller is better**. + +- `simple`: one flag, one option, and one required positional argument. +- `many_options`: strings, integers, doubles, paths, and enum-like choices. +- `subcommand`: a small command tree with `build` and `test`. + +Boost.Program_options is shown as a reference value because it links against +the Boost.Program_options shared library. Its binary-size numbers are therefore +not directly comparable with the mostly header-only libraries. + +## Results + + + +## Benchmark Source + +The benchmark code is kept in the repository so every number on this page can +be traced back to the parser setup being measured. + +| Area | Source | +|---|---| +| Runtime benchmarks | [`bench/runtime/`](https://github.com/CLI20-dev/cli20/tree/main/bench/runtime) | +| Binary size benchmarks | [`bench/size/`](https://github.com/CLI20-dev/cli20/tree/main/bench/size) | +| Compile-time benchmarks | [`bench/compile_time/`](https://github.com/CLI20-dev/cli20/tree/main/bench/compile_time) | +| Shared argv/allocation helpers | [`bench/include/bench/`](https://github.com/CLI20-dev/cli20/tree/main/bench/include/bench) | +| Result summarization | [`bench/scripts/`](https://github.com/CLI20-dev/cli20/tree/main/bench/scripts) | + +## Method + +Runtime benchmarks construct the parser and parse the same argv every +iteration. Allocation counts are measured inside the same operation. + +Binary size benchmarks build one executable per library and case, then record +both the unstripped and stripped file sizes. + +Compile-time benchmarks compile one translation unit per library and case with +`hyperfine`, using the same benchmark source as the binary-size targets. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 2880a57..cbee72b 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -11,6 +11,7 @@ const sidebars: SidebarsConfig = { label: 'Examples', items: [{type: 'autogenerated', dirName: 'examples'}], }, + 'benchmarks', ], }; diff --git a/docs/src/components/BenchmarkCharts.jsx b/docs/src/components/BenchmarkCharts.jsx new file mode 100644 index 0000000..0989813 --- /dev/null +++ b/docs/src/components/BenchmarkCharts.jsx @@ -0,0 +1,285 @@ +import React, {useMemo, useState} from 'react'; +import results from '@site/src/data/benchmarks/results.json'; + +const caseLabels = { + simple: 'Simple', + many_options: 'Many options', + subcommand: 'Subcommand', +}; + +const caseColors = { + simple: '#2563eb', + many_options: '#b45309', + subcommand: '#0f766e', +}; + +const caseDash = { + simple: '', + many_options: '7 4', + subcommand: '2 4', +}; + +const libraryLabels = { + cli20: 'cli20', + cli11: 'CLI11', + argparse: 'argparse', + cxxopts: 'cxxopts', + boost: 'Boost*', +}; + +function number(value) { + return new Intl.NumberFormat('en', { + maximumFractionDigits: value < 10 ? 2 : 1, + }).format(value); +} + +function bytes(value) { + if (value >= 1024 * 1024) return `${number(value / (1024 * 1024))} MiB`; + if (value >= 1024) return `${number(value / 1024)} KiB`; + return `${number(value)} B`; +} + +function metricValue(row, metric) { + if (metric === 'runtime') return row.timeNs; + if (metric === 'allocations') return row.allocations; + if (metric === 'allocatedBytes') return row.allocatedBytes; + if (metric === 'binarySize') return row.strippedBytes; + if (metric === 'compileTime') return row.seconds; + return 0; +} + +function formatMetric(value, metric) { + if (metric === 'runtime') return `${number(value)} ns`; + if (metric === 'allocations') return `${number(value)} allocs`; + if (metric === 'allocatedBytes' || metric === 'binarySize') return bytes(value); + if (metric === 'compileTime') return `${number(value)} s`; + return number(value); +} + +function rowsFor(metric) { + if (metric === 'binarySize') return results.size; + if (metric === 'compileTime') return results.compileTime; + return results.runtime; +} + +function LineChart({title, metric, standard}) { + const rows = rowsFor(metric).filter((row) => row.standard === standard); + const max = Math.max(...rows.map((row) => metricValue(row, metric)), 1); + const width = 760; + const height = 320; + const padding = {top: 18, right: 28, bottom: 68, left: 82}; + const plotWidth = width - padding.left - padding.right; + const plotHeight = height - padding.top - padding.bottom; + const xFor = (index) => + padding.left + (plotWidth * index) / Math.max(results.libraries.length - 1, 1); + const yFor = (value) => padding.top + plotHeight - (value / max) * plotHeight; + const ticks = [0, 0.25, 0.5, 0.75, 1]; + + return ( +
+
+

{title}

+ smaller is better +
+
+ + {ticks.map((tick) => { + const value = max * tick; + const y = yFor(value); + return ( + + + + {formatMetric(value, metric)} + + + ); + })} + + + {results.libraries.map((library, index) => ( + + + + {libraryLabels[library] ?? library} + + + ))} + {results.cases.map((caseName) => { + const points = results.libraries + .map((library, index) => { + const row = rows.find((item) => item.case === caseName && item.library === library); + if (!row) return null; + return { + x: xFor(index), + y: yFor(metricValue(row, metric)), + value: metricValue(row, metric), + library, + }; + }) + .filter(Boolean); + const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' '); + return ( + + + {points.map((point) => ( + + + + {`${caseLabels[caseName] ?? caseName} / ${ + libraryLabels[point.library] ?? point.library + }: ${formatMetric(point.value, metric)}`} + + + ))} + + ); + })} + +
+ {results.cases.map((caseName) => ( + + + {caseLabels[caseName] ?? caseName} + + ))} +
+
+
+ ); +} + +function ResultTable({standard}) { + const rows = useMemo(() => { + return results.runtime + .filter((row) => row.standard === standard) + .map((runtimeRow) => { + const sizeRow = results.size.find( + (row) => + row.standard === standard && + row.case === runtimeRow.case && + row.library === runtimeRow.library, + ); + const compileRow = results.compileTime.find( + (row) => + row.standard === standard && + row.case === runtimeRow.case && + row.library === runtimeRow.library, + ); + return {runtimeRow, sizeRow, compileRow}; + }); + }, [standard]); + + return ( +
+ + + + + + + + + + + + + + {rows.map(({runtimeRow, sizeRow, compileRow}) => ( + + + + + + + + + + ))} + +
CaseLibraryRuntimeAllocsBytesStripped sizeCompile time
{caseLabels[runtimeRow.case] ?? runtimeRow.case}{libraryLabels[runtimeRow.library] ?? runtimeRow.library}{formatMetric(runtimeRow.timeNs, 'runtime')}{formatMetric(runtimeRow.allocations, 'allocations')}{formatMetric(runtimeRow.allocatedBytes, 'allocatedBytes')}{sizeRow ? formatMetric(sizeRow.strippedBytes, 'binarySize') : '-'}{compileRow ? formatMetric(compileRow.seconds, 'compileTime') : '-'}
+
+ ); +} + +export default function BenchmarkCharts() { + const standards = results.standards.length > 0 ? results.standards : ['cxx20']; + const [standard, setStandard] = useState(standards[0]); + + if (results.runtime.length === 0) { + return ( +
+ Benchmark data has not been generated yet. +
+ ); + } + + return ( +
+
+ Language mode + {standards.map((item) => ( + + ))} +
+ + + + + +

+ * Boost.Program_options is a reference value: it links against the + Boost.Program_options shared library, so binary-size results do not + represent a fully self-contained executable in the same way as the + header-oriented libraries. +

+ +
+ ); +} diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 28a2957..27b270c 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -165,6 +165,164 @@ 50% { background: transparent; color: #e6edf3; } } +/* ── Benchmark charts ───────────────────────────────────────────── */ + +.bench-results { + display: flex; + flex-direction: column; + gap: 1.25rem; + margin: 1.5rem 0; +} + +.bench-toolbar { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.bench-toolbar span { + color: var(--ifm-color-emphasis-700); + font-size: 0.9rem; + font-weight: 700; + margin-right: 0.25rem; +} + +.bench-tab { + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 999px; + color: var(--ifm-color-emphasis-800); + cursor: pointer; + font-size: 0.85rem; + font-weight: 700; + padding: 0.35rem 0.8rem; +} + +.bench-tab-active { + background: var(--ifm-color-primary); + border-color: var(--ifm-color-primary); + color: white; +} + +.bench-card { + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 8px; + padding: 1rem; +} + +.bench-card-heading { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0 0 1rem; +} + +.bench-card-heading h3 { + margin: 0; +} + +.bench-card-heading span { + color: var(--ifm-color-emphasis-600); + font-size: 0.82rem; + font-weight: 700; +} + +.bench-line-chart { + overflow-x: auto; +} + +.bench-line-chart svg { + display: block; + min-width: 720px; + width: 100%; +} + +.bench-axis { + stroke: var(--ifm-color-emphasis-600); + stroke-width: 1; +} + +.bench-grid { + stroke: var(--ifm-color-emphasis-200); + stroke-width: 1; +} + +.bench-x-tick { + stroke: var(--ifm-color-emphasis-600); + stroke-width: 1; +} + +.bench-axis-label, +.bench-x-label { + fill: var(--ifm-color-emphasis-700); + font-family: var(--ifm-font-family-monospace); + font-size: 12px; +} + +.bench-line { + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2.5; +} + +.bench-point { + stroke: var(--ifm-background-surface-color); + stroke-width: 1.5; +} + +.bench-legend { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + margin-top: 0.75rem; +} + +.bench-legend-item { + align-items: center; + color: var(--ifm-color-emphasis-800); + display: inline-flex; + font-size: 0.86rem; + font-weight: 700; + gap: 0.4rem; +} + +.bench-legend-line { + border-top-width: 2.5px; + display: inline-block; + width: 2rem; +} + +.bench-table-wrap { + overflow-x: auto; +} + +.bench-table { + font-size: 0.86rem; + min-width: 760px; +} + +.bench-empty { + background: var(--ifm-alert-background-color); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 12px; + padding: 1rem; +} + +.bench-note { + color: var(--ifm-color-emphasis-700); + font-size: 0.9rem; + margin: 0; +} + +@media (max-width: 640px) { + .bench-line-chart svg { + min-width: 680px; + } +} + /* transparent input overlay — keyboard capture only */ .pg-input-hidden { position: absolute; @@ -320,4 +478,3 @@ .pg-env-remove:hover { color: #f85149; } - diff --git a/docs/src/data/benchmarks/results.json b/docs/src/data/benchmarks/results.json new file mode 100644 index 0000000..a5f740e --- /dev/null +++ b/docs/src/data/benchmarks/results.json @@ -0,0 +1,685 @@ +{ + "cases": [ + "simple", + "many_options", + "subcommand" + ], + "compileTime": [ + { + "case": "simple", + "library": "cli20", + "seconds": 1.568617, + "standard": "cxx20" + }, + { + "case": "simple", + "library": "cli11", + "seconds": 3.743366, + "standard": "cxx20" + }, + { + "case": "simple", + "library": "argparse", + "seconds": 1.781824, + "standard": "cxx20" + }, + { + "case": "simple", + "library": "cxxopts", + "seconds": 2.084148, + "standard": "cxx20" + }, + { + "case": "simple", + "library": "boost", + "seconds": 0.88167, + "standard": "cxx20" + }, + { + "case": "many_options", + "library": "cli20", + "seconds": 2.155856, + "standard": "cxx20" + }, + { + "case": "many_options", + "library": "cli11", + "seconds": 3.508925, + "standard": "cxx20" + }, + { + "case": "many_options", + "library": "argparse", + "seconds": 1.82392, + "standard": "cxx20" + }, + { + "case": "many_options", + "library": "cxxopts", + "seconds": 2.189928, + "standard": "cxx20" + }, + { + "case": "many_options", + "library": "boost", + "seconds": 1.138985, + "standard": "cxx20" + }, + { + "case": "subcommand", + "library": "cli20", + "seconds": 1.587791, + "standard": "cxx20" + }, + { + "case": "subcommand", + "library": "cli11", + "seconds": 3.255969, + "standard": "cxx20" + }, + { + "case": "subcommand", + "library": "argparse", + "seconds": 1.582255, + "standard": "cxx20" + }, + { + "case": "subcommand", + "library": "cxxopts", + "seconds": 1.802998, + "standard": "cxx20" + }, + { + "case": "subcommand", + "library": "boost", + "seconds": 0.754448, + "standard": "cxx20" + }, + { + "case": "simple", + "library": "cli20", + "seconds": 1.236948, + "standard": "cxx26" + }, + { + "case": "simple", + "library": "cli11", + "seconds": 3.520713, + "standard": "cxx26" + }, + { + "case": "simple", + "library": "argparse", + "seconds": 1.70554, + "standard": "cxx26" + }, + { + "case": "simple", + "library": "cxxopts", + "seconds": 1.861667, + "standard": "cxx26" + }, + { + "case": "simple", + "library": "boost", + "seconds": 0.788324, + "standard": "cxx26" + }, + { + "case": "many_options", + "library": "cli20", + "seconds": 2.258437, + "standard": "cxx26" + }, + { + "case": "many_options", + "library": "cli11", + "seconds": 3.7834, + "standard": "cxx26" + }, + { + "case": "many_options", + "library": "argparse", + "seconds": 1.853235, + "standard": "cxx26" + }, + { + "case": "many_options", + "library": "cxxopts", + "seconds": 2.192126, + "standard": "cxx26" + }, + { + "case": "many_options", + "library": "boost", + "seconds": 1.164148, + "standard": "cxx26" + }, + { + "case": "subcommand", + "library": "cli20", + "seconds": 1.71208, + "standard": "cxx26" + }, + { + "case": "subcommand", + "library": "cli11", + "seconds": 3.582609, + "standard": "cxx26" + }, + { + "case": "subcommand", + "library": "argparse", + "seconds": 1.571239, + "standard": "cxx26" + }, + { + "case": "subcommand", + "library": "cxxopts", + "seconds": 1.836612, + "standard": "cxx26" + }, + { + "case": "subcommand", + "library": "boost", + "seconds": 0.753532, + "standard": "cxx26" + } + ], + "libraries": [ + "cli20", + "cli11", + "argparse", + "cxxopts", + "boost" + ], + "runtime": [ + { + "allocatedBytes": 0.0, + "allocations": 0.0, + "case": "simple", + "cpuNs": 253.32093755028515, + "library": "cli20", + "standard": "cxx20", + "timeNs": 253.51536655200294 + }, + { + "allocatedBytes": 4248.0, + "allocations": 81.0, + "case": "simple", + "cpuNs": 3007.5960679178525, + "library": "cli11", + "standard": "cxx20", + "timeNs": 3008.3965538843627 + }, + { + "allocatedBytes": 3552.0, + "allocations": 34.0, + "case": "simple", + "cpuNs": 1417.6598611255279, + "library": "argparse", + "standard": "cxx20", + "timeNs": 1419.0460132111236 + }, + { + "allocatedBytes": 21920.0, + "allocations": 330.0, + "case": "simple", + "cpuNs": 9319.595083152675, + "library": "cxxopts", + "standard": "cxx20", + "timeNs": 9319.775833614272 + }, + { + "allocatedBytes": 5120.0, + "allocations": 86.0, + "case": "simple", + "cpuNs": 2787.335722819585, + "library": "boost", + "standard": "cxx20", + "timeNs": 2788.281564419768 + }, + { + "allocatedBytes": 0.0, + "allocations": 0.0, + "case": "many_options", + "cpuNs": 2731.753916698495, + "library": "cli20", + "standard": "cxx20", + "timeNs": 2732.860339429325 + }, + { + "allocatedBytes": 23344.0, + "allocations": 503.0, + "case": "many_options", + "cpuNs": 17230.110159118598, + "library": "cli11", + "standard": "cxx20", + "timeNs": 17231.487122739378 + }, + { + "allocatedBytes": 13992.0, + "allocations": 98.0, + "case": "many_options", + "cpuNs": 5355.455568053927, + "library": "argparse", + "standard": "cxx20", + "timeNs": 5357.267719529008 + }, + { + "allocatedBytes": 126472.0, + "allocations": 1489.0, + "case": "many_options", + "cpuNs": 42796.40718562883, + "library": "cxxopts", + "standard": "cxx20", + "timeNs": 42829.34128649778 + }, + { + "allocatedBytes": 27632.0, + "allocations": 412.0, + "case": "many_options", + "cpuNs": 20211.347517730752, + "library": "boost", + "standard": "cxx20", + "timeNs": 20222.458162787534 + }, + { + "allocatedBytes": 0.0, + "allocations": 0.0, + "case": "subcommand", + "cpuNs": 200.9111420893017, + "library": "cli20", + "standard": "cxx20", + "timeNs": 201.0356986937043 + }, + { + "allocatedBytes": 7112.0, + "allocations": 107.0, + "case": "subcommand", + "cpuNs": 3680.5483704087032, + "library": "cli11", + "standard": "cxx20", + "timeNs": 3681.486037420567 + }, + { + "allocatedBytes": 5920.0, + "allocations": 64.0, + "case": "subcommand", + "cpuNs": 2283.2128514056276, + "library": "argparse", + "standard": "cxx20", + "timeNs": 2284.819278005795 + }, + { + "allocatedBytes": 7776.0, + "allocations": 105.0, + "case": "subcommand", + "cpuNs": 2853.648675965263, + "library": "cxxopts", + "standard": "cxx20", + "timeNs": 2855.434201899498 + }, + { + "allocatedBytes": 1752.0, + "allocations": 36.0, + "case": "subcommand", + "cpuNs": 959.8003507351925, + "library": "boost", + "standard": "cxx20", + "timeNs": 960.1038700079201 + }, + { + "allocatedBytes": 0.0, + "allocations": 0.0, + "case": "simple", + "cpuNs": 243.00800742717982, + "library": "cli20", + "standard": "cxx26", + "timeNs": 243.42039846220192 + }, + { + "allocatedBytes": 4248.0, + "allocations": 81.0, + "case": "simple", + "cpuNs": 2882.654197283434, + "library": "cli11", + "standard": "cxx26", + "timeNs": 2883.8975749249594 + }, + { + "allocatedBytes": 3552.0, + "allocations": 34.0, + "case": "simple", + "cpuNs": 1361.5546851461324, + "library": "argparse", + "standard": "cxx26", + "timeNs": 1361.5212405716484 + }, + { + "allocatedBytes": 21920.0, + "allocations": 330.0, + "case": "simple", + "cpuNs": 8794.838709677477, + "library": "cxxopts", + "standard": "cxx26", + "timeNs": 8798.118064840955 + }, + { + "allocatedBytes": 5120.0, + "allocations": 86.0, + "case": "simple", + "cpuNs": 2659.7878495660766, + "library": "boost", + "standard": "cxx26", + "timeNs": 2659.6995174306426 + }, + { + "allocatedBytes": 0.0, + "allocations": 0.0, + "case": "many_options", + "cpuNs": 2839.222941720603, + "library": "cli20", + "standard": "cxx26", + "timeNs": 2858.84976808316 + }, + { + "allocatedBytes": 23344.0, + "allocations": 503.0, + "case": "many_options", + "cpuNs": 16879.898862199785, + "library": "cli11", + "standard": "cxx26", + "timeNs": 16883.533467407964 + }, + { + "allocatedBytes": 13992.0, + "allocations": 98.0, + "case": "many_options", + "cpuNs": 5247.54634678303, + "library": "argparse", + "standard": "cxx26", + "timeNs": 5249.954558304985 + }, + { + "allocatedBytes": 126472.0, + "allocations": 1489.0, + "case": "many_options", + "cpuNs": 40789.62536022991, + "library": "cxxopts", + "standard": "cxx26", + "timeNs": 40790.4668668183 + }, + { + "allocatedBytes": 27632.0, + "allocations": 412.0, + "case": "many_options", + "cpuNs": 19525.64102564122, + "library": "boost", + "standard": "cxx26", + "timeNs": 19525.878922085136 + }, + { + "allocatedBytes": 0.0, + "allocations": 0.0, + "case": "subcommand", + "cpuNs": 197.47375418963608, + "library": "cli20", + "standard": "cxx26", + "timeNs": 197.7622918708773 + }, + { + "allocatedBytes": 7112.0, + "allocations": 107.0, + "case": "subcommand", + "cpuNs": 3575.741801145244, + "library": "cli11", + "standard": "cxx26", + "timeNs": 3576.6637176255863 + }, + { + "allocatedBytes": 5920.0, + "allocations": 64.0, + "case": "subcommand", + "cpuNs": 2189.168456581753, + "library": "argparse", + "standard": "cxx26", + "timeNs": 2190.363759681612 + }, + { + "allocatedBytes": 7776.0, + "allocations": 105.0, + "case": "subcommand", + "cpuNs": 2692.5484060238773, + "library": "cxxopts", + "standard": "cxx26", + "timeNs": 2692.548405384886 + }, + { + "allocatedBytes": 1752.0, + "allocations": 36.0, + "case": "subcommand", + "cpuNs": 944.422546314541, + "library": "boost", + "standard": "cxx26", + "timeNs": 944.6744185754538 + } + ], + "schemaVersion": 1, + "size": [ + { + "case": "many_options", + "library": "argparse", + "standard": "cxx20", + "strippedBytes": 142624, + "unstrippedBytes": 194928 + }, + { + "case": "many_options", + "library": "boost", + "standard": "cxx20", + "strippedBytes": 111104, + "unstrippedBytes": 141808 + }, + { + "case": "many_options", + "library": "cli11", + "standard": "cxx20", + "strippedBytes": 414080, + "unstrippedBytes": 579008 + }, + { + "case": "many_options", + "library": "cli20", + "standard": "cxx20", + "strippedBytes": 216064, + "unstrippedBytes": 356224 + }, + { + "case": "many_options", + "library": "cxxopts", + "standard": "cxx20", + "strippedBytes": 192352, + "unstrippedBytes": 264608 + }, + { + "case": "simple", + "library": "argparse", + "standard": "cxx20", + "strippedBytes": 123744, + "unstrippedBytes": 162968 + }, + { + "case": "simple", + "library": "boost", + "standard": "cxx20", + "strippedBytes": 39136, + "unstrippedBytes": 46680 + }, + { + "case": "simple", + "library": "cli11", + "standard": "cxx20", + "strippedBytes": 380800, + "unstrippedBytes": 505032 + }, + { + "case": "simple", + "library": "cli20", + "standard": "cxx20", + "strippedBytes": 89424, + "unstrippedBytes": 109384 + }, + { + "case": "simple", + "library": "cxxopts", + "standard": "cxx20", + "strippedBytes": 174624, + "unstrippedBytes": 233688 + }, + { + "case": "subcommand", + "library": "argparse", + "standard": "cxx20", + "strippedBytes": 123760, + "unstrippedBytes": 165584 + }, + { + "case": "subcommand", + "library": "boost", + "standard": "cxx20", + "strippedBytes": 38560, + "unstrippedBytes": 46024 + }, + { + "case": "subcommand", + "library": "cli11", + "standard": "cxx20", + "strippedBytes": 381024, + "unstrippedBytes": 507208 + }, + { + "case": "subcommand", + "library": "cli20", + "standard": "cxx20", + "strippedBytes": 123072, + "unstrippedBytes": 147720 + }, + { + "case": "subcommand", + "library": "cxxopts", + "standard": "cxx20", + "strippedBytes": 174624, + "unstrippedBytes": 232704 + }, + { + "case": "many_options", + "library": "argparse", + "standard": "cxx26", + "strippedBytes": 142624, + "unstrippedBytes": 194928 + }, + { + "case": "many_options", + "library": "boost", + "standard": "cxx26", + "strippedBytes": 111104, + "unstrippedBytes": 141808 + }, + { + "case": "many_options", + "library": "cli11", + "standard": "cxx26", + "strippedBytes": 414080, + "unstrippedBytes": 579008 + }, + { + "case": "many_options", + "library": "cli20", + "standard": "cxx26", + "strippedBytes": 216064, + "unstrippedBytes": 356224 + }, + { + "case": "many_options", + "library": "cxxopts", + "standard": "cxx26", + "strippedBytes": 192352, + "unstrippedBytes": 264608 + }, + { + "case": "simple", + "library": "argparse", + "standard": "cxx26", + "strippedBytes": 123744, + "unstrippedBytes": 162968 + }, + { + "case": "simple", + "library": "boost", + "standard": "cxx26", + "strippedBytes": 39136, + "unstrippedBytes": 46680 + }, + { + "case": "simple", + "library": "cli11", + "standard": "cxx26", + "strippedBytes": 380800, + "unstrippedBytes": 505032 + }, + { + "case": "simple", + "library": "cli20", + "standard": "cxx26", + "strippedBytes": 89424, + "unstrippedBytes": 109384 + }, + { + "case": "simple", + "library": "cxxopts", + "standard": "cxx26", + "strippedBytes": 174624, + "unstrippedBytes": 233688 + }, + { + "case": "subcommand", + "library": "argparse", + "standard": "cxx26", + "strippedBytes": 123760, + "unstrippedBytes": 165584 + }, + { + "case": "subcommand", + "library": "boost", + "standard": "cxx26", + "strippedBytes": 38560, + "unstrippedBytes": 46024 + }, + { + "case": "subcommand", + "library": "cli11", + "standard": "cxx26", + "strippedBytes": 381024, + "unstrippedBytes": 507208 + }, + { + "case": "subcommand", + "library": "cli20", + "standard": "cxx26", + "strippedBytes": 123072, + "unstrippedBytes": 147720 + }, + { + "case": "subcommand", + "library": "cxxopts", + "standard": "cxx26", + "strippedBytes": 174624, + "unstrippedBytes": 232704 + } + ], + "standards": [ + "cxx20", + "cxx26" + ] +} diff --git a/examples/custom_action.cc b/examples/custom_action.cc index f9aaad7..ef50577 100644 --- a/examples/custom_action.cc +++ b/examples/custom_action.cc @@ -1,5 +1,4 @@ #include -#include #include #include #include @@ -12,17 +11,6 @@ struct MinutesSinceMidnight { int value{}; }; -template <> -struct std::formatter { - constexpr auto parse(std::format_parse_context& ctx) { return ctx.begin(); } - - auto format(MinutesSinceMidnight time, std::format_context& ctx) const { - const int hours = time.value / 60; - const int minutes = time.value % 60; - return std::format_to(ctx.out(), "{:02}:{:02}", hours, minutes); - } -}; - struct HHMMConversion { template static constexpr bool accepts_input = diff --git a/flake.nix b/flake.nix index 518f6ef..3a04aac 100644 --- a/flake.nix +++ b/flake.nix @@ -30,6 +30,34 @@ { pkgs, config, ... }: let version = builtins.replaceStrings [ "\n" ] [ "" ] (builtins.readFile ./VERSION); + google_bench = pkgs.libcxxStdenv.mkDerivation rec { + pname = "google-benchmark"; + version = "1.9.5"; + + src = pkgs.fetchFromGitHub { + owner = "google"; + repo = "benchmark"; + rev = "v${version}"; + hash = "sha256-Mm4pG7zMB00iof32CxreoNBFnduPZTMp3reHMCIAFPQ="; + }; + + nativeBuildInputs = [ + pkgs.cmake + ]; + + cmakeFlags = [ + "-DBENCHMARK_ENABLE_TESTING=OFF" + "-DBENCHMARK_ENABLE_GTEST_TESTS=OFF" + "-DBENCHMARK_ENABLE_INSTALL=ON" + ]; + + meta = { + description = "A microbenchmark support library"; + homepage = "https://github.com/google/benchmark"; + license = pkgs.lib.licenses.asl20; + platforms = pkgs.lib.platforms.unix; + }; + }; in rec { treefmt = { @@ -52,8 +80,16 @@ pkgs.clang-tools pkgs.doxygen pkgs.emscripten + pkgs.boost.dev + pkgs.cxxopts + pkgs.cli11 + pkgs.argparse + google_bench config.treefmt.build.wrapper + pkgs.hyperfine + pkgs.python3Packages.venvShellHook ]; + venvDir = "venv"; }; packages.default = pkgs.stdenvNoCC.mkDerivation { @@ -126,6 +162,59 @@ baseUrl = "/cli20/"; }; + packages.bench = pkgs.stdenvNoCC.mkDerivation { + pname = "cli20-bench"; + inherit version; + src = ./.; + + nativeBuildInputs = [ + pkgs.llvmPackages.libcxxClang + pkgs.cmake + pkgs.ninja + pkgs.hyperfine + pkgs.python3 + ]; + + buildInputs = [ + pkgs.cxxopts + pkgs.cli11 + pkgs.argparse + pkgs.boost.dev + google_bench + ]; + + dontConfigure = true; + + buildPhase = '' + runHook preBuild + + export BUILD_DIR="$TMPDIR/build-bench" + export RESULTS_DIR="$TMPDIR/bench-results" + export HYPERFINE_RUNS=3 + export HYPERFINE_WARMUP=0 + + for standard in 20 26; do + export BENCH_CXX_STANDARD="$standard" + bash bench/scripts/run-runtime.sh --benchmark_min_time=0.01s + bash bench/scripts/run-size.sh + bash bench/compile_time/scripts/run.sh "$RESULTS_DIR/compile_time/cxx$standard" + done + python3 bench/scripts/summarize.py --output "$RESULTS_DIR/report.md" + python3 bench/scripts/export-doc-data.py \ + "$RESULTS_DIR" \ + "$RESULTS_DIR/docs/benchmark-results.json" + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p "$out" + cp -R "$RESULTS_DIR"/. "$out"/ + runHook postInstall + ''; + }; + apps.build = { type = "app"; program = @@ -159,6 +248,21 @@ --expr '{ baseUrl }: (builtins.getFlake (toString ./.)).legacyPackages.${pkgs.system}.buildDoc { inherit baseUrl; }' ''}/bin/build-cli20-doc"; }; + + apps.update_benchmark = { + type = "app"; + program = "${pkgs.writeShellScriptBin "update-cli20-benchmark-docs" '' + set -euo pipefail + + repo_root="$(pwd)" + out_link="$repo_root/result-bench" + data_dir="$repo_root/docs/src/data/benchmarks" + + ${pkgs.nix}/bin/nix build -L "$repo_root#bench" --out-link "$out_link" + mkdir -p "$data_dir" + cp "$out_link/docs/benchmark-results.json" "$data_dir/results.json" + ''}/bin/update-cli20-benchmark-docs"; + }; }; }; } diff --git a/include/cli/action.hh b/include/cli/action.hh index 1af0f46..9f355e9 100644 --- a/include/cli/action.hh +++ b/include/cli/action.hh @@ -1,19 +1,20 @@ #pragma once #include +#include #include #include #include #include #include #include -#include #include #include #include #include #include #include +#include #include #include #include @@ -292,6 +293,22 @@ struct ActionCtx { namespace detail { +template +[[nodiscard]] +auto to_chars_string(T value) -> std::string { + std::array buffer{}; + const auto [ptr, ec] = [&] { + if constexpr (std::floating_point) { + return std::to_chars(buffer.data(), buffer.data() + buffer.size(), value, + std::chars_format::general); + } else { + return std::to_chars(buffer.data(), buffer.data() + buffer.size(), value); + } + }(); + if (ec != std::errc{}) return ""; + return std::string(buffer.data(), ptr); +} + template [[nodiscard]] auto to_error_subject(const T& value) -> std::string { @@ -305,13 +322,14 @@ auto to_error_subject(const T& value) -> std::string { return value == nullptr ? std::string{} : std::string(value); } else if constexpr (std::is_same_v) { return value ? "true" : "false"; - } else if constexpr (std::integral) { - return std::to_string(value); + } else if constexpr (std::integral || std::floating_point) { + return to_chars_string(value); } else if constexpr (std::same_as) { return value.string(); - } else if constexpr (requires { std::format("{}", value); } or - std::floating_point) { - return std::format("{}", value); + } else if constexpr (requires(std::ostream& os, const U& v) { os << v; }) { + std::ostringstream out; + out << value; + return out.str(); } else { return ""; } @@ -1051,7 +1069,7 @@ struct Min { if (input.value < MinValue) { return ActionResult::fail(detail::validation_failed_error( ctx.index, detail::to_error_subject(input.value), - std::format("value must be >= {}", MinValue))); + "value must be >= " + detail::to_error_subject(MinValue))); } return input; } @@ -1081,7 +1099,7 @@ struct Max { if (input.value > MaxValue) { return ActionResult::fail(detail::validation_failed_error( ctx.index, detail::to_error_subject(input.value), - std::format("value must be <= {}", MaxValue))); + "value must be <= " + detail::to_error_subject(MaxValue))); } return input; } @@ -1114,7 +1132,8 @@ struct Range { if (input.value < MinValue || input.value > MaxValue) { return ActionResult::fail(detail::validation_failed_error( ctx.index, detail::to_error_subject(input.value), - std::format("value must be between {} and {}", MinValue, MaxValue))); + "value must be between " + detail::to_error_subject(MinValue) + + " and " + detail::to_error_subject(MaxValue))); } return input; } @@ -1284,7 +1303,7 @@ struct Matches { if (!std::regex_match(input.value.begin(), input.value.end(), regex)) { return ActionResult::fail(detail::validation_failed_error( ctx.index, detail::to_error_subject(input.value), - std::format("value must match {}", Pattern.view()))); + "value must match " + std::string(Pattern.view()))); } return input; } diff --git a/include/cli/argument.hh b/include/cli/argument.hh index be8dee3..8dbf9db 100644 --- a/include/cli/argument.hh +++ b/include/cli/argument.hh @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -375,7 +376,15 @@ struct PositionalImpl : public PositionalTag { * }; * @endcode */ -struct Description : public std::string, DescriptionTag {}; +struct Description : DescriptionTag { + std::string_view text{}; + constexpr Description() = default; + template + // NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays, modernize-avoid-c-arrays) + constexpr Description(const char (&s)[N]) : text(s, N - 1) {} + constexpr Description(std::string_view) = delete; + constexpr operator std::string_view() const { return text; } +}; /** * @brief Parameter bag used when constructing a `Command` field. diff --git a/include/cli/cli.hh b/include/cli/cli.hh index e001b99..3cb2c59 100644 --- a/include/cli/cli.hh +++ b/include/cli/cli.hh @@ -1,4 +1,3 @@ #pragma once -#include "cli/argument.hh" #include "cli/parser.hh" diff --git a/include/cli/help.hh b/include/cli/help.hh index a4b9c40..064955a 100644 --- a/include/cli/help.hh +++ b/include/cli/help.hh @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include #include @@ -124,7 +123,9 @@ template auto metavar_for() -> std::string { using Storage = std::remove_cvref_t; using Base = unwrap_storage_t; - std::string out = std::format("<{}>", MetavarName::value); + std::string out = "<"; + out += MetavarName::value; + out += ">"; if constexpr (UnwrapStorage::variadic) { out.insert(out.size() - 1, "..."); } @@ -197,9 +198,12 @@ auto field_usage_label() -> std::string { if constexpr (std::derived_from) { std::string out; if constexpr (F::short_name != '\0') { - out += std::format("-{}, ", F::short_name); + out += "-"; + out += F::short_name; + out += ", "; } - out += std::format("--{}", F::name.view()); + out += "--"; + out += F::name.view(); if constexpr (!(F::nargs.min == 0 && F::nargs.max == 0)) { out += " "; out += metavar_for(); @@ -223,12 +227,14 @@ auto styled_field_usage_label(const AnsiStyle& style) -> std::string { std::string out; if constexpr (F::short_name != '\0') { out += style.option(); - out += std::format("-{}", F::short_name); + out += "-"; + out += F::short_name; out += reset; out += ", "; } out += style.option(); - out += std::format("--{}", F::name.view()); + out += "--"; + out += F::name.view(); out += reset; if constexpr (!(F::nargs.min == 0 && F::nargs.max == 0)) { out += " "; @@ -238,10 +244,15 @@ auto styled_field_usage_label(const AnsiStyle& style) -> std::string { } return out; } else if constexpr (std::derived_from) { - return std::format("{}{}{}", style.metavar(), - metavar_for(), reset); + std::string out = std::string(style.metavar()); + out += metavar_for(); + out += reset; + return out; } else if constexpr (std::derived_from) { - return std::format("{}{}{}", style.command(), F::command_name(), reset); + std::string out = std::string(style.command()); + out += F::command_name(); + out += reset; + return out; } else { return std::string{}; } @@ -433,7 +444,7 @@ auto relation_operand_label(std::string_view operand) -> std::string { return std::string(operand); } } - return std::format("--{}", operand); + return "--" + std::string(operand); } template @@ -452,22 +463,22 @@ auto relation_help_metadata() -> std::vector { (void)field_name; } else if constexpr (std::same_as) { if (relation_operand_contains(rels.left, field_name)) { - metadata.emplace_back(std::format( - "conflicts: {}", relation_operand_label(rels.right))); + metadata.emplace_back("conflicts: " + + relation_operand_label(rels.right)); } if (relation_operand_contains(rels.right, field_name)) { - metadata.emplace_back(std::format( - "conflicts: {}", relation_operand_label(rels.left))); + metadata.emplace_back("conflicts: " + + relation_operand_label(rels.left)); } } else if constexpr (std::same_as) { if (relation_operand_contains(rels.source, field_name)) { - metadata.emplace_back(std::format( - "requires: {}", relation_operand_label(rels.target))); + metadata.emplace_back( + "requires: " + relation_operand_label(rels.target)); } if (relation_operand_contains(rels.target, field_name)) { metadata.emplace_back( - std::format("required by: {}", - relation_operand_label(rels.source))); + "required by: " + + relation_operand_label(rels.source)); } } }(), @@ -489,7 +500,7 @@ auto help_metadata(Field& field) -> std::vector { metadata.emplace_back("required"); } if (!field.env().empty()) { - metadata.emplace_back(std::format("env: {}", field.env())); + metadata.emplace_back("env: " + std::string(field.env())); } if (const auto& default_value = field.default_value()) { using Value = std::remove_cvref_t; @@ -498,12 +509,12 @@ auto help_metadata(Field& field) -> std::vector { }) { std::ostringstream out; out << *default_value; - metadata.emplace_back(std::format("default: {}", out.str())); + metadata.emplace_back("default: " + out.str()); } } } if (!field.deprecated().empty()) { - metadata.emplace_back(std::format("deprecated: {}", field.deprecated())); + metadata.emplace_back("deprecated: " + std::string(field.deprecated())); } auto relation_metadata = relation_help_metadata(); metadata.insert(metadata.end(), @@ -557,8 +568,9 @@ auto append_group_requirements(std::string& row, std::string_view group_name) using R = std::remove_cvref_t; if constexpr (std::same_as) { if (rels.source == group_name) { - row += std::format(" requires: {}\n", - relation_operand_label(rels.target)); + row += " requires: "; + row += relation_operand_label(rels.target); + row += "\n"; } } }(), @@ -688,8 +700,13 @@ auto format_help_impl(T& value, std::string_view program_name, full_program += command_path; } - std::string out = std::format("{}Usage:{} {}{}\n", style.usage(), reset, - full_program, command_usage_suffix(value)); + std::string out = std::string(style.usage()); + out += "Usage:"; + out += reset; + out += " "; + out += full_program; + out += command_usage_suffix(value); + out += "\n"; if (const auto description = find_description(value); !description.empty()) { out += '\n'; diff --git a/include/cli/parser.hh b/include/cli/parser.hh index 2bc7d3b..28c233b 100644 --- a/include/cli/parser.hh +++ b/include/cli/parser.hh @@ -1,14 +1,17 @@ #pragma once // #include +#include +#include +#include #include -#include -#include +#include #include #include +#include #include -#include -#include +#include +#include #include #ifdef _WIN32 @@ -22,8 +25,46 @@ #include "cli/error.hh" #include "cli/help.hh" +#ifndef CLI20_TOKEN_STACK_CAPACITY +#define CLI20_TOKEN_STACK_CAPACITY 32 +#endif + +static_assert(CLI20_TOKEN_STACK_CAPACITY > 0, + "CLI20_TOKEN_STACK_CAPACITY must be greater than zero"); + namespace cli { +/** + * @brief Number of tokenizer tokens kept in inline stack storage before + * falling back to heap allocation. + * + * Define `CLI20_TOKEN_STACK_CAPACITY` before including `cli/parser.hh` to + * override the default. Because this value changes header-defined types, use + * one value consistently across all translation units in a program. + */ +inline constexpr std::size_t token_stack_capacity = CLI20_TOKEN_STACK_CAPACITY; + +namespace detail { + +inline auto write_message(FILE* file, std::string_view message) -> void { + if (file == nullptr || message.empty()) return; + (void)std::fwrite(message.data(), 1, message.size(), file); + (void)std::fwrite("\n", 1, 1, file); +} + +inline auto value_count_message(int required_count, int provided) + -> std::string { + return "option requires at least " + std::to_string(required_count) + + " value(s), but " + std::to_string(provided) + " provided"; +} + +inline auto inline_value_count_message(int required_count) -> std::string { + return "option requires at least " + std::to_string(required_count) + + " value(s), but inline separator provides only 1"; +} + +} // namespace detail + #ifdef _WIN32 namespace detail { /** @@ -100,26 +141,45 @@ inline auto utf16_to_utf8(int argc, wchar_t* argv[]) -> Utf8Argv { * - `command` : A subcommand name; subsequent tokens belong to the * subcommand. */ -enum class TokenType { +enum class TokenType : uint8_t { option, value, positional, command, }; +static constexpr std::uint8_t kNoPrefix = 0xff; +static constexpr std::uint8_t kShortPrefixBit = 0x80; +static constexpr std::uint8_t kPrefixIndexMask = 0x7f; + +static constexpr auto no_prefix() -> std::uint8_t { return kNoPrefix; } + +static constexpr auto short_prefix() -> std::uint8_t { return kShortPrefixBit; } + +static constexpr auto long_prefix(std::uint8_t i) -> std::uint8_t { + return i & kPrefixIndexMask; +} + +static constexpr auto has_prefix(std::uint8_t p) -> bool { + return p != kNoPrefix; +} + +static constexpr auto is_short_prefix(std::uint8_t p) -> bool { + return p != kNoPrefix && (p & kShortPrefixBit); +} + /** * @brief A single token produced by `tokenize()`. */ struct Token { - TokenType type; ///< Classification of this token. std::string_view text; ///< For option tokens: the bare name without prefix ///< (e.g. `"verbose"` for `--verbose`, `"v"` for ///< `-v`). For value/positional/command tokens: the ///< raw text from the args span. - std::string_view matched_prefix; ///< The option prefix that was matched (e.g. - ///< `"--"` or `"-"`); empty for non-option - ///< tokens. - std::size_t position; ///< Zero-based index into the original `args` span. + std::size_t position; ///< Zero-based index into the original `args` span. + std::uint8_t prefix; // 0xff = none, high bit = short prefix, low 7bit = long + // prefix index + TokenType type; ///< Classification of this token. }; /** @@ -130,7 +190,53 @@ struct Token { * `error` describes the first problem encountered. */ struct TokenizeResult { - std::vector tokens; + template + + struct SmallVector { + std::array stack{}; + std::vector heap; + std::size_t size_value{}; + + auto push_back(T value) -> void { + if (size_value < N && heap.empty()) { + stack[size_value++] = std::move(value); + return; + } + + if (heap.empty()) { + heap.reserve(N * 2); + for (std::size_t i = 0; i < size_value; ++i) { + heap.push_back(std::move(stack[i])); + } + } + + heap.push_back(std::move(value)); + ++size_value; + } + + [[nodiscard]] auto size() const -> std::size_t { return size_value; } + [[nodiscard]] auto empty() const -> bool { return size_value == 0; } + + auto operator[](std::size_t i) -> T& { + return heap.empty() ? stack[i] : heap[i]; + } + + auto operator[](std::size_t i) const -> const T& { + return heap.empty() ? stack[i] : heap[i]; + } + + [[nodiscard]] auto begin() const { + return heap.empty() ? stack.data() : heap.data(); + } + + [[nodiscard]] auto end() const { return begin() + size_value; } + + [[nodiscard]] auto data() const { + return heap.empty() ? stack.data() : heap.data(); + } + }; + + SmallVector tokens; ParseError error{}; /** @brief Returns `true` when tokenization succeeded. */ @@ -183,18 +289,97 @@ struct TokenizeResult { * `=` as the inline value separator and `--` as the end-of-options marker. */ struct TokenizerConfig { - std::vector option_prefixes = { - "--"}; ///< Prefixes that introduce long options. - std::string short_option_prefix = - "-"; ///< Prefix for short (single-character) options. - std::string end_of_options_separator = - "--"; ///< Token that ends option processing; subsequent tokens become - ///< positionals. - char inline_value_separator = - '='; ///< Character separating option name from inline value (e.g. `=` for - ///< `--opt=val`). + static constexpr std::size_t max_option_prefixes = 4; + + private: + std::array option_prefixes_{"--"}; + std::uint8_t option_prefix_count_{1}; + + std::string_view short_option_prefix_{"-"}; + std::string_view end_of_options_separator_{"--"}; + char inline_value_separator_{'='}; + + public: + constexpr TokenizerConfig() = default; + + [[nodiscard]] constexpr auto option_prefixes() const + -> std::span { + return {option_prefixes_.data(), option_prefix_count_}; + } + + [[nodiscard]] constexpr auto short_option_prefix() const -> std::string_view { + return short_option_prefix_; + } + + [[nodiscard]] constexpr auto end_of_options_separator() const + -> std::string_view { + return end_of_options_separator_; + } + + [[nodiscard]] constexpr auto inline_value_separator() const -> char { + return inline_value_separator_; + } + + template + constexpr auto set_option_prefixes() -> void { + static_assert(sizeof...(Prefixes) > 0, + "TokenizerConfig needs at least one option prefix"); + static_assert(sizeof...(Prefixes) <= max_option_prefixes, + "Too many option prefixes for TokenizerConfig"); + option_prefix_count_ = 0; + ((option_prefixes_[option_prefix_count_++] = Prefixes.view()), ...); + } + + template + [[nodiscard]] constexpr auto with_option_prefixes() const -> TokenizerConfig { + auto cfg = *this; + cfg.template set_option_prefixes(); + return cfg; + } + + template + [[nodiscard]] constexpr auto with_short_option_prefix() const + -> TokenizerConfig { + auto cfg = *this; + cfg.short_option_prefix_ = Prefix.view(); + return cfg; + } + + template + [[nodiscard]] constexpr auto with_end_of_options_separator() const + -> TokenizerConfig { + auto cfg = *this; + cfg.end_of_options_separator_ = Separator.view(); + return cfg; + } + + [[nodiscard]] constexpr auto with_inline_value_separator(char separator) const + -> TokenizerConfig { + auto cfg = *this; + cfg.inline_value_separator_ = separator; + return cfg; + } }; +/** + * @brief Retrieves the matched prefix string for an option token. + * + * For a token with `prefix == kNoPrefix`, returns an empty view. For a short + * prefix, returns `cfg.short_option_prefix`. For a long prefix, returns the + * corresponding entry from `cfg.option_prefixes`. + * + * @param tok The token whose prefix to retrieve; must be of type `option`. + * @param cfg The tokenizer configuration that was used to parse `tok`. + * @return The matched prefix string as a view into `cfg`, or empty if no + * prefix. + */ +inline auto token_prefix_view(const Token& tok, const TokenizerConfig& cfg) + -> std::string_view { + if (tok.prefix == kNoPrefix) return {}; + if (tok.prefix & kShortPrefixBit) return cfg.short_option_prefix(); + return cfg.option_prefixes()[tok.prefix & kPrefixIndexMask]; +} + /** * @brief Converts a raw argument span into an ordered sequence of typed tokens. * @@ -212,37 +397,56 @@ struct TokenizerConfig { * - After the end-of-options separator (`--`), all remaining tokens become * `{positional, "...", i}` regardless of prefix. * - * @param args The argument span to tokenize (typically `argv[1:]`). - * @param spec_map Map from full option keys (e.g. `"--verbose"`, `"-v"`) to - * their `Nargs`. Must NOT contain command names. - * @param command_names Set of bare subcommand name strings (no prefix). - * @param cfg Tokenizer configuration (prefixes, separator character, - * etc.). + * @param args The argument span to tokenize (typically `argv[1:]`). + * @param option_nargs Callable that accepts a full option spelling (e.g. + * `"--verbose"`, `"-v"`) and returns its `Nargs`, or + * `std::nullopt` when unknown. Must NOT match command + * names. + * @param is_command Callable that accepts a bare token and returns whether it + * is a subcommand name. + * @param cfg Tokenizer configuration (prefixes, separator character, + * etc.). * @return A `TokenizeResult` containing the token list on success, or a * `ParseError` on failure. */ +template inline auto tokenize(std::span args, - const std::unordered_map& spec_map, - const std::unordered_set& command_names = {}, + OptionLookup option_nargs, CommandLookup is_command, const TokenizerConfig& cfg = {}) -> TokenizeResult { TokenizeResult result; - // Returns the matched prefix (as a view into cfg.option_prefixes) or empty. - const auto find_prefix = [&](std::string_view tok) -> std::string_view { - for (const auto& p : cfg.option_prefixes) { - if (tok.size() > p.size() && tok.starts_with(p)) return p; + // Returns an encoded prefix tag that can be decoded with token_prefix_view(). + const auto find_prefix = [&](std::string_view tok) -> std::uint8_t { + for (std::uint8_t pi = 0; + pi < static_cast(cfg.option_prefixes().size()); ++pi) { + const auto& p = cfg.option_prefixes()[pi]; + if (tok.size() > p.size() && tok.starts_with(p)) { + return long_prefix(pi); + } } - if (tok.size() > cfg.short_option_prefix.size() && - tok.starts_with(cfg.short_option_prefix)) { - return cfg.short_option_prefix; + + if (tok.size() > cfg.short_option_prefix().size() && + tok.starts_with(cfg.short_option_prefix())) { + return short_prefix(); } - return {}; + + return no_prefix(); + }; + + const auto prefix_view = [&](std::uint8_t p) -> std::string_view { + if (p == no_prefix()) return {}; + if (is_short_prefix(p)) return cfg.short_option_prefix(); + return cfg.option_prefixes()[p & kPrefixIndexMask]; }; const auto push = [&](TokenType type, std::string_view text, std::size_t pos, - std::string_view prefix = {}) -> void { - result.tokens.push_back( - {.type = type, .text = text, .matched_prefix = prefix, .position = pos}); + std::uint8_t prefix = kNoPrefix) -> void { + result.tokens.push_back({ + .text = text, + .position = pos, + .prefix = prefix, + .type = type, + }); }; bool post_separator = false; @@ -252,7 +456,7 @@ inline auto tokenize(std::span args, const std::string_view tok = args[i]; // ── option parsing terminator ───────────────────────────────────────── - if (!post_separator && tok == cfg.end_of_options_separator) { + if (!post_separator && tok == cfg.end_of_options_separator()) { post_separator = true; ++i; continue; @@ -266,59 +470,59 @@ inline auto tokenize(std::span args, // ── command name ────────────────────────────────────────────────────── const auto prefix = find_prefix(tok); - if (prefix.empty() && command_names.contains(std::string(tok))) { + const auto prefix_sv = prefix_view(prefix); + if (!has_prefix(prefix) && is_command(tok)) { push(TokenType::command, tok, i); break; // args[i+1:] are the sub-command's responsibility } // ── option-looking token ────────────────────────────────────────────── - if (!prefix.empty()) { - const auto sep = cfg.inline_value_separator; + if (has_prefix(prefix)) { + const auto sep = cfg.inline_value_separator(); const auto eq_pos = tok.find(sep); const bool has_inline_val = (eq_pos != std::string_view::npos); const std::string_view opt_sv = has_inline_val ? tok.substr(0, eq_pos) : tok; - const std::string opt_name{opt_sv}; - - const auto spec_it = spec_map.find(opt_name); - if (spec_it == spec_map.end()) { + const auto nargs_opt = option_nargs(opt_sv); + if (!nargs_opt) { // Short cluster (-xvf) or attached value (-ofile): only when the token // uses the short prefix and carries more than one character after it. - const std::string_view body = opt_sv.substr(prefix.size()); - if (prefix == cfg.short_option_prefix && !has_inline_val && - body.size() > 1) { + const std::string_view body = opt_sv.substr(prefix_sv.size()); + if (is_short_prefix(prefix) && !has_inline_val && body.size() > 1) { bool advanced_i = false; - const std::string_view char_prefix = tok.substr(0, prefix.size()); + const std::string_view char_prefix = tok.substr(0, prefix_sv.size()); std::string char_full{char_prefix}; char_full.push_back('\0'); for (std::size_t k = 0; k < body.size(); ++k) { - const std::string_view char_bare = tok.substr(prefix.size() + k, 1); + const std::string_view char_bare = + tok.substr(prefix_sv.size() + k, 1); char_full.back() = body[k]; - const auto it = spec_map.find(char_full); - if (it == spec_map.end()) { + const auto char_nargs_opt = option_nargs(char_full); + if (!char_nargs_opt) { return result.fail(ErrorCode::unknown_option, i, char_full); } - const Nargs& nargs = it->second; + const Nargs nargs = *char_nargs_opt; const bool is_flag = (nargs.min == 0 && nargs.max == 0); - push(TokenType::option, char_bare, i, char_prefix); + push(TokenType::option, char_bare, i, prefix); if (!is_flag) { if (k + 1 < body.size()) { // First value is attached (rest of body). - push(TokenType::value, tok.substr(prefix.size() + k + 1), i); + push(TokenType::value, tok.substr(prefix_sv.size() + k + 1), i); ++i; // Consume additional tokens for nargs > 1. int count = 1; while (i < args.size()) { const std::string_view next = args[i]; - if (next == cfg.end_of_options_separator) break; - if (command_names.contains(std::string(next))) break; + if (next == cfg.end_of_options_separator()) break; + if (is_command(next)) break; if (nargs.max != -1 && count >= nargs.max) break; - if (!find_prefix(next).empty()) { + if (has_prefix(find_prefix(next))) { const auto next_sep = next.find(sep); - const std::string next_opt{next_sep != std::string_view::npos - ? next.substr(0, next_sep) - : next}; - if (spec_map.contains(next_opt)) break; + const std::string_view next_opt = + next_sep != std::string_view::npos + ? next.substr(0, next_sep) + : next; + if (option_nargs(next_opt).has_value()) break; if (count >= 1 || nargs.min == 0) break; } push(TokenType::value, args[i], i); @@ -328,9 +532,7 @@ inline auto tokenize(std::span args, if (count < nargs.min) { return result.fail( ErrorCode::missing_value, i, char_full, - std::format("option requires at least {} value(s), but {} " - "provided", - nargs.min, count)); + detail::value_count_message(nargs.min, count)); } } else { // Consume next token(s) greedily as values @@ -338,15 +540,16 @@ inline auto tokenize(std::span args, int count = 0; while (i < args.size()) { const std::string_view next = args[i]; - if (next == cfg.end_of_options_separator) break; - if (command_names.contains(std::string(next))) break; + if (next == cfg.end_of_options_separator()) break; + if (is_command(next)) break; if (nargs.max != -1 && count >= nargs.max) break; - if (!find_prefix(next).empty()) { + if (has_prefix(find_prefix(next))) { const auto next_sep = next.find(sep); - const std::string next_opt{next_sep != std::string_view::npos - ? next.substr(0, next_sep) - : next}; - if (spec_map.contains(next_opt)) break; + const std::string_view next_opt = + next_sep != std::string_view::npos + ? next.substr(0, next_sep) + : next; + if (option_nargs(next_opt).has_value()) break; if (count >= 1 || nargs.min == 0) break; } push(TokenType::value, args[i], i); @@ -356,9 +559,7 @@ inline auto tokenize(std::span args, if (count < nargs.min) { return result.fail( ErrorCode::missing_value, i, char_full, - std::format("option requires at least {} value(s), but {} " - "provided", - nargs.min, count)); + detail::value_count_message(nargs.min, count)); } } advanced_i = true; @@ -368,28 +569,25 @@ inline auto tokenize(std::span args, if (!advanced_i) ++i; continue; } - return result.fail(ErrorCode::unknown_option, i, opt_name); + return result.fail(ErrorCode::unknown_option, i, std::string(opt_sv)); } - const Nargs& nargs = spec_it->second; + const Nargs& nargs = *nargs_opt; const bool is_flag = (nargs.min == 0 && nargs.max == 0); // Store bare name (without prefix) in text; matched_prefix holds the // prefix. - push(TokenType::option, opt_sv.substr(prefix.size()), i, - opt_sv.substr(0, prefix.size())); + + push(TokenType::option, opt_sv.substr(prefix_sv.size()), i, prefix); if (has_inline_val) { if (is_flag) { - return result.fail(ErrorCode::invalid_value, i, opt_name, + return result.fail(ErrorCode::invalid_value, i, std::string(opt_sv), "flag does not accept a value"); } if (nargs.min > 1) { - return result.fail( - ErrorCode::missing_value, i, opt_name, - std::format("option requires at least {} value(s), but inline " - "separator provides only 1", - nargs.min)); + return result.fail(ErrorCode::missing_value, i, std::string(opt_sv), + detail::inline_value_count_message(nargs.min)); } push(TokenType::value, tok.substr(eq_pos + 1), i); ++i; @@ -405,16 +603,16 @@ inline auto tokenize(std::span args, while (i < args.size()) { const std::string_view next = args[i]; - if (next == cfg.end_of_options_separator) break; - if (command_names.contains(std::string(next))) break; + if (next == cfg.end_of_options_separator()) break; + if (is_command(next)) break; if (nargs.max != -1 && count >= nargs.max) break; - if (!find_prefix(next).empty()) { + if (has_prefix(find_prefix(next))) { const auto next_sep = next.find(sep); - const std::string next_opt{next_sep != std::string_view::npos - ? next.substr(0, next_sep) - : next}; - if (spec_map.contains(next_opt)) break; + const std::string_view next_opt = next_sep != std::string_view::npos + ? next.substr(0, next_sep) + : next; + if (option_nargs(next_opt).has_value()) break; // Unknown option-looking: consume only as first value when min > 0 if (count >= 1 || nargs.min == 0) break; @@ -426,10 +624,8 @@ inline auto tokenize(std::span args, } if (count < nargs.min) { - return result.fail( - ErrorCode::missing_value, i, opt_name, - std::format("option requires at least {} value(s), but {} provided", - nargs.min, count)); + return result.fail(ErrorCode::missing_value, i, std::string(opt_sv), + detail::value_count_message(nargs.min, count)); } continue; } @@ -678,11 +874,18 @@ struct Parser { TokenizerConfig cfg_; std::string program_name_ = "program"; + static constexpr std::size_t field_count = std::tuple_size_v< + std::remove_reference_t()))>>; + auto parse_body(ParseResult& result, std::span args, std::size_t first_index) -> void { - auto [spec_map, command_names] = get_option_spec_map(result.value); - auto tokenized = tokenize(args.subspan(std::min(first_index, args.size())), - spec_map, command_names, cfg_); + auto lookup = get_option_spec_map(result.value); + + auto tokenized = tokenize( + args.subspan(std::min(first_index, args.size())), + [&](std::string_view opt) -> auto { return lookup.option_nargs(opt); }, + [&](std::string_view name) -> auto { return lookup.is_command(name); }, + cfg_); if (tokenized.has_error()) { if (tokenized.error.has_position()) { tokenized.error.position += static_cast(first_index); @@ -700,9 +903,10 @@ struct Parser { if (tok.type == TokenType::option) { std::size_t j = i + 1; while (j < tokens.size() && tokens[j].type == TokenType::value) ++j; - auto err = dispatch_option(result.value, tok.matched_prefix, tok.text, - std::span(tokens).subspan(i + 1, j - i - 1), - first_index, tok.position); + auto err = + dispatch_option(result.value, token_prefix_view(tok, cfg_), tok.text, + std::span(tokens).subspan(i + 1, j - i - 1), + first_index, tok.position); if (err.has_error()) { result.error = finalize_special_error(std::move(err.error)); return; @@ -832,26 +1036,98 @@ struct Parser { std::tuple_size_v>>{}); } - // Build the option spec-map and command-name set from the field types of val. - auto get_option_spec_map(const T& val) - -> std::pair, - std::unordered_set> { - std::unordered_map spec_map; - std::unordered_set command_names; + // Build a flat lookup table once per parse so tokenization does not reflect + // over every field for every argv token. + struct Lookup { + struct OptionEntry { + std::string_view prefix; + std::string_view name; + char short_name{}; + Nargs nargs{}; + bool is_short{}; + }; + + struct CommandEntry { + std::string_view name; + }; + + std::array + options{}; + std::array commands{}; + std::size_t option_count{}; + std::size_t command_count{}; + + auto add_long_option(std::string_view prefix, std::string_view name, + Nargs nargs) -> void { + options[option_count++] = OptionEntry{ + .prefix = prefix, + .name = name, + .short_name = '\0', + .nargs = nargs, + .is_short = false, + }; + } + + auto add_short_option(std::string_view prefix, char short_name, Nargs nargs) + -> void { + options[option_count++] = OptionEntry{ + .prefix = prefix, + .name = {}, + .short_name = short_name, + .nargs = nargs, + .is_short = true, + }; + } + + auto add_command(std::string_view name) -> void { + commands[command_count++] = CommandEntry{.name = name}; + } + + [[nodiscard]] auto option_nargs(std::string_view opt) const + -> std::optional { + for (std::size_t i = 0; i < option_count; ++i) { + const auto& entry = options[i]; + if (!opt.starts_with(entry.prefix)) { + continue; + } + const auto rest = opt.substr(entry.prefix.size()); + if (entry.is_short) { + if (rest.size() == 1 && rest.front() == entry.short_name) { + return entry.nargs; + } + } else if (rest == entry.name) { + return entry.nargs; + } + } + return std::nullopt; + } + + [[nodiscard]] auto is_command(std::string_view name) const -> bool { + for (std::size_t i = 0; i < command_count; ++i) { + if (commands[i].name == name) return true; + } + return false; + } + }; + + auto get_option_spec_map(const T& val) const -> Lookup { + Lookup lookup{}; for_each_field(val, [&](const auto& f) -> auto { using F = std::remove_cvref_t; if constexpr (std::derived_from) { - for (const auto& p : cfg_.option_prefixes) - spec_map.emplace(p + std::string(F::name.view()), F::nargs); - if constexpr (F::short_name != '\0') - spec_map.emplace( - cfg_.short_option_prefix + std::string(1, F::short_name), - F::nargs); + for (const auto& p : cfg_.option_prefixes()) { + lookup.add_long_option(p, F::name.view(), F::nargs); + } + if constexpr (F::short_name != '\0') { + lookup.add_short_option(cfg_.short_option_prefix(), F::short_name, + F::nargs); + } + } else if constexpr (std::derived_from) { + lookup.add_command(F::command_name()); } - if constexpr (std::derived_from) - command_names.emplace(std::string(F::command_name())); }); - return {spec_map, command_names}; + return lookup; } // Find the option field matching (prefix, bare), notify it, and invoke its @@ -870,14 +1146,14 @@ struct Parser { return; } bool match = false; - for (const auto& p : cfg_.option_prefixes) { + for (const auto& p : cfg_.option_prefixes()) { if (p == prefix && F::name.view() == bare) { match = true; break; } } if (!match && F::short_name != '\0' && - cfg_.short_option_prefix == prefix && bare.size() == 1 && + cfg_.short_option_prefix() == prefix && bare.size() == 1 && bare[0] == F::short_name) { match = true; } @@ -951,8 +1227,8 @@ struct Parser { found = true; f.mark_provided(); auto sub_parser = Parser{}; - sub_parser.set_program_name( - std::format("{} {}", program_name_, F::command_name())); + sub_parser.set_program_name(program_name_ + " " + + std::string(F::command_name())); auto sub = sub_parser.parse(args, first_index); if (sub.has_error()) { res = ActionResult::fail(std::move(sub.error)); @@ -1192,9 +1468,10 @@ auto parse(int argc, char* argv[]) -> ParseResult { /** * @brief Parses `argc`/`argv` and exits the process on failure. * - * If parsing fails, writes the error message to `err` (or `out` for - * help/exit-success) and calls `std::exit` with the appropriate exit code. On - * success, returns the fully populated argument struct by value. + * If parsing fails, writes the error message directly to `stderr` (or `stdout` + * for help/exit-success) using stdio and calls `std::exit` with the appropriate + * exit code. Include `cli/parser_stdio.hh` or `cli/parser_iostream.hh` when the + * output destination must be customized. * * This is the typical one-liner entry point for command-line tools: * @code @@ -1204,19 +1481,16 @@ auto parse(int argc, char* argv[]) -> ParseResult { * @tparam T The argument specification type. * @param argc Argument count (includes program name at index 0). * @param argv Argument vector. - * @param out Output stream for help/success messages. Default: `std::cout`. - * @param err Output stream for error messages. Default: `std::cerr`. * @return The parsed argument struct (never returns on failure). */ template // NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays, modernize-avoid-c-arrays) -auto parse_or_exit(int argc, char* argv[], std::ostream& out = std::cout, - std::ostream& err = std::cerr) -> T { +auto parse_or_exit(int argc, char* argv[]) -> T { auto result = parse(argc, argv); if (!result) { if (const auto message = result.error.message(); !message.empty()) { - auto& stream = result.error.use_stdout() ? out : err; - stream << message << '\n'; + detail::write_message(result.error.use_stdout() ? stdout : stderr, + message); } std::exit(result.error.exit_code()); } diff --git a/include/cli/parser_iostream.hh b/include/cli/parser_iostream.hh new file mode 100644 index 0000000..1b6a888 --- /dev/null +++ b/include/cli/parser_iostream.hh @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +#include "cli/parser.hh" + +namespace cli { + +/** + * @brief Parses `argc`/`argv` and exits the process on failure. + * + * This overload writes help/exit-success messages to `out` and errors to `err` + * using iostreams. Include this header only when stream-based output + * customization is needed. + */ +template +// NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays, modernize-avoid-c-arrays) +auto parse_or_exit(int argc, char* argv[], std::ostream& out, std::ostream& err) + -> T { + auto result = parse(argc, argv); + if (!result) { + if (const auto message = result.error.message(); !message.empty()) { + auto& stream = result.error.use_stdout() ? out : err; + stream << message << '\n'; + } + std::exit(result.error.exit_code()); + } + return std::move(result.value); +} + +} // namespace cli diff --git a/include/cli/parser_stdio.hh b/include/cli/parser_stdio.hh new file mode 100644 index 0000000..089b267 --- /dev/null +++ b/include/cli/parser_stdio.hh @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include "cli/parser.hh" + +namespace cli { + +/** + * @brief Parses `argc`/`argv` and exits the process on failure. + * + * This overload writes help/exit-success messages to `out` and errors to `err` + * using stdio. Include this header when the FILE destinations must be + * customized; `parser.hh` provides the simpler stdout/stderr fixed version. + */ +template +// NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays, modernize-avoid-c-arrays) +auto parse_or_exit(int argc, char* argv[], FILE* out, FILE* err) -> T { + auto result = parse(argc, argv); + if (!result) { + if (const auto message = result.error.message(); !message.empty()) { + detail::write_message(result.error.use_stdout() ? out : err, message); + } + std::exit(result.error.exit_code()); + } + return std::move(result.value); +} + +} // namespace cli diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7cafc96..761c61a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -60,3 +60,22 @@ function(add_compile_fail_test name source) message(STATUS "Compile-fail test passed: ${name}") endif() endfunction() + +function(add_compile_pass_test name source) + try_compile( + COMPILE_RESULT + ${CMAKE_BINARY_DIR}/try_compile/${name} + ${CMAKE_CURRENT_SOURCE_DIR}/compile_pass/${source} + CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${PROJECT_SOURCE_DIR}/include" + CXX_STANDARD 20 + OUTPUT_VARIABLE COMPILE_OUTPUT + ) + if(NOT COMPILE_RESULT) + message(FATAL_ERROR + "Compile-pass test '${name}' failed.\n${COMPILE_OUTPUT}") + else() + message(STATUS "Compile-pass test passed: ${name}") + endif() +endfunction() + +add_compile_pass_test(token_stack_capacity token_stack_capacity.cc) diff --git a/tests/compile_pass/token_stack_capacity.cc b/tests/compile_pass/token_stack_capacity.cc new file mode 100644 index 0000000..7ca848d --- /dev/null +++ b/tests/compile_pass/token_stack_capacity.cc @@ -0,0 +1,7 @@ +#define CLI20_TOKEN_STACK_CAPACITY 4 + +#include "cli/parser.hh" + +static_assert(cli::token_stack_capacity == 4); + +auto main() -> int { return 0; } diff --git a/tests/test_tokenize.cc b/tests/test_tokenize.cc index 7cf3791..4d3ebd8 100644 --- a/tests/test_tokenize.cc +++ b/tests/test_tokenize.cc @@ -1,5 +1,8 @@ #include +#include +#include + #include "cli/parser.hh" // ---- Test infrastructure -------------------------------------------------- @@ -25,11 +28,21 @@ static auto tok(std::initializer_list il) { return std::vector(il); } -// Invoke tokenize with vector args. static auto tokenize(std::initializer_list args, const SpecMap& spec, const CmdSet& cmds = {}) -> TokenizeResult { - return cli::tokenize(tok(args), spec, cmds); + auto option_nargs = [&](std::string_view opt) -> std::optional { + if (auto it = spec.find(std::string(opt)); it != spec.end()) { + return it->second; + } + return std::nullopt; + }; + + auto is_command = [&](std::string_view name) -> bool { + return cmds.contains(std::string(name)); + }; + + return cli::tokenize(tok(args), option_nargs, is_command); } // Collect texts of tokens with the given type, in order. @@ -84,7 +97,7 @@ TEST(Tokenize, FlagEmitsOptionTokenOnly) { ASSERT_EQ(r.tokens.size(), 1u); EXPECT_EQ(r.tokens[0].type, TokenType::option); EXPECT_EQ(r.tokens[0].text, "verbose"); - EXPECT_EQ(r.tokens[0].matched_prefix, "--"); + EXPECT_EQ(cli::token_prefix_view(r.tokens[0], {}), "--"); } TEST(Tokenize, FlagDoesNotConsumeNextToken) { @@ -113,7 +126,7 @@ TEST(Tokenize, OptionExactlyOneValue) { ASSERT_EQ(r.tokens.size(), 2u); EXPECT_EQ(r.tokens[0].type, TokenType::option); EXPECT_EQ(r.tokens[0].text, "name"); - EXPECT_EQ(r.tokens[0].matched_prefix, "--"); + EXPECT_EQ(cli::token_prefix_view(r.tokens[0], {}), "--"); EXPECT_EQ(r.tokens[0].position, 0u); EXPECT_EQ(r.tokens[1].type, TokenType::value); EXPECT_EQ(r.tokens[1].text, "Alice"); @@ -176,7 +189,7 @@ TEST(Tokenize, InlineSyntaxSplitsIntoOptionAndValueAtSamePosition) { ASSERT_EQ(r.tokens.size(), 2u); EXPECT_EQ(r.tokens[0].type, TokenType::option); EXPECT_EQ(r.tokens[0].text, "name"); - EXPECT_EQ(r.tokens[0].matched_prefix, "--"); + EXPECT_EQ(cli::token_prefix_view(r.tokens[0], {}), "--"); EXPECT_EQ(r.tokens[0].position, 0u); EXPECT_EQ(r.tokens[1].type, TokenType::value); EXPECT_EQ(r.tokens[1].text, "Alice"); @@ -443,42 +456,53 @@ TEST(Tokenize, ErrorInlineSyntaxCannotSatisfyMinTwo) { static auto tokenize_cfg(std::initializer_list args, const SpecMap& spec, const cli::TokenizerConfig& cfg, const CmdSet& cmds = {}) -> TokenizeResult { - return cli::tokenize(tok(args), spec, cmds, cfg); + auto option_nargs = [&](std::string_view opt) -> std::optional { + if (auto it = spec.find(std::string(opt)); it != spec.end()) { + return it->second; + } + return std::nullopt; + }; + + auto is_command = [&](std::string_view name) -> bool { + return cmds.contains(std::string(name)); + }; + + return cli::tokenize(tok(args), option_nargs, is_command, cfg); } TEST(TokenizeMultiPrefix, AltPrefixRecognised) { // "+verbose" recognised as option when "+" is in option_prefixes - cli::TokenizerConfig cfg{.option_prefixes = {"--", "+"}}; + auto cfg = cli::TokenizerConfig{}.with_option_prefixes<"--", "+">(); auto r = tokenize_cfg({"+verbose"}, {{"+verbose", kFlag}}, cfg); ASSERT_TRUE(r.has_value()); ASSERT_EQ(r.tokens.size(), 1u); EXPECT_EQ(r.tokens[0].type, TokenType::option); EXPECT_EQ(r.tokens[0].text, "verbose"); - EXPECT_EQ(r.tokens[0].matched_prefix, "+"); + EXPECT_EQ(cli::token_prefix_view(r.tokens[0], cfg), "+"); } TEST(TokenizeMultiPrefix, MatchedPrefixDistinguishesPrefixes) { // "--foo" and "+foo" are different options; matched_prefix tells them apart - cli::TokenizerConfig cfg{.option_prefixes = {"--", "+"}}; + auto cfg = cli::TokenizerConfig{}.with_option_prefixes<"--", "+">(); SpecMap spec{{"--foo", kFlag}, {"+foo", kFlag}}; auto r = tokenize_cfg({"--foo", "+foo"}, spec, cfg); ASSERT_TRUE(r.has_value()); ASSERT_EQ(r.tokens.size(), 2u); - EXPECT_EQ(r.tokens[0].matched_prefix, "--"); - EXPECT_EQ(r.tokens[1].matched_prefix, "+"); + EXPECT_EQ(cli::token_prefix_view(r.tokens[0], cfg), "--"); + EXPECT_EQ(cli::token_prefix_view(r.tokens[1], cfg), "+"); } TEST(TokenizeMultiPrefix, DefaultPrefixStillWorks) { // Default config ({"--"}) behaves identically to before auto r = tokenize_cfg({"--name", "Alice"}, {{"--name", kOne}}, {}); ASSERT_TRUE(r.has_value()); - EXPECT_EQ(r.tokens[0].matched_prefix, "--"); + EXPECT_EQ(cli::token_prefix_view(r.tokens[0], {}), "--"); EXPECT_EQ(r.tokens[1].text, "Alice"); - EXPECT_TRUE(r.tokens[1].matched_prefix.empty()); + EXPECT_TRUE(cli::token_prefix_view(r.tokens[1], {}).empty()); } TEST(TokenizeMultiPrefix, UnknownAltPrefixTokenIsError) { - cli::TokenizerConfig cfg{.option_prefixes = {"--", "+"}}; + auto cfg = cli::TokenizerConfig{}.with_option_prefixes<"--", "+">(); auto r = tokenize_cfg({"+unknown"}, {{"--foo", kFlag}}, cfg); ASSERT_FALSE(r.has_value()); EXPECT_EQ(r.error.code, ErrorCode::unknown_option); @@ -488,7 +512,7 @@ TEST(TokenizeMultiPrefix, UnknownAltPrefixTokenIsError) { // ---- inline_value_separator ----------------------------------------------- TEST(TokenizeInlineSep, ColonSeparator) { - cli::TokenizerConfig cfg{.inline_value_separator = ':'}; + auto cfg = cli::TokenizerConfig{}.with_inline_value_separator(':'); auto r = tokenize_cfg({"--name:Alice"}, {{"--name", kOne}}, cfg); ASSERT_TRUE(r.has_value()); ASSERT_EQ(r.tokens.size(), 2u); @@ -505,7 +529,7 @@ TEST(TokenizeInlineSep, DefaultEqualsStillWorks) { TEST(TokenizeInlineSep, EqualsTreatedAsValueWhenSepIsColon) { // With sep=':', "--name=Alice" has no separator → entire token is option name. - cli::TokenizerConfig cfg{.inline_value_separator = ':'}; + auto cfg = cli::TokenizerConfig{}.with_inline_value_separator(':'); auto r = tokenize_cfg({"--name=Alice"}, {{"--name=Alice", kFlag}}, cfg); ASSERT_TRUE(r.has_value()); EXPECT_EQ(r.tokens[0].text, "name=Alice"); @@ -531,7 +555,7 @@ TEST(TokenizeCluster, SingleFlagStillWorks) { ASSERT_TRUE(r.has_value()); ASSERT_EQ(r.tokens.size(), 1u); EXPECT_EQ(r.tokens[0].text, "v"); - EXPECT_EQ(r.tokens[0].matched_prefix, "-"); + EXPECT_EQ(cli::token_prefix_view(r.tokens[0], {}), "-"); } TEST(TokenizeCluster, FlagsThenValueNextToken) { @@ -551,7 +575,7 @@ TEST(TokenizeCluster, AttachedValueOnly) { ASSERT_TRUE(r.has_value()); ASSERT_EQ(r.tokens.size(), 2u); EXPECT_EQ(r.tokens[0].text, "o"); - EXPECT_EQ(r.tokens[0].matched_prefix, "-"); + EXPECT_EQ(cli::token_prefix_view(r.tokens[0], {}), "-"); EXPECT_EQ(r.tokens[1].type, TokenType::value); EXPECT_EQ(r.tokens[1].text, "file"); EXPECT_EQ(r.tokens[1].position, 0u); @@ -643,7 +667,7 @@ TEST(Tokenize, ShortOptionInlineSeparator) { ASSERT_EQ(r.tokens.size(), 2u); EXPECT_EQ(r.tokens[0].type, TokenType::option); EXPECT_EQ(r.tokens[0].text, "o"); - EXPECT_EQ(r.tokens[0].matched_prefix, "-"); + EXPECT_EQ(cli::token_prefix_view(r.tokens[0], {}), "-"); EXPECT_EQ(r.tokens[1].type, TokenType::value); EXPECT_EQ(r.tokens[1].text, "file"); }