Using CMake template

This commit is contained in:
alejo committed 2026-08-20 14:58:14 -03:00
1 parent e878bdaf5a
commit fe6a77de83
47 files changed
+5357 -2192

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
---
# .clang-format
# Enforces the formatting rules from the C/C++ Coding Style Guide (Part I: C++ Style).
# Applies equally to Part II C API files per §15 (Indentation and Braces) — same
# tabs/Allman/line-length rules, just different naming (naming can't be enforced
# by clang-format; see .clang-tidy and TOOLING.md).
Language: Cpp
Standard: Latest
# --- Indentation (style guide §2) ---
UseTab: Always
TabWidth: 4
IndentWidth: 4
ContinuationIndentWidth: 4
IndentCaseLabels: true
IndentPPDirectives: None
IndentExternBlock: Indent
# Access specifiers (public:/private:) sit unindented, flush with the class
# keyword — only members are indented one level, matching every example in §1/§6.
AccessModifierOffset: -4
# Namespace bodies are indented like any other block (style guide §Namespaces).
NamespaceIndentation: All
# --- Braces: Allman style everywhere (style guide §3) ---
BreakBeforeBraces: Allman
Cpp11BracedListStyle: true
AllowShortFunctionsOnASingleLine: Empty
AllowShortIfStatementsOnASingleLine: Never
AllowShortLoopsOnASingleLine: false
AllowShortBlocksOnASingleLine: Never
AllowShortEnumsOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
# No trailing "// namespace X" / "// class X" comments on closing braces
# (style guide §Namespaces / §6).
FixNamespaceComments: false
# --- Pointers and references attach to the type (style guide §4) ---
PointerAlignment: Left
ReferenceAlignment: Pointer
DerivePointerAlignment: false
# --- Line length (style guide §8) ---
ColumnLimit: 100
# --- Spacing (style guide §5) ---
SpaceBeforeParens: ControlStatements
SpacesInParentheses: false
SpacesInSquareBrackets: false
SpaceAfterCStyleCast: false
SpaceBeforeAssignmentOperators: true
SpacesInAngles: false
# --- Include ordering (style guide §7: main header, C headers, C++ std,
# third-party, project headers) ---
SortIncludes: true
SortUsingDeclarations: true
IncludeBlocks: Regroup
IncludeIsMainRegex: '(Test)?$'
IncludeCategories:
# 1. Corresponding header is auto-detected and always sorted first.
# 2. C system headers wrapped for C++ (<cstdio>, <cstdint>, ...).
- Regex: '^<(cassert|cctype|cerrno|cfenv|cfloat|cinttypes|climits|clocale|cmath|csetjmp|csignal|cstdarg|cstddef|cstdint|cstdio|cstdlib|cstring|ctime|cuchar|cwchar|cwctype)>$'
Priority: 1
CaseSensitive: true
# 3. C++ standard library headers (no dot, no slash — <vector>, <memory>, ...).
- Regex: '^<[a-z_]+>$'
Priority: 2
CaseSensitive: true
# 4. Third-party / external library headers (<fmt/format.h>, <boost/...>, ...).
- Regex: '^<.*>$'
Priority: 3
CaseSensitive: true
# 5. Project headers, quoted ("NetworkManager.hpp", ...).
- Regex: '^".*"$'
Priority: 4
CaseSensitive: true
# --- Misc ---
AlignTrailingComments: true
AlignConsecutiveAssignments: false
AlignConsecutiveDeclarations: false
BinPackArguments: true
BinPackParameters: true
BreakConstructorInitializers: BeforeColon
ConstructorInitializerIndentWidth: 4
PenaltyReturnTypeOnItsOwnLine: 1000
+151
View File
@@ -0,0 +1,151 @@
---
# .clang-tidy
# Enforces naming conventions (style guide §1) and a subset of the Language
# Feature Do's and Don'ts (style guide §10) for C++ code. Place at the repo
# root for Part I (C++) files. For Part II (C API) files, use the
# directory-scoped override in c-api/.clang-tidy instead — clang-tidy picks
# the closest .clang-tidy file up the directory tree, so nesting one there
# gives that subtree different naming rules without affecting the rest of
# the project. See TOOLING.md for what this file can't check.
Checks: >
-*,
readability-identifier-naming,
modernize-use-nullptr,
modernize-use-override,
modernize-use-equals-default,
modernize-use-equals-delete,
modernize-loop-convert,
modernize-concat-nested-namespaces,
modernize-use-default-member-init,
modernize-avoid-c-arrays,
modernize-use-nodiscard,
cppcoreguidelines-special-member-functions,
cppcoreguidelines-owning-memory,
cppcoreguidelines-no-malloc,
cppcoreguidelines-pro-type-member-init,
cppcoreguidelines-pro-type-cstyle-cast,
cppcoreguidelines-macro-usage,
performance-unnecessary-value-param,
performance-unnecessary-copy-initialization,
bugprone-exception-escape,
bugprone-use-after-move
WarningsAsErrors: ''
HeaderFilterRegex: '.*\.hpp$'
FormatStyle: file
CheckOptions:
# --- Naming Conventions — style guide §1 ---
- key: readability-identifier-naming.NamespaceCase
value: lower_case
- key: readability-identifier-naming.ClassCase
value: CamelCase
- key: readability-identifier-naming.StructCase
value: CamelCase
- key: readability-identifier-naming.EnumCase
value: CamelCase
- key: readability-identifier-naming.EnumConstantCase
value: CamelCase
- key: readability-identifier-naming.FunctionCase
value: camelBack
- key: readability-identifier-naming.MethodCase
value: camelBack
- key: readability-identifier-naming.VariableCase
value: camelBack
- key: readability-identifier-naming.LocalVariableCase
value: camelBack
- key: readability-identifier-naming.ParameterCase
value: camelBack
# A plain local `const` (not `static`, not `constexpr`) stays under the
# Local Variables rule (camelCase) per the style guide's own note that
# only *global/static* const/constexpr values are "constants" for this
# rule's purposes -- without this, it silently inherits the general
# ConstantCase (PascalCase) fallback below instead, which would flag
# totally ordinary code like `const std::string name = ...;` inside a
# function. StaticConstantCase/GlobalConstantCase/ConstexprVariableCase
# below are unaffected and still require PascalCase, matching the guide.
- key: readability-identifier-naming.LocalConstantCase
value: camelBack
# Private/protected member variables: camelCase with an m_ prefix.
- key: readability-identifier-naming.PrivateMemberCase
value: camelBack
- key: readability-identifier-naming.PrivateMemberPrefix
value: m_
- key: readability-identifier-naming.ProtectedMemberCase
value: camelBack
- key: readability-identifier-naming.ProtectedMemberPrefix
value: m_
# Public members (e.g. plain data structs) — camelCase, no prefix.
- key: readability-identifier-naming.PublicMemberCase
value: camelBack
# Static class data members — camelCase with an s_ prefix (distinct from
# instance members above). "ClassMember" is clang-tidy's category for
# static (non-const) data members specifically.
- key: readability-identifier-naming.ClassMemberCase
value: camelBack
- key: readability-identifier-naming.ClassMemberPrefix
value: s_
# Global (namespace/file-scope, mutable) variables — camelCase with a g_
# prefix, to flag genuinely global mutable state at every use site.
- key: readability-identifier-naming.GlobalVariableCase
value: camelBack
- key: readability-identifier-naming.GlobalVariablePrefix
value: g_
# Static variables — function-local `static` or file-scope `static` —
# camelCase with an s_ prefix, for the same reason as globals above.
- key: readability-identifier-naming.StaticVariableCase
value: camelBack
- key: readability-identifier-naming.StaticVariablePrefix
value: s_
# Constants and enum values — PascalCase (style guide §1 table).
- key: readability-identifier-naming.ConstantCase
value: CamelCase
- key: readability-identifier-naming.GlobalConstantCase
value: CamelCase
- key: readability-identifier-naming.StaticConstantCase
value: CamelCase
- key: readability-identifier-naming.ClassConstantCase
value: CamelCase
- key: readability-identifier-naming.ConstexprVariableCase
value: CamelCase
# Template parameters — PascalCase (e.g. ValueType).
- key: readability-identifier-naming.TemplateParameterCase
value: CamelCase
- key: readability-identifier-naming.TypeTemplateParameterCase
value: CamelCase
# Type aliases / typedefs — PascalCase, matching class naming.
- key: readability-identifier-naming.TypeAliasCase
value: CamelCase
- key: readability-identifier-naming.TypedefCase
value: CamelCase
# Macros — ALL_CAPS_SNAKE_CASE (the one deliberate exception in §1).
- key: readability-identifier-naming.MacroDefinitionCase
value: UPPER_CASE
# --- cppcoreguidelines-owning-memory — style guide §10, Memory and Ownership ---
# Flags raw new/delete and legacy C allocators used for ownership instead of
# std::unique_ptr / std::shared_ptr.
- key: cppcoreguidelines-owning-memory.LegacyResourceProducers
value: 'malloc;calloc;realloc;strdup;strndup'
- key: cppcoreguidelines-owning-memory.LegacyResourceConsumers
value: 'free;realloc'
# --- cppcoreguidelines-macro-usage — style guide §10, prefer constexpr over #define ---
# Allow include-guard-style and project-prefixed macros; flag everything else
# that could instead be a constexpr constant.
- key: cppcoreguidelines-macro-usage.AllowedRegexp
value: '^[A-Z0-9_]+_H(PP)?$'
- key: cppcoreguidelines-macro-usage.CheckCapsOnly
value: false
+29
View File
@@ -0,0 +1,29 @@
# .editorconfig
# Baseline whitespace/indentation rules for editors that don't run
# clang-format live (or before it runs). See .clang-format for the
# authoritative, full formatting rules from the style guide.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
# Style guide §2 / §15 — tabs everywhere, C++ and C API alike.
[*.{c,h,cpp,hpp,cc,cxx,tpp}]
indent_style = tab
indent_size = 4
tab_width = 4
max_line_length = 100
# Config/build files typically use spaces by convention — not covered by the
# C/C++ style guide, kept here only so this file has a sane fallback for
# CMakeLists.txt, YAML, etc. in the same repo.
[*.{yml,yaml,cmake,json}]
indent_style = space
indent_size = 2
[CMakeLists.txt]
indent_style = space
indent_size = 2
+27
View File
@@ -0,0 +1,27 @@
# .gitattributes
#
# Forces LF line endings for source files regardless of OS or each
# contributor's core.autocrlf setting. Without this, the same file can have
# CRLF on a Windows checkout and LF on Linux/Mac, which makes clang-format
# and the pre-commit hook's diff check report false formatting differences
# that have nothing to do with actual style.
* text=auto eol=lf
# Be explicit for the style guide's own file types, on top of the general
# rule above.
*.c text eol=lf
*.h text eol=lf
*.cpp text eol=lf
*.hpp text eol=lf
*.cc text eol=lf
*.cxx text eol=lf
*.tpp text eol=lf
# Windows-specific scripts, if any get added later, should keep CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
# Never touch binaries.
*.png binary
*.jpg binary
*.ico binary
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
#
# .githooks/pre-commit
#
# Runs clang-format and clang-tidy against staged C/C++ files before each
# commit — no Python, no pre-commit framework, just bash and (optionally)
# the clang tools themselves.
#
# Neither tool is required to have this hook installed: if a binary isn't
# on PATH, this prints a warning and skips that check instead of blocking
# the commit. Only *actual formatting/lint issues* from a tool that IS
# installed will block a commit.
#
# Platforms:
# - Linux / macOS: runs under the system bash (or Homebrew bash on macOS)
# with no extra setup.
# - Windows: runs automatically through Git for Windows' bundled Git Bash —
# git.exe recognizes the "#!/usr/bin/env bash" line above and executes
# hooks through it directly, so no WSL, Cygwin, or separate bash install
# is needed. Everything this script uses (mktemp, diff, grep, printf,
# command -v) ships with Git for Windows out of the box.
#
# One-time setup per clone (same command on every OS):
# chmod +x .githooks/pre-commit (no-op on Windows, harmless either way)
# git config core.hooksPath .githooks
set -uo pipefail
# Colors only when writing to an interactive terminal — avoids raw escape
# codes in CI logs, redirected output, or terminals that don't support ANSI.
if [ -t 1 ]; then
RED=$'\033[0;31m'
YELLOW=$'\033[0;33m'
GREEN=$'\033[0;32m'
RESET=$'\033[0m'
else
RED=''
YELLOW=''
GREEN=''
RESET=''
fi
exit_code=0
# --- Collect staged C/C++ files (added/copied/modified) ---
# ':!vendor/*' excludes third-party code vendored under vendor/ (see
# cmake/Dependencies.cmake in the parent project) -- it isn't this
# project's code, so it isn't held to this style guide. Not part of the
# toolkit as shipped; added for this project's vendor/ convention.
files=()
while IFS= read -r line; do
[ -n "$line" ] && files+=("$line")
done < <(git diff --cached --name-only --diff-filter=ACM -- \
'*.c' '*.h' '*.cpp' '*.hpp' '*.cc' '*.cxx' '*.tpp' ':!vendor/*')
if [ ${#files[@]} -eq 0 ]; then
exit 0
fi
# --- clang-format: check staged files are already formatted ---
if command -v clang-format >/dev/null 2>&1; then
unformatted=()
for f in "${files[@]}"; do
[ -f "$f" ] || continue
if ! diff -q <(clang-format "$f") "$f" >/dev/null 2>&1; then
unformatted+=("$f")
fi
done
if [ ${#unformatted[@]} -gt 0 ]; then
echo "${RED}clang-format: these files aren't formatted:${RESET}"
printf ' %s\n' "${unformatted[@]}"
echo "Fix with: clang-format -i ${unformatted[*]}"
exit_code=1
fi
else
echo "${YELLOW}warning: clang-format not found on PATH — skipping format check.${RESET}"
fi
# --- clang-tidy: static analysis, needs a compile database ---
if command -v clang-tidy >/dev/null 2>&1; then
if [ ! -f compile_commands.json ]; then
echo "${YELLOW}warning: compile_commands.json not found — skipping clang-tidy.${RESET}"
echo "${YELLOW}Generate one with: cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ...${RESET}"
else
tidy_output=$(mktemp)
tidy_had_issues=0
for f in "${files[@]}"; do
[ -f "$f" ] || continue
if ! clang-tidy --quiet "$f" >>"$tidy_output" 2>&1; then
tidy_had_issues=1
fi
done
if grep -q "warning:\|error:" "$tidy_output" 2>/dev/null; then
echo "${RED}clang-tidy found issues:${RESET}"
cat "$tidy_output"
exit_code=1
elif [ "$tidy_had_issues" -eq 1 ]; then
# Non-zero exit with no warning/error text usually means clang-tidy
# itself couldn't run (e.g. missing headers) — surface it, but
# don't block the commit on a tooling problem rather than a style one.
echo "${YELLOW}warning: clang-tidy exited with an error unrelated to style:${RESET}"
cat "$tidy_output"
fi
rm -f "$tidy_output"
fi
else
echo "${YELLOW}warning: clang-tidy not found on PATH — skipping static analysis.${RESET}"
fi
if [ $exit_code -eq 0 ]; then
echo "${GREEN}pre-commit checks passed.${RESET}"
fi
exit $exit_code
+4 -2
View File
@@ -1,2 +1,4 @@
build/
out/
.cache/
compile_commands.json
.vs/
+342 -16
View File
@@ -1,25 +1,351 @@
cmake_minimum_required(VERSION 3.16)
project(cliforge CXX)
### Project
cmake_minimum_required(VERSION 3.25)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
project(
DemoApp
VERSION 0.1.0
DESCRIPTION "Demo app exercising the generator-friendly CMake module system"
LANGUAGES CXX
)
# Machine-friendly identifier baked into every target as PROJECT_NAME_CODE
# (used by BuildHelpers.cmake's _finalize_module_target). Edit this
# alongside the project() call above when retargeting this template.
set(PROJECT_CODE_NAME "demo")
# All output-path configuration (out/, install/, dist/, docs/, and the
# bin/lib subdirectory names) lives in Configuration.cmake -- included
# early since the output-directory setup just below depends on it.
include("${CMAKE_CURRENT_SOURCE_DIR}/Configuration.cmake")
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# cliforge is header-only.
add_library(cliforge INTERFACE)
target_include_directories(cliforge INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include)
# Every static library in the project gets built with -fPIC by default --
# including vendored/fetched ones, whose own CMakeLists.txt we don't
# control and can't rely on to opt in themselves. Without this, a static
# dependency (ours or third-party) fails to link the moment it ends up
# pulled into a SHARED module: "recompile with -fPIC". Cheap on every
# platform that matters here; add_lib_module's STATIC branch also sets
# this per-target as a belt-and-braces fallback.
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
add_executable(packctl examples/main.cpp)
target_link_libraries(packctl PRIVATE cliforge)
target_compile_options(packctl PRIVATE -Wall -Wextra)
# -------------------------------------------------------
# Generator guard -- require Ninja (single or multi-config)
# -------------------------------------------------------
if(NOT CMAKE_GENERATOR MATCHES "Ninja")
message(WARNING
"This project targets Ninja. "
"Current generator: '${CMAKE_GENERATOR}'. "
"Reconfigure with: cmake -G Ninja or cmake -G \"Ninja Multi-Config\"")
endif()
enable_testing()
# Single-config Ninja needs CMAKE_BUILD_TYPE at configure time.
# Ninja Multi-Config uses CMAKE_CONFIGURATION_TYPES instead (no BUILD_TYPE needed).
if(CMAKE_GENERATOR STREQUAL "Ninja")
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE "Release" CACHE STRING
"Build type (Debug|Release|RelWithDebInfo|MinSizeRel)" FORCE)
message(STATUS "CMAKE_BUILD_TYPE not set -- defaulting to Release")
endif()
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
Debug Release RelWithDebInfo MinSizeRel)
endif()
add_executable(type_test tests/type_test.cpp)
target_link_libraries(type_test PRIVATE cliforge)
target_compile_options(type_test PRIVATE -Wall -Wextra)
# -------------------------------------------------------
# Flat output layout
# Multi-config generators (Ninja Multi-Config) append /<Config> by default.
# Override all four configs so both Ninja variants produce the same layout.
# Subdirectory names come from Configuration.cmake (RUNTIME_OUTPUT_SUBDIR
# etc.); the base directory is always CMAKE_BINARY_DIR, wherever -B points
# (scripts/build.sh defaults to out/build -- see Configuration.cmake).
# -------------------------------------------------------
foreach(_cfg Debug Release RelWithDebInfo MinSizeRel)
string(TOUPPER "${_cfg}" _CFG)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_${_CFG} "${CMAKE_BINARY_DIR}/${RUNTIME_OUTPUT_SUBDIR}")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_${_CFG} "${CMAKE_BINARY_DIR}/${LIBRARY_OUTPUT_SUBDIR}")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_${_CFG} "${CMAKE_BINARY_DIR}/${ARCHIVE_OUTPUT_SUBDIR}")
endforeach()
add_executable(registration_errors_test tests/registration_errors_test.cpp)
target_link_libraries(registration_errors_test PRIVATE cliforge)
target_compile_options(registration_errors_test PRIVATE -Wall -Wextra)
# -------------------------------------------------------
# RPATH strategy
# By default CMake builds with a temporary RPATH and relinks at install time
# to embed the final one. Ninja on Linux with Clang does not support that
# relink step and will error out. Setting this ON tells CMake to embed the
# install RPATH at build time so no relink is ever needed.
# This is safe here because all our RPATHs are $ORIGIN-relative, so the
# same value is correct for both the build tree and the installed layout.
# Guarded to non-Windows since RPATH is an ELF concept only.
# -------------------------------------------------------
if(NOT WIN32)
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
endif()
add_test(NAME registration_errors COMMAND registration_errors_test)
# -------------------------------------------------------
# options
# -------------------------------------------------------
option(BUILD_TESTS "Build test binaries" OFF)
option(BUILD_EXAMPLES "Build example binaries" OFF)
option(INSTALL_TESTS "Install test binaries" OFF)
option(INSTALL_EXAMPLES "Install example binaries" OFF)
# Project-wide default for add_lib_module() when a module doesn't pass
# TYPE explicitly. This is CMake's own BUILD_SHARED_LIBS variable, so it
# also affects any third-party add_library() calls (FetchContent, etc.)
# that respect it. Override per-module with add_lib_module(... TYPE
# STATIC|SHARED ...), or globally with -DBUILD_SHARED_LIBS=ON.
option(BUILD_SHARED_LIBS "Build libraries as SHARED by default" OFF)
# INSTALL_* implies BUILD_* so targets exist when cmake --install runs
if(INSTALL_TESTS)
set(BUILD_TESTS ON CACHE BOOL "" FORCE)
endif()
if(INSTALL_EXAMPLES)
set(BUILD_EXAMPLES ON CACHE BOOL "" FORCE)
endif()
message(STATUS "BUILD_TESTS = ${BUILD_TESTS}")
message(STATUS "BUILD_EXAMPLES = ${BUILD_EXAMPLES}")
message(STATUS "BUILD_SHARED_LIBS (default lib TYPE) = ${BUILD_SHARED_LIBS}")
# -------------------------------------------------------
# DIST_TARGET resolution
#
# Preferred: pass -DDIST_TARGET=<value> explicitly, or use a CMakePresets.json
# preset (Visual Studio and CLion pick these up automatically).
#
# Fallback: if DIST_TARGET is not set, auto-detect from the compiler and
# platform. This keeps the project openable directly in an IDE without any
# preset selected, at the cost of skipping architecture validation.
#
# Valid explicit values:
# linux-x64-gcc linux-x64-clang
# windows-x64-msvc windows-x64-clang
# -------------------------------------------------------
if(NOT DIST_TARGET)
if(WIN32)
if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
set(DIST_TARGET "windows-x64-clang")
else()
# cl.exe reports MSVC; treat anything else on Windows as MSVC too.
set(DIST_TARGET "windows-x64-msvc")
endif()
else()
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
set(DIST_TARGET "linux-x64-gcc")
else()
set(DIST_TARGET "linux-x64-clang")
endif()
endif()
message(STATUS "DIST_TARGET not set -- auto-detected: ${DIST_TARGET}")
message(STATUS " (use CMakePresets.json or -DDIST_TARGET=<value> to set it explicitly)")
else()
message(STATUS "DIST_TARGET = ${DIST_TARGET}")
endif()
# -------------------------------------------------------
# Platform / compiler metadata from DIST_TARGET
# -------------------------------------------------------
if(DIST_TARGET STREQUAL "linux-x64-gcc")
set(PLATFORM "linux")
set(ARCHITECTURE "x64")
set(COMPILER_ID "gcc")
elseif(DIST_TARGET STREQUAL "linux-x64-clang")
set(PLATFORM "linux")
set(ARCHITECTURE "x64")
set(COMPILER_ID "clang")
elseif(DIST_TARGET STREQUAL "windows-x64-msvc")
set(PLATFORM "windows")
set(ARCHITECTURE "x64")
set(COMPILER_ID "msvc")
elseif(DIST_TARGET STREQUAL "windows-x64-clang")
set(PLATFORM "windows")
set(ARCHITECTURE "x64")
set(COMPILER_ID "clang")
else()
message(FATAL_ERROR
"Unsupported DIST_TARGET: '${DIST_TARGET}'.\n"
"Valid values: linux-x64-gcc linux-x64-clang "
"windows-x64-msvc windows-x64-clang\n"
"Tip: open CMakePresets.json in your IDE to select a preset, "
"or pass -DDIST_TARGET=<value> on the command line.")
endif()
message(STATUS "Platform = ${PLATFORM}")
message(STATUS "Architecture = ${ARCHITECTURE}")
message(STATUS "Compiler = ${COMPILER_ID} (${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION})")
# -------------------------------------------------------
# Global compiler settings
# MSVC is TRUE for both cl.exe and clang-cl (Clang targeting the MSVC ABI),
# so this block covers both Windows compiler variants.
#
# /MP -- parallel file compilation (Windows only; guarded
# because Ninja drives parallelism on Linux and
# clang-cl warns if passed /MP there)
# /EHsc -- standard C++ exception handling
# /Zc:preprocessor -- conformant preprocessor (fixes __VA_ARGS__ etc.)
# /utf-8 -- UTF-8 source + execution charset
# -------------------------------------------------------
if(MSVC)
if(WIN32)
add_compile_options(/MP)
endif()
add_compile_options(/EHsc /Zc:preprocessor /utf-8)
add_compile_definitions(_CRT_SECURE_NO_WARNINGS)
endif()
# -------------------------------------------------------
# Build machinery (macros/functions) -- see cmake/BuildHelpers.cmake.
# This file should change rarely; it holds nothing project-specific.
# -------------------------------------------------------
include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/BuildHelpers.cmake")
# -------------------------------------------------------
# Third-party dependencies -- see cmake/Dependencies.cmake for
# add_vendor_header_only / add_vendor_subdirectory / add_dependency.
# A module declares its own dependencies in its own CMakeLists.txt (see
# source/Core/CMakeLists.txt for an example); nothing to add here.
# -------------------------------------------------------
include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/Dependencies.cmake")
# -------------------------------------------------------
# Documentation -- see cmake/Documentation.cmake. Every module
# automatically gets its own Doxygen target the moment Doxygen is
# installed; nothing to add here or in a module's own CMakeLists.txt
# unless you want to opt a module out (NO_DOCS) or override a Doxyfile
# setting (-DDOXYGEN_<TAG>=...).
# -------------------------------------------------------
include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/Documentation.cmake")
# -------------------------------------------------------
# Module discovery
#
# Every module lives in its own directory with its own CMakeLists.txt --
# usually a single add_lib_module/add_exe_module/add_test_module/
# add_example_module(...) call (see cmake/BuildHelpers.cmake for what each
# accepts). This file never needs editing to add, remove, or reconfigure
# a module: just add or remove the module's directory + CMakeLists.txt.
#
# source/<Name>/CMakeLists.txt -- library or app/executable module
# source/tests/<Name>/CMakeLists.txt -- test module
# source/examples/<Name>/CMakeLists.txt -- example module
#
# (source/tests and source/examples aren't picked up by the first call
# below -- they have no CMakeLists.txt directly inside them, only their
# own children do, so they're only ever visited by their dedicated calls.)
#
# _discover_module_subdirectories() add_subdirectory()s every matching
# directory automatically; CONFIGURE_DEPENDS means the next build
# reconfigures on its own once a module directory is added or removed.
# -------------------------------------------------------
_discover_module_subdirectories("${CMAKE_SOURCE_DIR}/source")
_discover_module_subdirectories("${CMAKE_SOURCE_DIR}/source/tests")
_discover_module_subdirectories("${CMAKE_SOURCE_DIR}/source/examples")
# Emits <build-dir>/module_manifest-<CONFIG>.txt ("target_name=path_to_binary"
# per line) for every executable module discovered above. scripts/run.sh / run.bat
# read this to resolve a CMake target name to its binary -- must run after
# discovery.
_write_module_manifest("${CMAKE_BINARY_DIR}")
# Writes out/docs/index.html (DOCS_OUTPUT_DIR, see Configuration.cmake)
# linking to every module's generated documentation (a no-op if
# ENABLE_DOXYGEN is off) -- must run after discovery, same reasoning as
# the manifest above.
_write_docs_index("${DOCS_OUTPUT_DIR}")
# -------------------------------------------------------
# Copy compile_commands.json to the project root once it's fully
# populated, so clangd/clang-tidy/IWYU find it without extra config (most
# editors look for it at the repo root or search upward from the source
# file; CMake only ever writes it into the build directory). Runs at
# build time, by which point CMake's own generate step has already
# written build/compile_commands.json, so this works even on the very
# first build with no prior build directory. Vendored and fetched
# dependencies are excluded from it (see cmake/Dependencies.cmake) so it
# only reflects code this project owns.
# -------------------------------------------------------
if(CMAKE_EXPORT_COMPILE_COMMANDS)
add_custom_target(copy_compile_commands ALL
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_BINARY_DIR}/compile_commands.json"
"${CMAKE_SOURCE_DIR}/compile_commands.json"
COMMENT "Copying compile_commands.json to project root"
VERBATIM
)
endif()
# -------------------------------------------------------
# install
# -------------------------------------------------------
set(_pmn_install_target_dir ${INSTALL_OUTPUT_DIR}/${DIST_TARGET})
# primary
set(RUNTIME_OUT_DIR ${_pmn_install_target_dir}/${RUNTIME_OUTPUT_SUBDIR})
set(LIBRARY_OUT_DIR ${_pmn_install_target_dir}/${LIBRARY_OUTPUT_SUBDIR})
set(ARCHIVE_OUT_DIR ${_pmn_install_target_dir}/${ARCHIVE_OUTPUT_SUBDIR})
# tests -- runtime/library -> bin/tests/ archives -> lib/tests/
set(TEST_RUNTIME_OUT_DIR ${_pmn_install_target_dir}/${RUNTIME_OUTPUT_SUBDIR}/tests)
set(TEST_LIBRARY_OUT_DIR ${_pmn_install_target_dir}/${LIBRARY_OUTPUT_SUBDIR}/tests)
set(TEST_ARCHIVE_OUT_DIR ${_pmn_install_target_dir}/${ARCHIVE_OUTPUT_SUBDIR}/tests)
# examples -- runtime/library -> bin/examples/ archives -> lib/examples/
set(EXAMPLE_RUNTIME_OUT_DIR ${_pmn_install_target_dir}/${RUNTIME_OUTPUT_SUBDIR}/examples)
set(EXAMPLE_LIBRARY_OUT_DIR ${_pmn_install_target_dir}/${LIBRARY_OUTPUT_SUBDIR}/examples)
set(EXAMPLE_ARCHIVE_OUT_DIR ${_pmn_install_target_dir}/${ARCHIVE_OUTPUT_SUBDIR}/examples)
# Every install(TARGETS ...) below is tagged COMPONENT ${PROJECT_CODE_NAME}.
# A vendored/fetched dependency's own CMakeLists.txt (see
# cmake/Dependencies.cmake) usually has install() rules of its own, with
# no COMPONENT tag -- installing with `cmake --install build` (no
# --component filter) would pull those in too, typically under
# CMAKE_INSTALL_PREFIX (e.g. /usr/local). scripts/install.sh / install.bat always
# pass --component ${PROJECT_CODE_NAME} so only this project's own
# artifacts are installed; use the same flag if you invoke cmake --install
# directly.
message(STATUS "Will install primary modules.")
get_property(_pmn_primary_targets GLOBAL PROPERTY PMN_MODULE_TARGETS_PRIMARY)
if(_pmn_primary_targets)
install(TARGETS ${_pmn_primary_targets}
COMPONENT ${PROJECT_CODE_NAME}
ARCHIVE DESTINATION ${ARCHIVE_OUT_DIR}
LIBRARY DESTINATION ${LIBRARY_OUT_DIR}
RUNTIME DESTINATION ${RUNTIME_OUT_DIR}
)
endif()
# Both blocks below read whatever test/example modules discovery actually
# found -- no target names to keep in sync here when a module is added,
# removed, or renamed.
if(INSTALL_TESTS)
get_property(_pmn_test_targets GLOBAL PROPERTY PMN_MODULE_TARGETS_TEST)
if(_pmn_test_targets)
message(STATUS "Will install test modules: ${_pmn_test_targets}")
install(TARGETS ${_pmn_test_targets}
COMPONENT ${PROJECT_CODE_NAME}
ARCHIVE DESTINATION ${TEST_ARCHIVE_OUT_DIR}
LIBRARY DESTINATION ${TEST_LIBRARY_OUT_DIR}
RUNTIME DESTINATION ${TEST_RUNTIME_OUT_DIR}
)
else()
message(WARNING "INSTALL_TESTS is ON but no test modules were found under source/tests/.")
endif()
endif()
if(INSTALL_EXAMPLES)
get_property(_pmn_example_targets GLOBAL PROPERTY PMN_MODULE_TARGETS_EXAMPLE)
if(_pmn_example_targets)
message(STATUS "Will install example modules: ${_pmn_example_targets}")
install(TARGETS ${_pmn_example_targets}
COMPONENT ${PROJECT_CODE_NAME}
ARCHIVE DESTINATION ${EXAMPLE_ARCHIVE_OUT_DIR}
LIBRARY DESTINATION ${EXAMPLE_LIBRARY_OUT_DIR}
RUNTIME DESTINATION ${EXAMPLE_RUNTIME_OUT_DIR}
)
else()
message(WARNING "INSTALL_EXAMPLES is ON but no example modules were found under source/examples/.")
endif()
endif()
+72
View File
@@ -0,0 +1,72 @@
# =============================================================================
# Configuration.cmake
#
# All output-path configuration lives here. Change a value here, or
# override with -D<NAME>=... at configure time, instead of hunting through
# CMakeLists.txt/cmake/*.cmake for a hardcoded path.
#
# Everything this project generates lives under one OUT_DIR:
#
# out/
# build/ CMAKE_BINARY_DIR -- scripts/build.sh
# defaults to configuring here (-B
# out/build); compiled artifacts land
# in out/build/<RUNTIME_OUTPUT_SUBDIR>
# and out/build/<ARCHIVE_OUTPUT_SUBDIR>
# during normal day-to-day builds.
# install/<DIST_TARGET>/{bin,lib} what `cmake --install` / scripts/
# scripts/install.sh populates.
# dist/ reserved for packaged/archived
# output (e.g. a future CPack
# integration) -- not populated by
# this project yet, but the path is
# configured and ready.
# docs/<module>/html, docs/index.html generated Doxygen output
# (cmake/Documentation.cmake).
#
# One thing lives outside OUT_DIR on purpose: .cache/tools/ (TOOLCACHE_DIR)
# caches downloaded, pinned build tools (currently a pinned Doxygen), kept
# separate so a clean `rm -rf out/` doesn't force a re-download.
#
# Included by CMakeLists.txt before any output-directory variable is used,
# so every value here is available project-wide.
# =============================================================================
include_guard(GLOBAL)
set(OUT_DIR "${CMAKE_SOURCE_DIR}/out" CACHE PATH
"Root directory for everything this project generates: build artifacts, install trees, docs, and reserved dist packaging output")
set(DIST_OUTPUT_DIR "${OUT_DIR}/dist" CACHE PATH
"Reserved for packaged/archived distribution output (e.g. a future CPack integration). Not populated by this project yet.")
set(INSTALL_OUTPUT_DIR "${OUT_DIR}/install" CACHE PATH
"Root of the cmake --install destination tree, one subdirectory per DIST_TARGET")
set(DOCS_OUTPUT_DIR "${OUT_DIR}/docs" CACHE PATH
"Root of generated Doxygen documentation, one subdirectory per module")
# Deliberately NOT under OUT_DIR: this caches downloaded, pinned build
# tools (currently just Doxygen -- see cmake/Documentation.cmake), which
# are machine-local and expensive to re-fetch, not build output. Keeping
# it separate means a clean `rm -rf out/` (a normal "start fresh" gesture)
# doesn't force a re-download.
set(TOOLCACHE_DIR "${CMAKE_SOURCE_DIR}/.cache/tools" CACHE PATH
"Where pinned build tools (currently just a downloaded Doxygen) are cached, kept separate from OUT_DIR")
# out/dist/ is reserved (see the header comment above) and nothing writes
# to it yet, so it's created eagerly here -- otherwise it simply wouldn't
# exist until something populates it, which could easily read as "this
# was never wired up" rather than "intentionally reserved for later".
file(MAKE_DIRECTORY "${DIST_OUTPUT_DIR}")
# Subdirectory NAMES only (not full paths) for where compiled artifacts
# land inside whatever the current build directory is (CMAKE_BINARY_DIR) --
# these apply correctly no matter where -B points, including if you build
# outside out/build entirely. LIBRARY defaults to the same subdirectory as
# RUNTIME because shared libraries need to sit next to the executables
# that load them via the project's $ORIGIN-relative RPATH strategy (see
# CMakeLists.txt).
set(RUNTIME_OUTPUT_SUBDIR "bin" CACHE STRING "Build-dir subdirectory for runtime artifacts (executables, .dll, .so)")
set(LIBRARY_OUTPUT_SUBDIR "bin" CACHE STRING "Build-dir subdirectory for shared-library artifacts")
set(ARCHIVE_OUTPUT_SUBDIR "lib" CACHE STRING "Build-dir subdirectory for static/import-library artifacts (.a, .lib)")
+380 -266
View File
@@ -1,272 +1,386 @@
# CLIForge
# CMake module-system demo
A header-only C++20 engine for building CLIs whose commands are ordinary
free functions. Parameters and options are explicitly typed where
they're declared; `.action()` validates (never invents) that the bound
function's arguments are consistent with what was declared, and accepts
them by value, by reference, or by const reference.
```cpp
#include <cliforge/cliforge.hpp>
using cliforge::Engine;
void greet(const std::string& name, bool loud) {
std::cout << (loud ? "HELLO, " : "Hello, ") << name << (loud ? "!!" : ".") << "\n";
}
int main(int argc, char** argv) {
cliforge::Engine cli("greeter");
cli.command()
.keyword("greet")
.parameter<std::string>("name", "Who to greet")
.flag("loud", 'l', "SHOUT the greeting")
.describe("Greets someone by name")
.action(&greet);
return cli.run(argc, argv);
}
```
```
$ greeter greet Ada --loud
HELLO, Ada!!
```
## Try it
```sh
g++ -std=c++20 -Iinclude -O2 -o packctl examples/main.cpp
./packctl # global help
./packctl project myapp create --template cpp --git
./packctl build 42 2 1 -a arm64 x64 -e vim # variadic params + variadic option
./packctl build 7 -ndi -e nano # combined short flags
./packctl deploy web1 --env prod web2 -n web3 # loose parameters, order-agnostic
./packctl project new -h # ambiguous prefix -> shows all 3 matches
./packctl projct myapp delete # typo -> "did you mean"
```
Or with CMake: `cmake -B build && cmake --build build`, which produces
`packctl` (the demo) plus `type_test` / `registration_errors_test`.
## The model
A command has exactly two parts, matching the spec:
```
packctl deploy web1 --env prod web2 -n web3
\____/ \___________ unstructured ___________/
structured flags + options + loose parameters, any order
```
* **Structured part** -- an ordered, *fixed-position* sequence of literal
**keywords** (`project`, `delete`) and typed positional **parameters**
(`<name>`). The user's tokens must line up with this sequence exactly,
in order. Only the *last* parameter here may be variadic. Keywords
carry no description of their own -- `.describe()` on the command is
what shows up in help.
* **Unstructured part** -- **flags** (boolean switches), **options**
(named, typed values), and additional **loose parameters**, which the
user may give in any order relative to each other:
* Flags/options are found by name (`--foo`/`-f`) wherever they appear.
Short flags combine (`-ndi` == `-n -d -i`); options can't be combined
since they need a value.
* Whatever bare (non-dash) tokens are left over are the loose
parameters, matched *positionally among themselves, in declaration
order* -- regardless of where they fall relative to the flags/
options. `deploy web1 --env prod web2 -n web3` and
`deploy -n web1 web2 --env prod web3` both give `hosts = [web1, web2,
web3]`.
A command must declare at least one keyword. `.keyword()` can only be
called before any `.flag()`/`.option()`. `.parameter<T>()` can be called
on *either side* of that line: before the first `.flag()`/`.option()`
it's a structured, fixed-position parameter; after, it's a loose one.
Either way, only the last parameter declared in its half may be variadic.
**Parameters are always required** (unless they're the trailing variadic
one, which accepts zero or more). **Options are always optional** --
`.option<T>()` must be bound to `std::optional<T>` or (if variadic)
`std::vector<T>` in `action()`; binding it to a bare, required `T` is a
`RegistrationError`. This mirrors ordinary CLI convention: positional
arguments are the required ones, named flags/options never are.
## Explicit types, validated (not invented) at action()
Every `.parameter<T>()` / `.option<T>()` call requires `T` up front --
types are never inferred from the bound function. `.action(&fn)` deduces
`fn`'s argument types via `function_traits` and *checks* each one against
what was already declared, matched in order:
```
[ parameters, in the order you called .parameter<T>() before any flag/option ]
++
[ flags/options/loose-parameters, in the order you called them after ]
```
| Declared as | `action()`'s argument must be |
|---|---|
| `flag(...)` | `bool` |
| `parameter<T>(...)` | `T` -- by value, `const T&`, or `T&` |
| `parameter<T>(..., variadic=true)` | `std::vector<T>` |
| `option<T>(...)` | `std::optional<T>` (never a bare `T` -- options are always optional) |
| `option<T>(..., variadic=true)` | `std::vector<T>` |
| an `enum class` (either kind) | needs `.choices<T>({{"name", T::Value}, ...})` called immediately after; `T` is deduced from the initializer list |
A mismatch anywhere in this table (wrong arity, a flag bound to
non-`bool`, an option bound to a bare/required type, a declared type
that doesn't match the corresponding argument type, a variadic slot
bound to a non-vector, ...) is a `RegistrationError` at startup -- see
`tests/registration_errors_test.cpp`. An unsupported scalar type (say,
`std::map<int,int>`) fails immediately at the `parameter<T>()`/
`option<T>()` call site via `static_assert`, before `action()` is even
reached.
Arguments can be taken **by value, by reference, or by const
reference** -- whichever avoids an unnecessary copy for the type in
question (`const std::string&`, `const std::vector<T>&`, etc. all work).
## Types
Fixed-width signed/unsigned integers (`int8_t` ... `uint64_t`), `float`,
`double`, `std::string`, `char` (exactly one byte), `bool`
(`true/false/1/0/yes/no/on/off`, case-insensitive), and custom
`enum class` types via `choices<T>()`. Parsing uses `std::from_chars`
for numbers, so `"5abc"` or `"3.14"` handed to an `int32_t` slot is
rejected rather than silently truncated.
## Errors are categorized, not just "something went wrong"
Every runtime parse failure is a `CliError` carrying both a specific,
actionable message and a programmatic `ErrorKind` (`.kind()`):
| Situation | `ErrorKind` | Example message |
|---|---|---|
| A `--flag`/`-x` isn't recognized | `UnknownOption` | `unknown flag/option '--arch' for this command -- did you mean '--architectures'?` |
| More positional values than declared slots can absorb | `TooManyArguments` | `too many arguments: unexpected 'extra' (and 2 more)` |
| A required parameter (or loose parameter) was never given | `MissingArgument` | `missing required argument '<name>': expected a value of type string` |
| An option was named but its value token is missing | `MissingValue` | `option '--editor' requires a value` |
| A token doesn't fit its declared type | `TypeMismatch` | `type mismatch for 'threshold': got 'abc', expected int32` |
## `--help` resolves ambiguity instead of hiding it
`--help`/`-h` (anywhere in the command line) and the `help <words...>`
top-level command both search for *every* command consistent with what
was typed so far -- including when a positional value has already been
filled in. `packctl project new -h` matches `project <name> create`,
`project <name> delete`, and `project <name>` (info) all at once, since
`new` is a perfectly plausible `<name>` for any of them; it prints all
three, clearly separated, rather than silently picking one. A
fully-specified prefix (`project myapp delete --help`) narrows to the
one actual match, exactly as before.
The same treatment applies to an *incomplete* command that wasn't
explicitly asking for help: `packctl project` alone is missing
`<name>`, and that's equally true of all three `project` commands (they
report the identical error). Rather than arbitrarily surfacing whichever
one happened to be registered first, every command tied for "most
specific incomplete match" is listed:
```
$ packctl project
Error: missing required argument '<name>': expected a value of type string
'project' matches 3 commands:
packctl project <name> create [OPTIONS] Scaffold a new project from a template
packctl project <name> delete [OPTIONS] Permanently delete a project by name
packctl project <name> [OPTIONS] Show information about a project
```
A single most-specific match (e.g. `packctl project x create --template`,
missing only `--template`'s value) still reports just the one relevant
`Usage:` line, unchanged.
## Keeping allocations down
* `TypeInfo`'s parser is a **plain stateless function pointer**, not
`std::function` -- built-in scalar types (the overwhelming majority of
slots) cost nothing to store or copy. Only `choices<T>()`-restricted
slots pay for an allocation, once, at registration (a `shared_ptr` to
a small name table), not per parse.
* **Short flag/option names are `char`**, not `std::string` -- no
allocation to declare one, and combined short flags (`-ndi`) are
matched by direct `char` comparison instead of constructing a
temporary single-character string per letter.
* The unstructured parser (flags/options/loose-parameters) works in
terms of `std::string_view` internally -- splitting `--name=value` or
stripping the leading dashes off a token no longer copies; the token's
bytes are only ever copied once, into the final `Scalar`, when a value
actually needs to be owned.
* `choices<T>()` no longer goes through `std::any` -- the declared C++
type is tracked with a `std::type_index` (a couple of pointer-sized
comparisons, no allocation) and validated directly against `action()`'s
argument types.
Parsing a single command line still does a handful of small, one-shot
`std::vector` allocations (there's no way around owning the parsed
values), so if you're embedding cliforge in a long-running REPL parsing
many commands per process, reusing buffers across calls would be the
next lever -- not implemented here, since it's not needed for the
"parse argv once and dispatch" shape most CLIs have.
## Design decisions worth knowing about
These are the places where the spec was open to interpretation; here's
what this implementation does and why.
* **Keywords never start with `-`.** The spec allows keywords to start
with digits, but a leading dash would be indistinguishable from a
flag. Internal dashes/dots/underscores are fine (`self-update`,
`2fa`, `v2.load` are all valid keywords).
* **Negative numbers vs. flags.** A token is only treated as "flag/
option syntax" if it starts with `-` and the next character *isn't a
digit* -- so `-5`/`-3` parse as negative numeric values (structured
variadic parameters *or* loose parameters), while `-n` still means the
flag `n`. Applied consistently in both the structured matcher and the
unstructured parser.
* **Disambiguating overlapping commands.** `project <name>` and
`project <name> delete` can both structurally match
`project myapp delete`. Candidates are ranked by how many *literal*
keywords matched (more specific wins), with total structured-slot
count as a tiebreaker, and are tried in that order -- so `delete`
correctly wins over swallowing `"delete"` into `<name>`.
* **Error attribution when several candidates fail.** If the
best-ranked candidate's *value* fails to parse or is missing, that's
almost certainly the user's intended command, so its error is what
gets reported -- not a generic "no command matches", and not a
less-relevant error from a lower-ranked candidate that also happened
to fail.
* **"Did you mean?" is structural, not string-blob.** Comparing the raw
input against a usage string like `project <name> delete` would
score badly once you substitute a real value for `<name>`. Instead,
suggestions walk the same keyword/parameter structure as real
matching, treating parameter slots as wildcards and only scoring
edit-distance on the literal keyword text -- normalized to a ratio so
a variadic parameter silently absorbing unrelated tokens can't make
garbage input look like a close match. Flag/option suggestions
additionally treat a prefix relationship (`--arch` for
`--architectures`) as a strong match even though its raw edit
distance is large.
* **`--name=value` is supported** alongside the spec's space-separated
form, as a low-cost, expected CLI convention.
* **Repeated non-variadic options: last write wins** (`--reason a
--reason b` yields `"b"`). Variadic options/loose-parameters
accumulate instead.
* **Not implemented:** a `--` "end of options" separator. Given the
negative-number heuristic already resolves the main practical
ambiguity, this was left as a documented extension point rather than
added speculatively.
A minimal C++20 project showing a per-module CMake setup: each module owns
a tiny, self-contained `CMakeLists.txt`, the root `CMakeLists.txt`
discovers modules automatically, three ways to bring in third-party code
(vendored, installed, or fetched) stay out of your warnings and your
compile database, every module gets its own browsable documentation
automatically the moment Doxygen is installed, and every output path is
configured in one place.
## Layout
```
include/cliforge/
value.hpp Scalar/Value/TypeInfo/ErrorKind -- the type & error system
slot.hpp Keyword/Parameter/Flag/Option descriptor
function_traits.hpp Return/argument-type deduction for action()
levenshtein.hpp Edit distance + prefix-aware closest-match helper
command.hpp Builder, matcher, unstructured parser, help text
engine.hpp Registration, ranking/dispatch, help resolution
cliforge.hpp Aggregator -- the only header you include
examples/main.cpp "packctl", a demo touching every feature
tests/
type_test.cpp every scalar type, valid + invalid input
registration_errors_test.cpp every builder-misuse guard (+ positive cases)
CMakeLists.txt project setup, options, install(), discovery -- edit by hand
Configuration.cmake all output-path configuration -- edit to relocate anything
cmake/BuildHelpers.cmake module macros + discovery mechanism -- edit rarely
cmake/Dependencies.cmake vendoring / installed-library / FetchContent helpers -- edit rarely
cmake/Documentation.cmake automatic per-module Doxygen integration -- edit rarely
source/Core/CMakeLists.txt library module "core_lib" (SHARED), depends on fmt
source/HelloApp/CMakeLists.txt executable module "hello_exe", depends on vendored tiny_ansi
source/FarewellApp/CMakeLists.txt executable module "farewell_exe", depends on vendored tiny_case
vendor/tiny_ansi/ header-only vendored dependency (no build of its own)
vendor/tiny_case/ vendored dependency with its own CMakeLists.txt
scripts/build.sh, build.bat configure + build
scripts/run.sh, run.bat run a module by its CMake target name
scripts/install.sh, install.bat install this project's own artifacts only
scripts/docs.sh, docs.bat build documentation for every module (or one)
.clang-format, .clang-tidy C++ style guide tooling (see "Code style & tooling" below)
.editorconfig, .gitattributes -- same
c-api/.clang-tidy naming override template for a C API subtree, if you add one
.githooks/pre-commit formatting/lint pre-commit hook
cpp-style-guide.md, TOOLING.md the style guide itself + tooling docs
```
Everything generated -- the build tree, install output, docs, and reserved
packaging output -- lives under one `out/` directory (see "Output paths"
below); nothing under `out/` is source-controlled.
Each module's `CMakeLists.txt` is (conventionally) a couple of lines:
```cmake
# source/Core/CMakeLists.txt
add_dependency(fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 11.0.2
FIND_PACKAGE_ARGS NAMES fmt
)
add_lib_module(core_lib TYPE SHARED OUTPUT_NAME "demo_core" DEPENDS fmt::fmt)
```
A module declares its own dependencies in its own `CMakeLists.txt` -- same
"minimal tool intervention" principle as module registration: nothing to
add in the root file.
## Output paths (`Configuration.cmake`)
Every generated path is a cache variable, overridable with `-D<NAME>=...`
at configure time instead of editing any file:
| Variable | Default | What it controls |
|---|---|---|
| `OUT_DIR` | `out/` | Root of everything generated; override this to relocate all of the below at once |
| `INSTALL_OUTPUT_DIR` | `out/install` | Root of the `cmake --install` tree, one subdirectory per `DIST_TARGET` |
| `DIST_OUTPUT_DIR` | `out/dist` | Reserved for packaged/archived output (e.g. a future CPack integration) -- not populated by this project yet, but created and ready |
| `DOCS_OUTPUT_DIR` | `out/docs` | Root of generated Doxygen documentation, one subdirectory per module |
| `TOOLCACHE_DIR` | `.cache/tools` | Where downloaded, pinned build tools (currently just Doxygen) are cached -- kept outside `OUT_DIR` so `rm -rf out/` doesn't force a re-download |
| `RUNTIME_OUTPUT_SUBDIR` | `bin` | Subdirectory (inside whatever the build dir is) for executables/`.dll`/`.so` |
| `LIBRARY_OUTPUT_SUBDIR` | `bin` | Subdirectory for shared-library artifacts (kept alongside executables so `$ORIGIN` RPATH resolves them) |
| `ARCHIVE_OUTPUT_SUBDIR` | `lib` | Subdirectory for static/import-library artifacts |
The build directory itself (`CMAKE_BINARY_DIR`, where `CMakeCache.txt`
lives) isn't a `Configuration.cmake` variable -- it's fixed by whatever you
pass to `-B` when configuring. `scripts/build.sh`/`build.bat` default to
`out/build`, so the resulting layout is:
```
out/
build/ CMAKE_BINARY_DIR -- out/build/bin, out/build/lib during normal builds
install/<DIST_TARGET>/{bin,lib} what `cmake --install` / scripts/install.sh populates
dist/ reserved for future packaging output
docs/<module>/html/, docs/index.html generated documentation
```
Example -- build into a different location entirely:
```bash
cmake -G Ninja -S . -B /tmp/my-build -DOUT_DIR=/tmp/my-out
cmake --build /tmp/my-build
```
## Three ways to bring in a dependency (`cmake/Dependencies.cmake`)
| Helper | For | Example |
|---|---|---|
| `add_vendor_header_only(NAME [INCLUDE_DIR <dir>])` | A buildless header-only drop under `vendor/<NAME>/include` | `vendor/tiny_ansi/` |
| `add_vendor_subdirectory(SUBDIR)` | A vendored dependency with its own `CMakeLists.txt`, under `vendor/<SUBDIR>` | `vendor/tiny_case/` |
| `add_dependency(NAME <FetchContent_Declare args>)` | An installed package (`FIND_PACKAGE_ARGS ...`) with a fetch-from-source fallback | `fmt` in `source/Core/` |
All three are safe to call more than once for the same name (e.g. two
modules that need the same dependency) and mark the dependency's headers
`SYSTEM`, so `-Wall -Wextra -Wpedantic`/`/W4` -- which we only ever apply
per-target inside our own module macros, never globally -- doesn't fire on
code you don't control. Their targets are also dropped from
`compile_commands.json` (see below).
`add_dependency` is CMake's native find-then-fetch integration: it tries
`find_package()` first (an already-installed apt/vcpkg/conan copy) via
`FIND_PACKAGE_ARGS`, and only downloads + builds from source if nothing is
found. Omit `FIND_PACKAGE_ARGS` to always fetch. Requires network access
the first time a dependency isn't already installed.
A module `DEPENDS` on whatever target the chosen path defines (`fmt::fmt`,
`tiny_case`, `tiny_ansi`, ...) exactly like depending on another module --
`add_lib_module`/`add_exe_module` don't need to know or care which of the
three supplied it.
## Documentation (automatic, per module, pinned Doxygen)
Every module registered via `add_lib_module`/`add_exe_module`/
`add_test_module`/`add_example_module` gets its own
[Doxygen](https://www.doxygen.nl/) documentation target automatically --
nothing to add to a module's own `CMakeLists.txt`, and no Doxygen install
required on your machine. This project downloads and manages its own
pinned Doxygen (currently **1.16.1**, see `DOXYGEN_PINNED_VERSION` in
`cmake/Documentation.cmake`) the first time you configure with
`ENABLE_DOXYGEN` on (the default) -- it deliberately never looks for or
uses a system-installed `doxygen`, so every machine building this project
generates identical output regardless of what (if anything) is on `PATH`.
The download is cached under `.cache/tools/` (`TOOLCACHE_DIR`, see
"Output paths" above), so it only happens once per machine -- a clean
`rm -rf out/` does not force a re-download.
```bash
scripts/docs.sh # build docs for every module, prints where to open it
scripts/docs.sh core_lib # build docs for just one module (docs_<target>)
```
```bat
scripts\docs.bat
scripts\docs.bat core_lib
```
Output goes to `out/docs/<module>/html/index.html` (a sibling of
`out/build/`, not nested inside it -- see "Output paths" above), plus a
landing page at `out/docs/index.html` linking to every module.
Documentation is **not** built as part of a normal `scripts/build.sh` --
it's opt-in, the same way tests and examples are, since generating it on
every compile would be wasteful. If the pinned Doxygen can't be downloaded
(no network access, an unsupported host platform, or you passed
`-DENABLE_DOXYGEN=OFF`), `scripts/docs.sh` prints a clear message instead
of failing -- everything else keeps building normally either way:
```
Documentation is disabled (ENABLE_DOXYGEN=OFF, or Doxygen isn't installed). Install Doxygen and reconfigure to enable it.
```
**Writing docs for a module** is just normal Doxygen comments in that
module's headers -- e.g. `source/Core/include/core/Greeting.hpp`:
```cpp
/// Builds a greeting for @p name.
///
/// @param name the person (or thing) being greeted.
/// @return a formatted greeting string, e.g. "Hello, Ale! (from demo_core)".
std::string makeGreeting(const std::string& name);
```
Even with zero comments, a module's docs are still generated and useful --
file lists, function/class signatures, and (if [Graphviz](https://graphviz.org/)
is installed on your system -- this one enhancement, unlike Doxygen
itself, is still detected rather than pinned, since it's optional) call
graphs are produced regardless, since `DOXYGEN_EXTRACT_ALL` is on by
default. Comments just make it richer. Drop a `README.md` in a module's
own directory and it's automatically used as that module's Doxygen main
page.
**Configuring it** -- nothing is required, but everything is overridable:
```bash
# Disable entirely, even though Doxygen would otherwise download fine:
cmake -S . -B out/build -DENABLE_DOXYGEN=OFF
# Pin a different Doxygen version (also update the two SHA256 hashes in
# cmake/Documentation.cmake to match that release's assets -- a stale
# hash fails the configure loudly, on purpose, rather than silently
# accepting an unexpected file):
cmake -S . -B out/build -DDOXYGEN_PINNED_VERSION=1.17.0
# Any Doxyfile tag can be set as a CMake variable, e.g. stricter output:
cmake -S . -B out/build -DDOXYGEN_WARN_IF_UNDOCUMENTED=YES -DDOXYGEN_QUIET=NO
```
A single module can opt out with `NO_DOCS`:
```cmake
# some/internal/module/CMakeLists.txt
add_lib_module(internal_only TYPE STATIC NO_DOCS)
```
Vendored and fetched dependencies (`cmake/Dependencies.cmake`) are never
auto-documented -- same reasoning as their `compile_commands.json`
exclusion, it isn't your code.
## compile_commands.json / clang tooling
`CMAKE_EXPORT_COMPILE_COMMANDS` is on by default, and every build copies
`compile_commands.json` from the build directory to the project root so
clangd/clang-tidy/IWYU find it without extra configuration (CMake only
ever writes it into the build directory; most editors look for it at the
repo root). Vendored and fetched dependencies are excluded from it, so it
only reflects code this project actually owns. The project also builds
cleanly with clang directly:
```bash
cmake -G Ninja -S . -B out/build -DCMAKE_CXX_COMPILER=clang++ -DDIST_TARGET=linux-x64-clang
cmake --build out/build
```
## Code style & tooling
This project follows [`cpp-style-guide.md`](cpp-style-guide.md) (tabs,
Allman braces, `PascalCase` file names, `camelCase` functions/variables,
`m_`/`s_`/`g_` member/static/global prefixes -- see the file itself for
the full rules and rationale), enforced by the tooling described in
[`TOOLING.md`](TOOLING.md):
| File | Enforces |
|---|---|
| `.clang-format` | All formatting: tabs, Allman braces, pointer alignment, include order, line length |
| `.clang-tidy` | Naming conventions and a subset of language-feature rules, for this project's own C++ code |
| `c-api/.clang-tidy` | A template naming override (`snake_case`) for a C API subtree, if/when this project has one -- see below |
| `.editorconfig` | Baseline tabs/whitespace for editors that don't run clang-format live |
| `.gitattributes` | Forces LF line endings for source files on every OS |
| `.githooks/pre-commit` | Runs clang-format + clang-tidy on staged files before each commit; warns and skips (never blocks) if a tool isn't installed |
**One-time setup per clone** to activate the hook:
```bash
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
```
**clang-tidy needs `compile_commands.json`** to resolve `#include`s --
this project already generates and copies it to the repo root on every
build (see above), so as long as you've built at least once, the hook's
clang-tidy check works with no extra setup.
Two adjustments made when integrating the toolkit into this specific
project (not part of the toolkit as provided -- see `TOOLING.md` in this
repo for the toolkit's own documentation of everything else):
- **`vendor/` is excluded** from the pre-commit hook's staged-file check
(`git diff ... ':!vendor/*'`). Vendored code (`cmake/Dependencies.cmake`)
isn't this project's code, so it isn't held to this style guide -- same
reasoning as its `compile_commands.json` and warning-flag exclusions
elsewhere in this project.
- **`.clang-tidy` gained one option**, `LocalConstantCase: camelBack`. The
style guide's own text says only *global/static* `const`/`constexpr`
values are "constants" for naming purposes -- everything else stays
under the `camelCase` Local Variables rule. Without this option, a plain
local `const` (e.g. `const std::string name = ...;` inside a function)
silently fell through to the general `PascalCase` constant rule instead,
which would have flagged completely ordinary code. Verified against a
small isolated test file: with the fix, a local `const` is accepted in
`camelCase` while a `static const`/`constexpr` still correctly requires
`PascalCase`.
`c-api/.clang-tidy` is included as a template, positioned exactly as the
toolkit ships it (`c-api/.clang-tidy`) -- this project doesn't currently
have a C API (Part II) surface. If you add one, move this file to that
subtree's root (`c-api/`, `public-include/`, wherever your `extern "C"`
headers live); `clang-tidy` always uses the closest `.clang-tidy` file up
the directory tree, so files under that directory automatically pick up
the C API's `snake_case` naming instead of this project's `camelCase`.
This project's own code was reformatted and renamed to comply when the
toolkit was integrated -- e.g. `Greeting.hpp`/`.cpp` (was `greeting.h`/
`.cpp`) and `makeGreeting`/`makeFarewell` (was `make_greeting`/
`make_farewell`) in `source/Core/`. `main.cpp` in each app module became
`Main.cpp` for the same reason (`PascalCase` file names) -- the style
guide doesn't call out an entry-point exception, so none was assumed;
rename back to lowercase if you'd rather treat that specific convention
as an exception.
## How module discovery works
The root `CMakeLists.txt` calls `_discover_module_subdirectories()` on
three roots, in this order:
| Root | What it holds |
|---|---|
| `source/*` | library AND app/executable modules, side by side |
| `source/tests/*` | test modules |
| `source/examples/*` | example modules |
(`source/tests` and `source/examples` aren't picked up by the first call
-- they have no `CMakeLists.txt` directly inside them, only their own
children do.)
Any immediate child directory that contains its own `CMakeLists.txt` gets
`add_subdirectory()`'d automatically (`vendor/` is not one of these roots
-- vendored dependencies are opted into explicitly by the modules that
need them, not auto-built by default). This uses `CONFIGURE_DEPENDS`, so
the *next build* reconfigures on its own after you add or remove a module
directory -- no manual `cmake` re-run needed. A module may `DEPENDS` on a
module discovered later in this list; order doesn't affect linking (CMake
resolves target names across the whole project at generate time --
verified, not assumed).
## Requirements
CMake >= 3.25, Ninja, a C++20 compiler (GCC/Clang/MSVC), `git` (for
FetchContent's `GIT_REPOSITORY`-based dependencies like `fmt`), and
network access the first time you configure (also needed for the pinned
Doxygen download -- both are cached afterward, see "Output paths" and
"Documentation" above). Nothing else to install: Doxygen is downloaded
and pinned automatically, not a system dependency.
[Graphviz](https://graphviz.org/) is optional for Doxygen call graphs, and
[clang-format](https://clang.llvm.org/docs/ClangFormat.html)/
[clang-tidy](https://clang.llvm.org/extra/clang-tidy/) are optional for
the pre-commit hook (see "Code style & tooling" above) -- both are
detected, not pinned, since they're editor/workflow tools rather than
part of the build itself.
## Build
```bash
scripts/build.sh # Release (default)
scripts/build.sh Debug # or Debug / RelWithDebInfo / MinSizeRel
```
```bat
scripts\build.bat
scripts\build.bat Debug
```
## Run
```bash
scripts/run.sh hello_exe
scripts/run.sh farewell_exe -- Ale
scripts/run.sh # no args: lists available targets
```
```bat
scripts\run.bat hello_exe
scripts\run.bat farewell_exe -- Ale
```
`run.sh`/`run.bat` resolve the CMake **target id** (not the binary's
`OUTPUT_NAME`) to the actual binary via a manifest CMake generates at
configure time, so these scripts never need editing when a module is
added, removed, or renamed.
## Install
```bash
scripts/install.sh
```
```bat
scripts\install.bat
```
Always use the install script (or pass `--component demo` yourself if
calling `cmake --install` directly). A vendored/fetched dependency's own
`CMakeLists.txt` usually has `install()` rules of its own with no
component tag; installing without `--component` would also run those,
typically dumping files under `CMAKE_INSTALL_PREFIX` (e.g. `/usr/local`).
Tagging this project's own `install(TARGETS ...)` calls with `COMPONENT
demo` and always filtering on it is what keeps `cmake --install` scoped to
just this project's own artifacts.
## Adding a module
1. Create a directory under the right root (see the discovery table above).
2. Add a `CMakeLists.txt` inside it with one `add_lib_module` /
`add_exe_module` / `add_test_module` / `add_example_module` call, plus
any `add_vendor_*`/`add_dependency` calls it needs.
3. Add your `.cpp`/`.h` files inside that same directory.
4. Rebuild (`scripts/build.sh`) -- no other file needs touching.
```cmake
# source/MyTool/CMakeLists.txt
add_exe_module(my_tool_exe OUTPUT_NAME "my-tool" DEPENDS core_lib)
```
`scripts/run.sh my_tool_exe` picks it up automatically once it's built,
and `scripts/docs.sh my_tool_exe` documents it automatically too -- both
with zero edits anywhere outside `source/MyTool/`.
+85
View File
@@ -0,0 +1,85 @@
# Tooling for the C/C++ Coding Style Guide
This repo ships five files that automate as much of [cpp-style-guide.md](cpp-style-guide.md) as tooling can reach:
| File | Enforces |
|---|---|
| [`.clang-format`](#clang-format) | All formatting: tabs, Allman braces, namespace indentation, pointer alignment, include order, line length |
| [`.clang-tidy`](#clang-tidy) | Naming conventions for Part I (C++), plus a subset of the Language Feature Do's and Don'ts |
| [`c-api/.clang-tidy`](#c-apiclang-tidy) | Naming conventions for Part II (C API) — `snake_case` instead of `camelCase`/`PascalCase` |
| [`.editorconfig`](#editorconfig) | Baseline tabs/whitespace for editors that don't run clang-format live |
| [`.gitattributes`](#gitattributes) | Forces LF line endings for source files on every OS, so diffs and formatting checks agree across Linux/Windows/Mac |
| [`.githooks/pre-commit`](#githookspre-commit) | Runs clang-format + clang-tidy automatically on every commit — pure bash, no Python or third-party framework, works on Linux/macOS/Windows |
Put all of these at your repo root except `c-api/.clang-tidy`, which goes at the root of whatever directory holds your Part II surface (rename the folder to match your actual layout — `c-api/`, `public-include/`, etc.).
---
## `.clang-format`
Covers [§2 Indentation](cpp-style-guide.md#2-indentation), [§3 Brace Placement](cpp-style-guide.md#3-brace-placement-allman-style), [§4 Pointers and References](cpp-style-guide.md#4-pointers-and-references), [§5 Spacing](cpp-style-guide.md#5-spacing), [§7 Include Directive Order](cpp-style-guide.md#7-include-directive-order), [§8 Line Length](cpp-style-guide.md#8-line-length), and [Namespaces](cpp-style-guide.md#namespaces) (indentation + no trailing brace comments), in full — running `clang-format -i` reformats a file to match all of these automatically.
One caveat: the include-order regexes classify a header by *how it's written*, not what it actually is — `<vector>` is correctly detected as C++ standard library, but a third-party header with no slash and no extension (rare, but it happens) could be miscategorized. Spot-check unusual includes rather than trusting the grouping blindly.
## `.clang-tidy`
Covers the naming table in [§1 Naming Conventions](cpp-style-guide.md#1-naming-conventions) for Part I via `readability-identifier-naming`, plus a handful of checks tied to specific rules in [§10 Language Feature Do's and Don'ts](cpp-style-guide.md#10-language-feature-dos-and-donts):
- `cppcoreguidelines-owning-memory` / `cppcoreguidelines-no-malloc` → [Memory and Ownership](cpp-style-guide.md#memory-and-ownership): flags raw owning pointers and C-style allocation.
- `modernize-use-nullptr`, `modernize-use-override`, `cppcoreguidelines-pro-type-cstyle-cast` → [Modern C++ Features](cpp-style-guide.md#modern-c-features).
- `modernize-concat-nested-namespaces` → [Namespaces](cpp-style-guide.md#namespaces): rewrites manually nested `namespace network_utils { namespace detail { ... } }` into the required `namespace network_utils::detail { ... }` form (`clang-tidy --fix` applies it automatically).
- `bugprone-exception-escape` → [Error Handling](cpp-style-guide.md#error-handling): flags exceptions escaping `noexcept` contexts, including destructors.
- `GlobalVariableCase`/`GlobalVariablePrefix` and `StaticVariableCase`/`StaticVariablePrefix` → [§1](cpp-style-guide.md#1-naming-conventions): `g_`/`s_` for mutable global and `static` variables. `ClassMemberCase`/`ClassMemberPrefix` covers the same `s_` rule for static class data members specifically, kept separate from the `m_`-prefixed instance members. Constants are unaffected — `GlobalConstantCase` still requires `PascalCase` with no prefix, since a `const`/`constexpr` global isn't the mutable state these prefixes exist to flag.
## `c-api/.clang-tidy`
A second, directory-scoped `.clang-tidy` that overrides naming to match [§13 Naming Conventions (C API)](cpp-style-guide.md#13-naming-conventions-c-api) — `snake_case` functions/variables, `UPPER_CASE` enum values and macros — without touching the rest of the project. clang-tidy always walks up from the file being checked to the nearest `.clang-tidy`, so this is the standard way to give one subtree different rules; no per-file flags needed. `InheritParentConfig: true` keeps the shared checks (like `bugprone-exception-escape`) from the root config active here too.
## `.editorconfig`
A fallback for editors/IDEs that display tabs-vs-spaces before clang-format has a chance to run on save. Doesn't replace `.clang-format` — just prevents a raw, unformatted first draft from using spaces.
## `.gitattributes`
Forces LF line endings for source files on checkout, regardless of each contributor's OS or local `core.autocrlf` setting. Without this, the exact same file can end up CRLF on a Windows checkout and LF on Linux/Mac — which makes `clang-format`'s output and the on-disk file disagree on every single line, not because of real formatting drift but purely because of line endings. This is what makes the `.githooks/pre-commit` diff check (and `clang-format` in general) behave identically across platforms.
## `.githooks/pre-commit`
A plain bash script — no Python, no `pip install`, no third-party framework — that runs `clang-format` and `clang-tidy` against staged C/C++ files before each commit.
**Setup (once per clone):**
```
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
```
**Tool availability is optional, by design:**
- If `clang-format` or `clang-tidy` isn't found on `PATH`, the hook prints a yellow warning and skips that check — it never blocks a commit just because a contributor hasn't installed the tools.
- If a tool *is* installed and finds real formatting or lint issues, the commit is blocked with a red error and the fix command (`clang-format -i ...`).
- `clang-tidy` additionally needs `compile_commands.json` to resolve `#include`s; if that's missing, it's treated the same as a missing binary — a warning, not a failure. Generate one with `cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON` (or `bear -- make` for non-CMake builds).
Because `core.hooksPath` is a local git config, each contributor runs the setup command once — it isn't automatically enabled by cloning the repo. If you want it enforced repo-wide regardless of local setup, run the same checks in CI as a second, unconditional layer.
---
## Platform notes
The whole toolset — `.clang-format`, `.clang-tidy`, `c-api/.clang-tidy`, `.editorconfig`, `.gitattributes`, and `.githooks/pre-commit` — works unmodified on Linux, macOS, and Windows. A few specifics:
- **Linux:** install the tools via your distro's package manager, e.g. `apt install clang-format clang-tidy` (Debian/Ubuntu) or the equivalent for your distro.
- **macOS:** `brew install clang-format llvm` (Homebrew's `llvm` formula includes `clang-tidy`; it isn't symlinked onto `PATH` by default — `brew --prefix llvm` shows where it installed, add its `bin/` to `PATH`).
- **Windows:** install LLVM from [releases.llvm.org](https://releases.llvm.org) or `winget install LLVM.LLVM`, which puts `clang-format.exe`/`clang-tidy.exe` on `PATH`. The `.githooks/pre-commit` script itself needs no separate installation or WSL — it runs through Git for Windows' bundled Git Bash automatically, since `git.exe` reads the `#!/usr/bin/env bash` line at the top of the hook and executes it through that shell. If a contributor doesn't have the LLVM tools installed, the hook warns and skips, exactly as on Linux/Mac.
- **Line endings:** `.gitattributes` normalizes everything to LF on checkout regardless of platform or a contributor's `core.autocrlf` setting, so `clang-format`'s output always matches the on-disk file byte-for-byte and the hook's diff check doesn't produce false positives from CRLF/LF mismatches.
---
## What these tools can't check
Some rules in the guide are structural or naming-adjacent in ways `clang-format`/`clang-tidy` can't reach. These stay as code-review checklist items:
- **File naming** — [§1](cpp-style-guide.md#1-naming-conventions)'s `PascalCase` `.hpp`/`.cpp` file names and [§13](cpp-style-guide.md#13-naming-conventions-c-api)'s `snake_case` `.h`/`.c` file names. Neither tool renames or checks file names.
- **One type per header** — [§6](cpp-style-guide.md#6-header-files) and the one-template-per-header rule in [Templates](cpp-style-guide.md#templates). No standard clang-tidy check counts top-level declarations per file.
- **The `#include`/forward-declare choice and the Pimpl idiom** — [Headers as Public API](cpp-style-guide.md#headers-as-public-api-keep-implementation-types-out) is a design decision, not a formatting or naming rule.
- **The C API symbol-prefix convention** (e.g. `net_manager_*`) in [§13](cpp-style-guide.md#13-naming-conventions-c-api) — `c-api/.clang-tidy` enforces the *casing*, but a project-specific prefix isn't something a generic naming check can require without hardcoding your library name into the config.
- **Exceptions-for-true-exceptions-only** in [Error Handling](cpp-style-guide.md#error-handling) — whether a given `throw` represents a genuinely exceptional condition versus expected control flow is a judgment call no linter makes reliably.
- **The static/dynamic/runtime-load guidance** in [§11](cpp-style-guide.md#11-library-linking-and-runtime-loading) — these are architectural decisions about what crosses a library boundary, not something a formatter enforces per line.
+57
View File
@@ -0,0 +1,57 @@
---
# c-api/.clang-tidy
# Place this file at the root of whatever directory holds your Part II
# (C API) surface — e.g. c-api/, public-include/, or wherever your extern "C"
# headers and shims live. clang-tidy always uses the closest .clang-tidy file
# up the directory tree, so files under this directory pick up these
# snake_case naming rules instead of the PascalCase/camelCase rules in the
# repo-root .clang-tidy, without any extra flags or per-file configuration.
#
# Formatting (tabs, Allman braces, line length) still comes from the single
# root-level .clang-format — only naming differs between Part I and Part II,
# per style guide §15 (Indentation and Braces).
InheritParentConfig: true
Checks: >
readability-identifier-naming,
bugprone-exception-escape
CheckOptions:
# --- C API naming — style guide §13 ---
# Every public symbol uses snake_case with a library/module prefix
# (e.g. net_manager_connect, NET_MAX_RETRIES). clang-tidy can enforce the
# casing; the prefix itself is a code-review convention (see TOOLING.md).
- key: readability-identifier-naming.FunctionCase
value: lower_case
- key: readability-identifier-naming.VariableCase
value: lower_case
- key: readability-identifier-naming.ParameterCase
value: lower_case
- key: readability-identifier-naming.LocalVariableCase
value: lower_case
- key: readability-identifier-naming.StructCase
value: CamelCase
- key: readability-identifier-naming.EnumCase
value: CamelCase
# Enum values — ALL_CAPS_SNAKE_CASE with a library + enum prefix
# (e.g. NET_STATE_CONNECTED).
- key: readability-identifier-naming.EnumConstantCase
value: UPPER_CASE
- key: readability-identifier-naming.GlobalConstantCase
value: UPPER_CASE
- key: readability-identifier-naming.MacroDefinitionCase
value: UPPER_CASE
# Internal (non-exported) global/static variables — style guide §13's
# g_/s_ rows. Only applies to mutable state; GlobalConstantCase above
# still governs const globals, which stay UPPER_CASE with no prefix.
- key: readability-identifier-naming.GlobalVariableCase
value: lower_case
- key: readability-identifier-naming.GlobalVariablePrefix
value: g_
- key: readability-identifier-naming.StaticVariableCase
value: lower_case
- key: readability-identifier-naming.StaticVariablePrefix
value: s_
+381
View File
@@ -0,0 +1,381 @@
# =============================================================================
# BuildHelpers.cmake
#
# Reusable, project-agnostic build machinery: module discovery, per-target
# compiler/RPATH settings, and the module-registration macros (add_lib_module,
# add_exe_module, add_test_module, add_example_module) that a module's own
# CMakeLists.txt calls.
#
# This file is meant to change rarely and hold nothing project-specific.
# New targets should not need edits here, or in the root CMakeLists.txt --
# see the "Module discovery" section of CMakeLists.txt and the per-module
# CMakeLists.txt convention described there.
#
# Expects the including CMakeLists.txt to have already set (from project()
# and the project-identity block): PROJECT_NAME, PROJECT_VERSION,
# PROJECT_CODE_NAME. Multiplatform: MSVC/WIN32 branches guard the
# Windows-only paths; everything else is generic.
# =============================================================================
include_guard(GLOBAL)
# -----------------------------------------------------------------------
# all_tests / all_examples -- aggregate targets so groups can be built
# without CMake target-name collisions:
# cmake --build . --target all_tests
# cmake --build . --target all_examples
#
# enable_testing() is required for add_test_module()'s add_test() calls to
# actually be discoverable by ctest -- without it, tests build fine but
# `ctest` reports "No tests were found" with no error anywhere else.
# -----------------------------------------------------------------------
enable_testing()
add_custom_target(all_tests COMMENT "Build all test targets")
add_custom_target(all_examples COMMENT "Build all example targets")
# -----------------------------------------------------------------------
# _discover_module_subdirectories(ROOT_DIR): add_subdirectory() every
# immediate child of ROOT_DIR that contains its own CMakeLists.txt.
#
# This is the whole mechanism behind "no root edits to add a module": drop
# a new directory with a one-line CMakeLists.txt under one of the known
# module roots (source/ for libraries and apps, source/tests/ or
# source/examples/ for tests and examples) and it is picked up automatically.
# CONFIGURE_DEPENDS means the *next build* reconfigures on its own after a
# module directory is added or removed -- no manual `cmake` re-run needed.
#
# Order between add_subdirectory() calls does not matter for correctness:
# CMake resolves target_link_libraries() names against the whole project
# at generate time, not sequentially, so a module may DEPENDS on a module
# discovered later (verified empirically -- this isn't a guess).
#
# A macro (not a function) so add_subdirectory() behaves exactly as if it
# were called directly from the caller -- no extra scope layer involved.
# -----------------------------------------------------------------------
macro(_discover_module_subdirectories ROOT_DIR)
if(IS_DIRECTORY "${ROOT_DIR}")
file(GLOB _pmn_module_candidates CONFIGURE_DEPENDS LIST_DIRECTORIES true "${ROOT_DIR}/*")
foreach(_pmn_candidate ${_pmn_module_candidates})
if(IS_DIRECTORY "${_pmn_candidate}" AND EXISTS "${_pmn_candidate}/CMakeLists.txt")
add_subdirectory("${_pmn_candidate}")
endif()
endforeach()
endif()
endmacro()
# -----------------------------------------------------------------------
# _apply_compile_options: per-target warning flags.
# MSVC path : /W4 (high warnings) + /permissive- (strict conformance).
# GCC/Clang : -Wall -Wextra -Wpedantic.
# -----------------------------------------------------------------------
function(_apply_compile_options TARGET)
if(MSVC)
target_compile_options(${TARGET} PRIVATE /W4 /permissive-)
else()
target_compile_options(${TARGET} PRIVATE -Wall -Wextra -Wpedantic)
endif()
endfunction()
# -----------------------------------------------------------------------
# _apply_rpath: RPATH is an ELF-only concept; skip entirely on Windows.
# -----------------------------------------------------------------------
function(_apply_rpath TARGET RPATH_VAL)
if(NOT WIN32)
set_target_properties(${TARGET} PROPERTIES
BUILD_RPATH "${RPATH_VAL}"
INSTALL_RPATH "${RPATH_VAL}"
)
endif()
endfunction()
# -----------------------------------------------------------------------
# _set_output_dirs(TARGET RUNTIME_DIR [LIBRARY_DIR]):
#
# CMakeLists.txt sets CMAKE_RUNTIME_OUTPUT_DIRECTORY_<CONFIG> /
# CMAKE_LIBRARY_OUTPUT_DIRECTORY_<CONFIG> globally (once, near the top) so
# every target defaults to the flat bin/ layout. That also means every
# new target's per-config RUNTIME_OUTPUT_DIRECTORY_<CONFIG> /
# LIBRARY_OUTPUT_DIRECTORY_<CONFIG> property is pre-populated at creation
# time -- and a per-config property always wins over the plain
# (config-independent) one. So a module that needs a *different* output
# directory (tests -> bin/tests, examples -> bin/examples) must override
# the per-config properties too, or the override silently has no effect.
# This helper does both in one place.
# -----------------------------------------------------------------------
function(_set_output_dirs TARGET RUNTIME_DIR)
set(_library_dir "${RUNTIME_DIR}")
if(ARGC GREATER 2)
set(_library_dir "${ARGV2}")
endif()
set_target_properties(${TARGET} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${RUNTIME_DIR}"
LIBRARY_OUTPUT_DIRECTORY "${_library_dir}"
)
foreach(_cfg Debug Release RelWithDebInfo MinSizeRel)
string(TOUPPER "${_cfg}" _CFG)
set_target_properties(${TARGET} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY_${_CFG} "${RUNTIME_DIR}"
LIBRARY_OUTPUT_DIRECTORY_${_CFG} "${_library_dir}"
)
endforeach()
endfunction()
# -----------------------------------------------------------------------
# _register_executable_target(TARGET): tracks every executable module
# (exe/test/example) in a global property so _write_module_manifest()
# can find them all without anything needing to know their names ahead
# of time.
# -----------------------------------------------------------------------
function(_register_executable_target TARGET)
set_property(GLOBAL APPEND PROPERTY PMN_EXECUTABLE_TARGETS ${TARGET})
endfunction()
# -----------------------------------------------------------------------
# _register_module_target(CATEGORY TARGET): tracks a target under
# CATEGORY (PRIMARY, TEST, or EXAMPLE) in a global property so
# CMakeLists.txt's install() rules can be generated from whatever modules
# actually got discovered, instead of a hardcoded target list that would
# need editing every time a module is added or removed.
# -----------------------------------------------------------------------
function(_register_module_target CATEGORY TARGET)
set_property(GLOBAL APPEND PROPERTY PMN_MODULE_TARGETS_${CATEGORY} ${TARGET})
endfunction()
# -----------------------------------------------------------------------
# _write_module_manifest(OUTPUT_DIR): call once, after every module has
# been discovered, to emit "<target-name>=<absolute-path-to-binary>"
# lines for every executable module into
# OUTPUT_DIR/module_manifest-<CONFIG>.txt.
#
# scripts/run.sh / run.bat read this file to resolve a CMake target name to its
# actual executable path -- so they never hardcode per-module paths and
# never need editing when a module is added, removed, or renamed.
# $<CONFIG> is part of the filename because file(GENERATE) requires that
# for content (like $<TARGET_FILE:...>) that can differ per build
# configuration under a multi-config generator (e.g. Ninja Multi-Config).
# -----------------------------------------------------------------------
function(_write_module_manifest OUTPUT_DIR)
get_property(_pmn_targets GLOBAL PROPERTY PMN_EXECUTABLE_TARGETS)
set(_manifest_content "")
foreach(_t ${_pmn_targets})
string(APPEND _manifest_content "${_t}=$<TARGET_FILE:${_t}>\n")
endforeach()
file(GENERATE
OUTPUT "${OUTPUT_DIR}/module_manifest-$<CONFIG>.txt"
CONTENT "${_manifest_content}"
)
endfunction()
# -----------------------------------------------------------------------
# _finalize_module_target(TARGET MODULE_NAME [OUTPUT_NAME name] [DEPENDS ...])
#
# The tail every module macro below shares: identity compile definitions,
# warning flags, OUTPUT_NAME, and DEPENDS linking. Centralized so all four
# macros stay in lockstep -- edit once here instead of four times.
# Re-parses the caller's raw ARGN, so it's safe to call as
# _finalize_module_target(${NAME} ${_MODULE_NAME} ${ARGN}) even though
# ARGN also contains keywords (TYPE, EXTRA_SOURCES, ...) this function
# doesn't know about -- those are simply ignored.
# -----------------------------------------------------------------------
# -----------------------------------------------------------------------
# _finalize_module_target(TARGET MODULE_NAME [OUTPUT_NAME name] [DEPENDS ...]
# [NO_DOCS])
#
# The tail every module macro below shares: identity compile definitions,
# warning flags, OUTPUT_NAME, DEPENDS linking, and an automatic per-module
# Doxygen target (see cmake/Documentation.cmake) unless NO_DOCS is given.
# Centralized so all four macros stay in lockstep -- edit once here
# instead of four times. Re-parses the caller's raw ARGN, so it's safe to
# call as _finalize_module_target(${NAME} ${_MODULE_NAME} ${ARGN}) even
# though ARGN also contains keywords (TYPE, EXTRA_SOURCES, ...) this
# function doesn't know about -- those are simply ignored.
# -----------------------------------------------------------------------
function(_finalize_module_target TARGET MODULE_NAME)
cmake_parse_arguments(FIN "NO_DOCS" "OUTPUT_NAME" "DEPENDS" ${ARGN})
target_compile_definitions(${TARGET} PRIVATE
$<$<CONFIG:Debug>:DEBUG>
MODULE_NAME="${MODULE_NAME}"
PROJECT_NAME="${PROJECT_NAME}"
PROJECT_NAME_CODE="${PROJECT_CODE_NAME}"
PROJECT_VER="${PROJECT_VERSION}"
)
_apply_compile_options(${TARGET})
if(FIN_OUTPUT_NAME)
set_target_properties(${TARGET} PROPERTIES OUTPUT_NAME ${FIN_OUTPUT_NAME})
endif()
if(FIN_DEPENDS)
target_link_libraries(${TARGET} PRIVATE ${FIN_DEPENDS})
endif()
if(NOT FIN_NO_DOCS)
_add_module_docs(${TARGET})
endif()
endfunction()
# =============================================================================
# add_lib_module(NAME [TYPE STATIC|SHARED] [OUTPUT_NAME name] [DEPENDS ...]
# [EXTRA_SOURCES ...] [NO_DOCS])
#
# Called from a library module's own CMakeLists.txt (e.g. source/Core/CMakeLists.txt).
# Sources/headers are globbed relative to that module's own directory:
# src/*.cpp and include/*.h(pp) underneath it. TYPE picks STATIC or SHARED
# for this module only; omit it to use the project-wide default
# (BUILD_SHARED_LIBS -- set in CMakeLists.txt's OPTIONS section, or pass
# -DBUILD_SHARED_LIBS=ON at configure time).
#
# Headers are globbed in purely so they show up in IDE project trees --
# CMake never compiles headers, so this is inert for the actual build.
# =============================================================================
macro(add_lib_module NAME)
cmake_parse_arguments(ARG "" "OUTPUT_NAME;TYPE" "DEPENDS;EXTRA_SOURCES" ${ARGN})
if(ARG_OUTPUT_NAME)
set(_MODULE_NAME ${ARG_OUTPUT_NAME})
else()
set(_MODULE_NAME ${NAME})
endif()
if(ARG_TYPE)
set(_LIB_TYPE ${ARG_TYPE})
elseif(BUILD_SHARED_LIBS)
set(_LIB_TYPE SHARED)
else()
set(_LIB_TYPE STATIC)
endif()
if(NOT _LIB_TYPE MATCHES "^(STATIC|SHARED)$")
message(FATAL_ERROR "add_lib_module(${NAME}): TYPE must be STATIC or SHARED, got '${_LIB_TYPE}'")
endif()
file(GLOB_RECURSE ${NAME}_SOURCES CONFIGURE_DEPENDS src/*.cpp)
file(GLOB_RECURSE ${NAME}_HEADERS CONFIGURE_DEPENDS include/*.h include/*.hpp)
add_library(${NAME} ${_LIB_TYPE} ${${NAME}_SOURCES} ${${NAME}_HEADERS} ${ARG_EXTRA_SOURCES})
target_include_directories(${NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
if(_LIB_TYPE STREQUAL "STATIC")
# Keep static libs relocatable in case they end up linked into a
# shared library or PIE executable later.
set_target_properties(${NAME} PROPERTIES POSITION_INDEPENDENT_CODE ON)
else()
# LIBRARY_OUTPUT_DIRECTORY controls .so placement on Linux.
# RUNTIME_OUTPUT_DIRECTORY controls .dll placement on Windows.
# WINDOWS_EXPORT_ALL_SYMBOLS auto-generates a .def file so all
# public symbols are exported without __declspec(dllexport).
# Remove if you prefer explicit visibility macros instead.
_set_output_dirs(${NAME} "${CMAKE_BINARY_DIR}/${RUNTIME_OUTPUT_SUBDIR}" "${CMAKE_BINARY_DIR}/${LIBRARY_OUTPUT_SUBDIR}")
set_target_properties(${NAME} PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON)
endif()
_register_module_target(PRIMARY ${NAME})
_finalize_module_target(${NAME} ${_MODULE_NAME} ${ARGN})
endmacro()
# Back-compat wrappers for the old two-macro spelling.
# Prefer add_lib_module(... TYPE STATIC|SHARED) directly in new code.
macro(add_static_lib_module NAME)
add_lib_module(${NAME} TYPE STATIC ${ARGN})
endmacro()
macro(add_shared_lib_module NAME)
add_lib_module(${NAME} TYPE SHARED ${ARGN})
endmacro()
# =============================================================================
# add_exe_module(NAME [OUTPUT_NAME name] [DEPENDS ...] [EXTRA_SOURCES ...] [NO_DOCS])
#
# Called from an app module's own CMakeLists.txt (e.g. HelloApp/CMakeLists.txt).
# Sources/headers are globbed relative to that module's own directory:
# *.cpp and include/*.h(pp) underneath it.
# =============================================================================
macro(add_exe_module NAME)
cmake_parse_arguments(ARG "" "OUTPUT_NAME" "DEPENDS;EXTRA_SOURCES" ${ARGN})
if(ARG_OUTPUT_NAME)
set(_MODULE_NAME ${ARG_OUTPUT_NAME})
else()
set(_MODULE_NAME ${NAME})
endif()
file(GLOB_RECURSE ${NAME}_SOURCES CONFIGURE_DEPENDS *.cpp)
file(GLOB_RECURSE ${NAME}_HEADERS CONFIGURE_DEPENDS include/*.h include/*.hpp)
add_executable(${NAME} ${${NAME}_SOURCES} ${${NAME}_HEADERS} ${ARG_EXTRA_SOURCES})
target_include_directories(${NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
_set_output_dirs(${NAME} "${CMAKE_BINARY_DIR}/${RUNTIME_OUTPUT_SUBDIR}")
_apply_rpath(${NAME} "$ORIGIN")
_register_module_target(PRIMARY ${NAME})
_register_executable_target(${NAME})
_finalize_module_target(${NAME} ${_MODULE_NAME} ${ARGN})
endmacro()
# =============================================================================
# add_test_module(NAME [OUTPUT_NAME name] [DEPENDS ...] [EXTRA_SOURCES ...] [NO_DOCS])
#
# Called from a test module's own CMakeLists.txt (e.g. source/tests/mytest/CMakeLists.txt).
# Lands in bin/tests/, so shared-lib deps one level up need $ORIGIN/...
# EXCLUDE_FROM_ALL keeps it out of the default build; BUILD_TESTS opts it
# back in.
# =============================================================================
macro(add_test_module NAME)
cmake_parse_arguments(ARG "" "OUTPUT_NAME" "DEPENDS;EXTRA_SOURCES" ${ARGN})
if(ARG_OUTPUT_NAME)
set(_MODULE_NAME ${ARG_OUTPUT_NAME})
else()
set(_MODULE_NAME ${NAME})
endif()
file(GLOB_RECURSE ${NAME}_SOURCES CONFIGURE_DEPENDS *.cpp)
file(GLOB_RECURSE ${NAME}_HEADERS CONFIGURE_DEPENDS include/*.h include/*.hpp)
if(BUILD_TESTS)
add_executable(${NAME} ${${NAME}_SOURCES} ${${NAME}_HEADERS} ${ARG_EXTRA_SOURCES})
else()
add_executable(${NAME} EXCLUDE_FROM_ALL ${${NAME}_SOURCES} ${${NAME}_HEADERS} ${ARG_EXTRA_SOURCES})
endif()
target_include_directories(${NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include)
_set_output_dirs(${NAME} "${CMAKE_BINARY_DIR}/${RUNTIME_OUTPUT_SUBDIR}/tests")
_apply_rpath(${NAME} "$ORIGIN/..")
_register_module_target(TEST ${NAME})
_register_executable_target(${NAME})
_finalize_module_target(${NAME} ${_MODULE_NAME} ${ARGN})
add_test(NAME ${NAME} COMMAND ${NAME})
add_dependencies(all_tests ${NAME})
endmacro()
# =============================================================================
# add_example_module(NAME [OUTPUT_NAME name] [DEPENDS ...] [EXTRA_SOURCES ...] [NO_DOCS])
#
# Called from an example module's own CMakeLists.txt (e.g. source/examples/myexample/CMakeLists.txt).
# Same bin/examples/ + $ORIGIN/.. layout as tests.
# =============================================================================
macro(add_example_module NAME)
cmake_parse_arguments(ARG "" "OUTPUT_NAME" "DEPENDS;EXTRA_SOURCES" ${ARGN})
if(ARG_OUTPUT_NAME)
set(_MODULE_NAME ${ARG_OUTPUT_NAME})
else()
set(_MODULE_NAME ${NAME})
endif()
file(GLOB_RECURSE ${NAME}_SOURCES CONFIGURE_DEPENDS *.cpp)
file(GLOB_RECURSE ${NAME}_HEADERS CONFIGURE_DEPENDS include/*.h include/*.hpp)
if(BUILD_EXAMPLES)
add_executable(${NAME} ${${NAME}_SOURCES} ${${NAME}_HEADERS} ${ARG_EXTRA_SOURCES})
else()
add_executable(${NAME} EXCLUDE_FROM_ALL ${${NAME}_SOURCES} ${${NAME}_HEADERS} ${ARG_EXTRA_SOURCES})
endif()
target_include_directories(${NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include)
_set_output_dirs(${NAME} "${CMAKE_BINARY_DIR}/${RUNTIME_OUTPUT_SUBDIR}/examples")
_apply_rpath(${NAME} "$ORIGIN/..")
_register_module_target(EXAMPLE ${NAME})
_register_executable_target(${NAME})
_finalize_module_target(${NAME} ${_MODULE_NAME} ${ARGN})
add_dependencies(all_examples ${NAME})
endmacro()
+125
View File
@@ -0,0 +1,125 @@
# =============================================================================
# Dependencies.cmake
#
# Three ways to bring in a third-party dependency, and one helper that ties
# two of them together. All three keep the dependency's own warnings out of
# your build (SYSTEM) and its sources out of compile_commands.json, so
# clangd/clang-tidy stay focused on code this project owns.
#
# add_vendor_header_only(NAME [INCLUDE_DIR <dir>])
# A buildless (header-only) drop checked into vendor/<NAME>/include
# (or vendor/<INCLUDE_DIR>). Wraps it as an INTERFACE target NAME.
#
# add_vendor_subdirectory(SUBDIR)
# A vendored dependency that ships its own CMakeLists.txt, checked
# into vendor/<SUBDIR> (a plain source drop, git submodule, or git
# subtree). Whatever target names that CMakeLists.txt defines are
# what you DEPENDS on.
#
# add_dependency(NAME <FetchContent_Declare args...>)
# CMake's native find_package + FetchContent integration: pass
# FIND_PACKAGE_ARGS ... to try an already-installed copy (an apt/
# vcpkg/conan package) first, and only download + build from source
# if nothing is found. Omit FIND_PACKAGE_ARGS to always fetch.
# https://cmake.org/cmake/help/latest/module/FetchContent.html#integrating-with-find-package
#
# All three are safe to call more than once for the same name (e.g. two
# modules that both need the same dependency) -- later calls are no-ops.
#
# A module DEPENDS on whatever target the chosen path defines, exactly
# like depending on another module -- add_lib_module/add_exe_module don't
# need to know or care which of the three supplied it.
#
# IMPORTANT -- installing: a vendored/fetched dependency's own
# CMakeLists.txt usually has its own install() rules, and there is no
# reliable, version-independent way to suppress them from here (EXCLUDE_
# FROM_ALL's effect on install() is explicitly documented as undefined,
# and CMAKE_SKIP_INSTALL_RULES breaks `cmake --install` outright for
# FetchContent -- both were tried and rejected). The actual fix lives in
# CMakeLists.txt: our own install(TARGETS ...) calls are tagged
# COMPONENT ${PROJECT_CODE_NAME}, and scripts/install.sh / install.bat always pass
# --component so a third-party dependency's untagged install rules are
# never pulled in. Installing with plain `cmake --install build` (no
# --component) will also install whatever the dependency wanted to
# install, typically under CMAKE_INSTALL_PREFIX (e.g. /usr/local) --
# use the install script, or pass --component yourself.
# =============================================================================
include_guard(GLOBAL)
# -----------------------------------------------------------------------
# _exclude_from_compile_commands(DIR): drop every target defined under
# DIR from compile_commands.json. Applied to vendored/fetched third-party
# code so the exported compile database stays focused on code this
# project actually owns.
# -----------------------------------------------------------------------
function(_exclude_from_compile_commands DIR)
if(NOT IS_DIRECTORY "${DIR}")
return()
endif()
get_property(_pmn_dir_targets DIRECTORY "${DIR}" PROPERTY BUILDSYSTEM_TARGETS)
foreach(_t ${_pmn_dir_targets})
set_target_properties(${_t} PROPERTIES EXPORT_COMPILE_COMMANDS OFF)
endforeach()
endfunction()
# -----------------------------------------------------------------------
# add_vendor_header_only(NAME [INCLUDE_DIR <dir>])
# -----------------------------------------------------------------------
macro(add_vendor_header_only NAME)
cmake_parse_arguments(ARG "" "INCLUDE_DIR" "" ${ARGN})
if(NOT TARGET ${NAME})
add_library(${NAME} INTERFACE)
if(ARG_INCLUDE_DIR)
target_include_directories(${NAME} SYSTEM INTERFACE "${CMAKE_SOURCE_DIR}/vendor/${ARG_INCLUDE_DIR}")
else()
target_include_directories(${NAME} SYSTEM INTERFACE "${CMAKE_SOURCE_DIR}/vendor/${NAME}/include")
endif()
endif()
endmacro()
# -----------------------------------------------------------------------
# add_vendor_subdirectory(SUBDIR)
#
# EXCLUDE_FROM_ALL means only targets actually depended on via
# target_link_libraries get built. SYSTEM marks its headers so our
# per-module warning flags (added only by add_lib_module/add_exe_module/
# etc., never globally) don't fire when *our* code includes them.
# -----------------------------------------------------------------------
macro(add_vendor_subdirectory SUBDIR)
get_property(_pmn_vendor_added GLOBAL PROPERTY PMN_VENDOR_ADDED_${SUBDIR} SET)
if(NOT _pmn_vendor_added)
set_property(GLOBAL PROPERTY PMN_VENDOR_ADDED_${SUBDIR} TRUE)
add_subdirectory(
"${CMAKE_SOURCE_DIR}/vendor/${SUBDIR}"
"${CMAKE_BINARY_DIR}/vendor/${SUBDIR}"
EXCLUDE_FROM_ALL SYSTEM
)
_exclude_from_compile_commands("${CMAKE_SOURCE_DIR}/vendor/${SUBDIR}")
endif()
endmacro()
# -----------------------------------------------------------------------
# add_dependency(NAME <FetchContent_Declare args...>)
#
# Example -- prefer an installed copy, fetch+build from source otherwise:
# add_dependency(fmt
# GIT_REPOSITORY https://github.com/fmtlib/fmt.git
# GIT_TAG 11.0.2
# FIND_PACKAGE_ARGS NAMES fmt
# )
# add_lib_module(mylib DEPENDS fmt::fmt)
#
# FetchContent_MakeAvailable() is documented idempotent per name, so
# calling this more than once for the same NAME (from multiple modules)
# is safe.
# -----------------------------------------------------------------------
macro(add_dependency NAME)
include(FetchContent)
FetchContent_Declare(${NAME} SYSTEM ${ARGN})
FetchContent_MakeAvailable(${NAME})
string(TOLOWER "${NAME}" _pmn_lc_name)
if(DEFINED ${_pmn_lc_name}_SOURCE_DIR)
_exclude_from_compile_commands("${${_pmn_lc_name}_SOURCE_DIR}")
endif()
endmacro()
+243
View File
@@ -0,0 +1,243 @@
# =============================================================================
# Documentation.cmake
#
# Automatic, per-module Doxygen documentation, using a pinned Doxygen
# version this project downloads and manages itself -- never your
# system's doxygen, so every machine gets identical output. Zero setup
# required: every module registered via add_lib_module/add_exe_module/
# add_test_module/add_example_module gets its own documentation target
# for free -- nothing to add to a module's own CMakeLists.txt. Vendored
# and fetched dependencies (cmake/Dependencies.cmake) are never
# auto-documented, same reasoning as their compile_commands.json
# exclusion: it's not your code.
#
# What gets generated, per module <target>:
# out/docs/<target>/html/index.html -- via `cmake --build <build-dir> --target docs_<target>`
# Plus one landing page linking to all of them:
# out/docs/index.html -- via `cmake --build <build-dir> --target docs`
# (or just run scripts/docs.sh / docs.bat, which builds `docs` and prints the path)
# Paths above use the defaults from Configuration.cmake (DOCS_OUTPUT_DIR).
#
# Configuring it:
# -DENABLE_DOXYGEN=OFF disable entirely (skips the download too)
# -DDOXYGEN_PINNED_VERSION=1.17.0 use a different pinned version (also
# update the two SHA256 hashes below to match that release's assets)
# -DDOXYGEN_WARN_IF_UNDOCUMENTED=YES / -DDOXYGEN_QUIET=NO stricter/louder output
# -DDOXYGEN_<ANY_DOXYFILE_TAG>=... any Doxyfile tag can be set this way --
# see https://www.doxygen.nl/manual/config.html
# A module can opt out with NO_DOCS, e.g.:
# add_lib_module(core_lib TYPE SHARED NO_DOCS ...)
# =============================================================================
include_guard(GLOBAL)
# -----------------------------------------------------------------------
# Pinned Doxygen -- deliberately NOT found via find_package(Doxygen)
# searching the system. Every machine building this project downloads and
# uses the exact same Doxygen version, once, cached outside out/ (in
# TOOLCACHE_DIR, see Configuration.cmake) so a clean out/ rebuild doesn't
# force a re-download. Bump DOXYGEN_PINNED_VERSION to change the pinned
# version project-wide -- doing so also requires updating the two SHA256
# hashes below to match the new release's assets (a stale hash fails loud
# and hard by design; see the integrity check further down).
# -----------------------------------------------------------------------
set(DOXYGEN_PINNED_VERSION "1.16.1" CACHE STRING
"Exact Doxygen version this project downloads and uses -- never a system-installed doxygen")
option(ENABLE_DOXYGEN "Generate per-module Doxygen documentation using a pinned, project-managed Doxygen (downloaded automatically)" ON)
if(ENABLE_DOXYGEN)
# Loads the FindDoxygen module (defines doxygen_add_docs(), used
# below) and, as a side effect, searches the system for `dot`
# (Graphviz) for call graphs -- an optional, system-detected
# enhancement. This call's own doxygen-executable detection is
# discarded and overridden with our pinned copy regardless of what
# (if anything) it finds; only `dot` detection from this is used.
find_package(Doxygen QUIET OPTIONAL_COMPONENTS dot)
string(REPLACE "." "_" _pmn_doxygen_tag_version "${DOXYGEN_PINNED_VERSION}")
set(_pmn_doxygen_tag "Release_${_pmn_doxygen_tag_version}")
set(_pmn_doxygen_root "${TOOLCACHE_DIR}/doxygen-${DOXYGEN_PINNED_VERSION}")
if(CMAKE_HOST_WIN32)
set(_pmn_doxygen_url "https://github.com/doxygen/doxygen/releases/download/${_pmn_doxygen_tag}/doxygen-${DOXYGEN_PINNED_VERSION}.windows.x64.bin.zip")
set(_pmn_doxygen_expected_hash "ddfa59a4ae9549651330471c2e387ec7a9891080543faed541c859e5ce448653")
set(_pmn_doxygen_exe "${_pmn_doxygen_root}/doxygen.exe")
elseif(CMAKE_HOST_UNIX AND NOT CMAKE_HOST_APPLE)
set(_pmn_doxygen_url "https://github.com/doxygen/doxygen/releases/download/${_pmn_doxygen_tag}/doxygen-${DOXYGEN_PINNED_VERSION}.linux.bin.tar.gz")
set(_pmn_doxygen_expected_hash "a56f885d37e3aae08a99f638d17bbb381224c03a878d9e2dda4f9fa4baf1d8bd")
set(_pmn_doxygen_exe "${_pmn_doxygen_root}/doxygen-${DOXYGEN_PINNED_VERSION}/bin/doxygen")
else()
message(WARNING "No pinned Doxygen ${DOXYGEN_PINNED_VERSION} binary is configured for this host "
"platform. Install Doxygen ${DOXYGEN_PINNED_VERSION} manually and pass "
"-DDOXYGEN_EXECUTABLE=/path/to/doxygen, or extend cmake/Documentation.cmake "
"with a download URL + hash for this platform.")
set(ENABLE_DOXYGEN OFF)
endif()
if(ENABLE_DOXYGEN AND NOT EXISTS "${_pmn_doxygen_exe}")
message(STATUS "Downloading pinned Doxygen ${DOXYGEN_PINNED_VERSION} (one-time; cached under ${TOOLCACHE_DIR})")
set(_pmn_doxygen_archive "${TOOLCACHE_DIR}/_doxygen_download.archive")
# No EXPECTED_HASH here on purpose: file(DOWNLOAD ... EXPECTED_HASH
# ...) raises a hard, unrecoverable CMake Error on ANY failure --
# including plain network/connectivity failures -- which would
# defeat the graceful "disable docs, keep building" fallback below.
# Downloading first and verifying the hash ourselves afterward
# (further down) lets us tell a network hiccup (recoverable, just
# disable docs for this configure) apart from a genuine integrity
# failure (not recoverable -- see the FATAL_ERROR below).
file(DOWNLOAD "${_pmn_doxygen_url}" "${_pmn_doxygen_archive}"
STATUS _pmn_doxygen_dl_status
SHOW_PROGRESS
)
list(GET _pmn_doxygen_dl_status 0 _pmn_doxygen_dl_code)
if(NOT _pmn_doxygen_dl_code EQUAL 0)
list(GET _pmn_doxygen_dl_status 1 _pmn_doxygen_dl_msg)
file(REMOVE "${_pmn_doxygen_archive}")
message(WARNING "Failed to download pinned Doxygen ${DOXYGEN_PINNED_VERSION}: "
"${_pmn_doxygen_dl_msg}. Documentation will be disabled for this configure "
"-- everything else builds normally. Reconfigure once you have network "
"access to retry.")
set(ENABLE_DOXYGEN OFF)
else()
# Verify integrity ourselves, deliberately as a hard failure --
# a mismatch here means the downloaded artifact doesn't match
# what this project expects (corruption or tampering in
# transit, or a stale pinned hash after bumping
# DOXYGEN_PINNED_VERSION without updating it): a supply-chain-
# relevant integrity failure, not something to silently
# degrade past the way a plain network failure above is.
file(SHA256 "${_pmn_doxygen_archive}" _pmn_doxygen_actual_hash)
string(TOLOWER "${_pmn_doxygen_actual_hash}" _pmn_doxygen_actual_hash)
string(TOLOWER "${_pmn_doxygen_expected_hash}" _pmn_doxygen_expected_hash_lc)
if(NOT _pmn_doxygen_actual_hash STREQUAL _pmn_doxygen_expected_hash_lc)
file(REMOVE "${_pmn_doxygen_archive}")
message(FATAL_ERROR "Pinned Doxygen ${DOXYGEN_PINNED_VERSION} download hash mismatch -- "
"expected ${_pmn_doxygen_expected_hash_lc}, got "
"${_pmn_doxygen_actual_hash}. This usually means "
"DOXYGEN_PINNED_VERSION was bumped without updating the matching "
"hash in cmake/Documentation.cmake, or the download was corrupted "
"or tampered with in transit.")
endif()
file(ARCHIVE_EXTRACT INPUT "${_pmn_doxygen_archive}" DESTINATION "${_pmn_doxygen_root}")
file(REMOVE "${_pmn_doxygen_archive}")
if(NOT CMAKE_HOST_WIN32)
file(CHMOD "${_pmn_doxygen_exe}" PERMISSIONS
OWNER_READ OWNER_WRITE OWNER_EXECUTE
GROUP_READ GROUP_EXECUTE
WORLD_READ WORLD_EXECUTE)
endif()
endif()
endif()
if(ENABLE_DOXYGEN AND NOT EXISTS "${_pmn_doxygen_exe}")
message(WARNING "Pinned Doxygen download/extract did not produce the expected executable at "
"${_pmn_doxygen_exe}. Documentation will be disabled for this configure.")
set(ENABLE_DOXYGEN OFF)
endif()
if(ENABLE_DOXYGEN)
# Override whatever (if anything) find_package(Doxygen) found
# above -- this project never uses a system-installed doxygen.
set(DOXYGEN_EXECUTABLE "${_pmn_doxygen_exe}" CACHE FILEPATH "Pinned Doxygen executable (never the system's)" FORCE)
set(DOXYGEN_VERSION "${DOXYGEN_PINNED_VERSION}")
endif()
endif()
if(ENABLE_DOXYGEN)
message(STATUS "Doxygen ${DOXYGEN_VERSION} ready (pinned, not system-installed: ${DOXYGEN_EXECUTABLE}) -- per-module documentation enabled")
# Project-wide defaults, one Doxyfile tag per DOXYGEN_<TAG> variable.
# Override any of them with -DDOXYGEN_<TAG>=... at configure time.
# Kept deliberately quiet and lenient by default (no warnings about
# undocumented entities) so a freshly generated module with zero
# doxygen comments still produces clean, useful output -- browsable
# files/functions/call graphs -- instead of a wall of warnings.
set(DOXYGEN_GENERATE_HTML YES)
set(DOXYGEN_GENERATE_LATEX NO)
set(DOXYGEN_EXTRACT_ALL YES)
set(DOXYGEN_EXTRACT_STATIC YES)
set(DOXYGEN_EXTRACT_PRIVATE NO)
set(DOXYGEN_WARN_IF_UNDOCUMENTED NO)
set(DOXYGEN_QUIET YES)
set(DOXYGEN_RECURSIVE YES)
set(DOXYGEN_SOURCE_BROWSER YES)
set(DOXYGEN_GENERATE_TREEVIEW YES)
if(DOXYGEN_DOT_FOUND)
set(DOXYGEN_HAVE_DOT YES)
set(DOXYGEN_CALL_GRAPH YES)
set(DOXYGEN_CALLER_GRAPH YES)
endif()
add_custom_target(docs COMMENT "Build documentation for every module")
else()
add_custom_target(docs
COMMAND ${CMAKE_COMMAND} -E echo
"Documentation is disabled (ENABLE_DOXYGEN=OFF, or Doxygen isn't installed). Install Doxygen and reconfigure to enable it."
VERBATIM
)
endif()
# -----------------------------------------------------------------------
# _add_module_docs(TARGET): called automatically from _finalize_module_target
# for every module -- creates docs_<TARGET> (a Doxygen run scoped to that
# module's own directory) and wires it into the aggregate `docs` target.
# A no-op if ENABLE_DOXYGEN is off. Excluded from the default build same
# as tests/examples -- documentation is opt-in per build, not generated
# on every compile.
#
# If the module's own directory has a README.md, it's used as that
# module's Doxygen mainpage automatically.
# -----------------------------------------------------------------------
function(_add_module_docs TARGET)
if(NOT ENABLE_DOXYGEN)
return()
endif()
set(DOXYGEN_OUTPUT_DIRECTORY "${DOCS_OUTPUT_DIR}/${TARGET}")
set(DOXYGEN_PROJECT_NAME "${TARGET}")
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/README.md")
set(DOXYGEN_USE_MDFILE_AS_MAINPAGE "${CMAKE_CURRENT_SOURCE_DIR}/README.md")
else()
unset(DOXYGEN_USE_MDFILE_AS_MAINPAGE)
endif()
doxygen_add_docs(docs_${TARGET}
"${CMAKE_CURRENT_SOURCE_DIR}"
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
COMMENT "Generating documentation for module '${TARGET}'"
)
set_target_properties(docs_${TARGET} PROPERTIES EXCLUDE_FROM_ALL TRUE)
add_dependencies(docs docs_${TARGET})
set_property(GLOBAL APPEND PROPERTY PMN_DOC_MODULES ${TARGET})
endfunction()
# -----------------------------------------------------------------------
# _write_docs_index(OUTPUT_DIR): call once, after every module has been
# discovered, to write a plain landing page at OUTPUT_DIR/index.html
# linking to each module's own generated docs -- so "where do I start
# reading" has one obvious answer regardless of how many modules exist.
# -----------------------------------------------------------------------
function(_write_docs_index OUTPUT_DIR)
if(NOT ENABLE_DOXYGEN)
return()
endif()
file(MAKE_DIRECTORY "${OUTPUT_DIR}")
get_property(_pmn_doc_modules GLOBAL PROPERTY PMN_DOC_MODULES)
list(SORT _pmn_doc_modules)
set(_pmn_doc_links "")
foreach(_pmn_mod ${_pmn_doc_modules})
string(APPEND _pmn_doc_links " <li><a href=\"${_pmn_mod}/html/index.html\">${_pmn_mod}</a></li>\n")
endforeach()
file(WRITE "${OUTPUT_DIR}/index.html" "<!DOCTYPE html>
<html>
<head><meta charset=\"utf-8\"><title>${PROJECT_NAME} -- module documentation</title></head>
<body>
<h1>${PROJECT_NAME} module documentation</h1>
<ul>
${_pmn_doc_links} </ul>
</body>
</html>
")
endfunction()
+685
View File
@@ -0,0 +1,685 @@
# C/C++ Coding Style Guide
This guide is split into two parts:
- **[Part I: C++ Style](#part-i-c-style)** applies to all internal application/library code written in C++.
- **[Part II: C API Style](#part-ii-c-api-style)** applies specifically to public API surfaces exposed as plain C (`extern "C"`, `.h` headers) — e.g. for dynamic library boundaries, plugin interfaces, or C-consumer compatibility. Keep these two worlds separate: don't let C-API constraints (no exceptions, no classes, `snake_case`) leak into internal C++ code, and don't let C++ constructs leak into a public C API.
## Contents
**[Part I: C++ Style](#part-i-c-style)**
1. [Naming Conventions](#1-naming-conventions)
2. [Indentation](#2-indentation)
3. [Brace Placement (Allman Style)](#3-brace-placement-allman-style)
4. [Pointers and References](#4-pointers-and-references)
5. [Spacing](#5-spacing)
6. [Header Files](#6-header-files)
- [Namespaces](#namespaces)
- [Headers as Public API: Keep Implementation Types Out](#headers-as-public-api-keep-implementation-types-out)
- [Templates](#templates)
- [Implementing Templates (Project Standard)](#implementing-templates-project-standard)
7. [Include Directive Order](#7-include-directive-order)
8. [Line Length](#8-line-length)
9. [Comments](#9-comments)
10. [Language Feature Do's and Don'ts](#10-language-feature-dos-and-donts)
- [Memory and Ownership](#memory-and-ownership)
- [Error Handling](#error-handling)
- [Modern C++ Features](#modern-c-features)
11. [Library Linking and Runtime Loading](#11-library-linking-and-runtime-loading)
- [Static Linking](#static-linking)
- [Dynamic Linking (Shared Libraries)](#dynamic-linking-shared-libraries)
- [Runtime Loading (dlopen and LoadLibrary)](#runtime-loading-dlopen-and-loadlibrary)
- [Summary](#summary)
12. [Miscellaneous](#12-miscellaneous)
**[Part II: C API Style](#part-ii-c-api-style)**
13. [Naming Conventions (C API)](#13-naming-conventions-c-api)
14. [File Extensions and Structure](#14-file-extensions-and-structure)
15. [Indentation and Braces](#15-indentation-and-braces)
16. [API Design Do's and Don'ts](#16-api-design-dos-and-donts)
17. [Boundary Rule of Thumb](#17-boundary-rule-of-thumb)
---
# Part I: C++ Style
## 1. Naming Conventions
| Symbol Type | Convention | Example |
|-------------------------|-------------------|-----------------------------------|
| Classes / Structs | `PascalCase` | `class NetworkManager;` |
| Functions / Methods | `camelCase` | `void connectToServer();` |
| Local Variables | `camelCase` | `int retryCount = 0;` |
| Member Variables (private/protected) | `camelCase` with `m_` prefix | `int m_connectionTimeout;` |
| Static Member Variables (class-scope `static`) | `camelCase` with `s_` prefix | `static int s_instanceCount;` |
| Global Variables (namespace/file scope, mutable) | `camelCase` with `g_` prefix | `int g_activeConnectionCount;` |
| Static Variables (function-local or file-scope `static`) | `camelCase` with `s_` prefix | `static int s_retryAttempts;` |
| Constants | `PascalCase` | `constexpr int MaxRetries = 5;` |
| Enum Type Names | `PascalCase` | `enum class ConnectionState` |
| Enum Values | `PascalCase` | `ConnectionState::Connected` |
| Namespaces | `snake_case` | `namespace network_utils` |
| Template Parameters | `PascalCase` | `template <typename ValueType>` |
| Macros | `ALL_CAPS_SNAKE_CASE` | `#define MAX_BUFFER_SIZE 1024` |
| File Names | `PascalCase` | `NetworkManager.cpp` / `.hpp` |
> **Note:** Macros are an exception to the general PascalCase/camelCase scheme since `ALL_CAPS` is the near-universal C/C++ convention and helps macros stand out visually from regular code.
> **Note:** `g_`/`s_` mark *mutable* global/static state specifically so it stands out at every use site — genuinely global mutable variables are rare and worth flagging by name. A `constexpr` or `const` global/static value is a **constant**, not a variable for this rule's purposes: it keeps the `PascalCase` constant convention above, with no `g_`/`s_` prefix, since it can't cause the aliasing/lifetime bugs the prefix exists to call out (e.g. `constexpr int MaxRetries = 5;` at namespace scope, not `g_MaxRetries`). A `static` variable inside a function keeps `s_` for the same reason as a file-scope one — both persist across calls and are exactly the kind of hidden state the prefix is meant to surface.
---
## 2. Indentation
- Use **tabs** for indentation (not spaces).
- Do not mix tabs and spaces.
- Configure your editor's tab width to display consistently (recommended: 4 columns wide) across the team.
- **Namespace bodies are indented**, exactly like any other block (`if`, `class`, `for`, ...) — a namespace is not a special case left flush against the margin. See [Namespaces](#namespaces) for the full rule and examples.
```cpp
void doSomething()
{
if (condition)
{
performAction();
}
}
```
---
## 3. Brace Placement (Allman Style)
Braces always go on their **own new line**, aligned with the statement that opens the block.
```cpp
// Correct (Allman)
if (isReady)
{
start();
}
else
{
waitForReady();
}
class Widget
{
public:
Widget();
~Widget();
};
void process()
{
for (int i = 0; i < count; ++i)
{
handle(i);
}
}
```
```cpp
// Incorrect (K&R style — do not use)
if (isReady) {
start();
}
```
---
## 4. Pointers and References
Attach `*` and `&` to the **type**, not the variable name.
```cpp
int* pointerToValue;
const std::string& nameRef;
void processData(const Data* data, int& outResult);
```
---
## 5. Spacing
- One space around binary operators: `a + b`, `x == y`.
- No space between a function name and its parentheses: `doWork()`, not `doWork ()`.
- One space after control-flow keywords: `if (`, `for (`, `while (`.
```cpp
if (a == b)
{
total = a + b * c;
}
```
---
## 6. Header Files
- Use `#pragma once` at the top of every header.
- **`.hpp` for C++ headers, `.h` for C headers.** Any header containing C++-only constructs (classes, templates, namespaces, overloads, `enum class`, etc.) uses `.hpp`. Reserve `.h` for headers meant to be valid C — see [Part II: C API Style](#part-ii-c-api-style) for the public C API convention.
- **One `.cpp` per `.hpp`.** Each header declaring a class or module has exactly one corresponding source file implementing it, sharing the same base name (`NetworkManager.hpp` ↔ `NetworkManager.cpp`). Avoid dumping multiple unrelated classes' implementations into one `.cpp`, and avoid splitting one class's implementation across multiple `.cpp` files.
- **One class, struct, or enum per header — meaning one *publicly usable* type.** Each `.hpp` exposes exactly one type as part of its public API: the primary class/struct/enum a caller `#include`s the header to use. A header should never declare two independent, separately-usable types (e.g. `NetworkManager` and an unrelated `RetryPolicy`) side by side — each gets its own `.hpp`. This is what prevents the dependency problem: a file that only needs `RetryPolicy` should never be forced to also compile/depend on everything `NetworkManager` pulls in. Small types that have no meaning or use *outside* the primary type — a nested `enum class` used only as one of its parameters, a tag type only passed to its constructor — aren't "separate" types for this rule; they're part of the primary type's interface and stay in the same header.
- Header-only components (templates, small inline utilities) are the exception to the one-`.cpp`-per-`.hpp` rule — they may have a `.hpp` with no matching `.cpp` — but the one-type-per-header rule still applies to them (see [Templates](#templates) below).
- Match header/source file names to the primary class (`PascalCase`), e.g. `NetworkManager.hpp` / `NetworkManager.cpp` for `class NetworkManager`.
- **Do not add a trailing comment on a closing brace** (`}; // class NetworkManager`, `} // namespace network_utils`, `} // if (isReady)`, etc.), for namespaces, classes, functions, or any other block. Keep blocks short and readable enough that the comment isn't needed — if a block is too long to tell what its closing brace belongs to at a glance, the fix is to shorten or restructure the block, not to annotate the brace.
```cpp
// NetworkManager.hpp
#pragma once
namespace network_utils
{
class NetworkManager
{
public:
NetworkManager();
~NetworkManager();
void connectToServer();
private:
int m_connectionTimeout;
};
}
```
### Namespaces
- **Namespace contents are indented**, exactly like the body of any other block. Don't leave everything inside a namespace flush against the left margin just because the namespace itself doesn't "do" anything at runtime — indentation is what makes nesting depth obvious at a glance, and a namespace is nesting.
- **Use the C++17 nested-namespace syntax (`namespace outer::inner { ... }`) instead of manually nesting namespace blocks**, whenever you're declaring a fixed, known multi-level namespace (e.g. `network_utils::detail`). Manually nesting `namespace network_utils { namespace detail { ... } }` for a case like this just adds an extra level of indentation and an extra closing brace for no benefit.
- The one case that still requires manual nesting: wrapping an **anonymous** namespace inside a named one (e.g. hiding a `.cpp`-local helper type inside `network_utils`) — anonymous namespaces have no name, so there's nothing to join with `::`.
```cpp
// Preferred — a fixed multi-level namespace uses the single nested-namespace form
namespace network_utils::detail
{
struct RetryState
{
int attemptCount;
int backoffMs;
};
}
```
```cpp
// Avoid — manually nesting two named namespaces that could be joined with ::
namespace network_utils
{
namespace detail
{
struct RetryState
{
int attemptCount;
int backoffMs;
};
}
}
```
### Headers as Public API: Keep Implementation Types Out
Treat every header as a **public interface contract**, not a scratchpad for whatever the implementation happens to need. A caller including `NetworkManager.hpp` should see only what they need to *use* `NetworkManager` — not the types that exist purely to help `NetworkManager.cpp` implement it internally.
- **Implementation-only helper types belong in the `.cpp`, not the `.hpp`.** If a class needs a small helper struct, enum, or class that callers never see or interact with (a parser's internal token type, a lookup-table entry, a strategy object used only inside one function), define it in the `.cpp` file, wrapped in an unnamed (anonymous) namespace so it has internal linkage and can't leak or collide across translation units:
```cpp
// NetworkManager.cpp
#include "NetworkManager.hpp"
namespace network_utils
{
namespace
{
// Implementation detail — never appears in the header, never used outside this file.
struct RetryState
{
int attemptCount;
int backoffMs;
};
}
void NetworkManager::connectToServer()
{
RetryState state{0, 100};
// ...
}
}
```
- **Forward-declare instead of including, whenever the header only needs a pointer or reference to another type.** This keeps the header's dependency footprint to exactly the API surface it exposes, rather than transitively dragging in every header its implementation happens to use.
```cpp
// NetworkManager.hpp
#pragma once
namespace network_utils
{
class Socket; // forward declaration — full definition only needed in NetworkManager.cpp
class NetworkManager
{
public:
void connectToServer();
private:
Socket* m_socket;
};
}
```
- **One real C++ constraint to know:** a class's *own* private member variables (as opposed to helper types) generally must still be declared in the header, because C++ needs the complete type — including all members — to know the class's size and layout wherever it's used by value, on the stack, or as a direct member of another class. This is different from hiding a *helper type*; you're not hiding the fact that `NetworkManager` has a timeout, only hiding the definition of an unrelated struct it happens to use internally.
- If you need to hide private *members* too — for a stable ABI across a shared-library boundary (see [Library Linking and Runtime Loading](#11-library-linking-and-runtime-loading)) or to shrink recompiles when private internals change — the standard technique is the **Pimpl idiom** (pointer to implementation): the header holds only a forward-declared pointer, and the real member variables live in a struct defined entirely in the `.cpp`.
```cpp
// NetworkManager.hpp
#pragma once
#include <memory>
namespace network_utils
{
class NetworkManager
{
public:
NetworkManager();
~NetworkManager();
void connectToServer();
private:
struct Impl; // forward-declared, defined only in NetworkManager.cpp
std::unique_ptr<Impl> m_impl;
};
}
```
```cpp
// NetworkManager.cpp
#include "NetworkManager.hpp"
namespace network_utils
{
struct NetworkManager::Impl
{
int connectionTimeout;
};
NetworkManager::NetworkManager() : m_impl(std::make_unique<Impl>()) {}
NetworkManager::~NetworkManager() = default;
void NetworkManager::connectToServer()
{
// use m_impl->connectionTimeout
}
}
```
Pimpl isn't required for every class — it adds an extra pointer indirection and a heap allocation, so reserve it for types where header-hiding genuinely matters (public library API, ABI-sensitive dynamic-library boundaries, or classes whose private internals churn often enough that reducing rebuild fan-out is worth the cost). For ordinary internal application classes, plain private members in the header (as in the example at the top of this section) are fine — only the *helper types*, not the members themselves, need to move out.
### Templates
Templates are *more* exposed to the one-type-per-header problem than ordinary classes, for two structural reasons specific to how templates compile:
1. **No compiled-`.cpp` firewall.** An ordinary class hides its implementation behind a `.cpp` — callers only need the declaration to link against it. A template has no such firewall: the compiler needs the *full definition* everywhere it's instantiated, so templates live entirely in headers (see the exception to "one `.cpp` per `.hpp`" in [Header Files](#6-header-files)). That means anything sharing a header with a template gets pulled into every translation unit that only wanted that one template.
2. **Parsing cost applies even to unused templates.** Bundling `template <typename T> class Cache` and `template <typename T> class Pool` in the same header means a file that only uses `Cache` still has to parse `Pool`'s full definition, and a change to `Pool` still bumps the header's timestamp — forcing a rebuild of every translation unit that includes it, including the ones that never touch `Pool` at all.
Given that, the rules:
- **One template class/function per header.** `Cache` and `Pool` each get their own `.hpp`, even if they're conceptually related utilities that usually get used together.
- **Keep a template's declaration and inline implementation in the same `.hpp`** — templates generally can't be split into a separate `.cpp` the way non-template code can (see reason 1 above) — but that's not license to merge *other*, unrelated templates into the same file alongside it.
- **If several templates genuinely share implementation details** (a common traits struct, a shared constant), factor *only that shared piece* into its own single-purpose header, rather than merging the templates themselves into one file. If the shared piece belongs in a nested namespace (e.g. an internal `detail` namespace not meant for outside use), declare it with the `::` nested-namespace syntax from [Namespaces](#namespaces).
- **Avoid including heavy headers** (e.g. `<iostream>`, large third-party headers) inside a widely-used template header purely for convenience — because template headers are included wherever they're instantiated, an unnecessary include here silently bloats compile times project-wide, multiplied across every instantiation site.
```cpp
// Avoid — two unrelated templates sharing one header
// Cache.hpp
#pragma once
namespace storage
{
template <typename Key, typename Value>
class Cache
{
public:
void insert(const Key& key, const Value& value);
const Value* find(const Key& key) const;
};
template <typename T>
class Pool
{
public:
T* acquire();
void release(T* item);
};
}
```
```cpp
// Preferred — one template per header
// Cache.hpp
#pragma once
namespace storage
{
template <typename Key, typename Value>
class Cache
{
public:
void insert(const Key& key, const Value& value);
const Value* find(const Key& key) const;
};
}
```
```cpp
// Pool.hpp — separate file, included only where Pool is actually needed
#pragma once
namespace storage
{
template <typename T>
class Pool
{
public:
T* acquire();
void release(T* item);
};
}
```
If `Cache` and `Pool` both rely on the same internal helper — say, a fixed-capacity ring buffer trait — that shared piece moves to its own header, in an internal namespace declared with `::`:
```cpp
// DetailTraits.hpp — included by both Cache.hpp and Pool.hpp, nothing else
#pragma once
namespace storage::detail
{
template <typename T, std::size_t Capacity>
struct RingBufferTraits
{
using ValueType = T;
static constexpr std::size_t capacity = Capacity;
};
}
```
#### Implementing Templates (Project Standard)
Templates are the one exception to this guide's header-declares/`.cpp`-implements split: the compiler needs a template's *complete definition* at every point it gets instantiated, and a separately-compiled `.cpp` can't supply that. So the implementation has to live in the header — but to keep this consistent across the codebase rather than a per-template judgment call, **this project uses one standard pattern**:
> **Declare template members in the class body, and define them out-of-line further down in the same header** — the same shape as an out-of-line member definition of a non-template class, just kept in the `.hpp` instead of moved to a `.cpp`.
```cpp
// Cache.hpp
#pragma once
namespace storage
{
template <typename Key, typename Value>
class Cache
{
public:
void insert(const Key& key, const Value& value);
const Value* find(const Key& key) const;
private:
std::unordered_map<Key, Value> m_entries;
};
template <typename Key, typename Value>
void Cache<Key, Value>::insert(const Key& key, const Value& value)
{
m_entries[key] = value;
}
template <typename Key, typename Value>
const Value* Cache<Key, Value>::find(const Key& key) const
{
auto it = m_entries.find(key);
return it != m_entries.end() ? &it->second : nullptr;
}
}
```
- **Exception:** a genuinely trivial one-line body (a plain getter, a one-expression forwarding call) can stay inline in the class, same as it would for a non-template class. Don't force it out-of-line just for consistency's sake.
- **Escape hatch, not a pattern:** if a template is only ever meant to be used with a small, fixed set of types, its definitions *can* move to a `.cpp` via explicit instantiation (`template class Cache<int, std::string>;`). This is a deliberate, rare trade-off — it turns a general-purpose template into one that only supports the explicitly instantiated types — and should be a documented exception with a stated reason (e.g. reducing header bloat for a widely-included, ABI-sensitive type), not the default way templates get written.
> **See also:** [Library Linking and Runtime Loading](#11-library-linking-and-runtime-loading) — the Pimpl idiom above exists mainly to satisfy the ABI constraints described there · [Part II: C API Style](#part-ii-c-api-style) — the `.h`/`.hpp` split introduced here is defined in full there.
---
## 7. Include Directive Order
Group includes into blocks, separated by a blank line, in the following order. Within each block, sort alphabetically.
1. **Corresponding header** — for a `.cpp`, its own `.hpp` comes first (catches missing-include bugs in the header itself).
2. **C system headers** — `<cstdio>`, `<cstdlib>`, etc.
3. **C++ standard library headers** — `<vector>`, `<memory>`, `<string>`, etc.
4. **Third-party / external library headers** — e.g. `<boost/...>`, `<fmt/...>`.
5. **Project headers** — other headers from this codebase, using quoted includes (`"..."`).
```cpp
// NetworkManager.cpp
#include "NetworkManager.hpp" // 1. corresponding header
#include <cstdint> // 2. C system headers
#include <memory> // 3. C++ standard library
#include <string>
#include <vector>
#include <fmt/format.h> // 4. third-party libraries
#include "Logger.hpp" // 5. project headers
#include "SocketWrapper.hpp"
```
- Use angle brackets `<...>` for system, standard library, and third-party headers.
- Use quotes `"..."` for project-local headers.
- This ordering guarantees each header can be compiled standalone (no hidden dependency on something included before it) and makes missing includes fail fast in CI rather than silently compiling due to include order in some other file.
---
## 8. Line Length
- Prefer a maximum of **100 characters** per line.
- Break long function signatures with one parameter per line, indented once, if needed.
---
## 9. Comments
- Use `//` for single-line comments.
- Use `/** ... */` (Doxygen-style) for documenting public API functions and classes.
```cpp
/**
* Establishes a connection to the configured server.
* @return true if the connection succeeded.
*/
bool connectToServer();
```
---
## 10. Language Feature Do's and Don'ts
### Memory and Ownership
| Do | Don't |
|----|-------|
| Use `std::unique_ptr` / `std::shared_ptr` for owning dynamic objects | Use raw `new` / `delete` for ownership |
| Use raw pointers or references for **non-owning** access | Use a raw pointer to express or transfer ownership |
| Use `std::make_unique` / `std::make_shared` to construct smart pointers | Construct a smart pointer with `new` directly (`std::unique_ptr<T>(new T())`) |
| Follow RAII for all resources (files, locks, sockets, etc.) | Manually pair acquire/release calls |
### Error Handling
| Do | Don't |
|----|-------|
| Throw exceptions for **true exceptional situations** (unrecoverable errors, invariant violations, constructor failures) | Use exceptions for expected/recoverable control flow (e.g. "value not found") |
| Use return values (`bool`, `std::optional`, error codes, `std::expected` where available) for expected failure cases | Use exceptions as a substitute for normal branching logic |
| Catch exceptions by `const&` | Catch exceptions by value (causes slicing) |
| Keep destructors `noexcept` (the default) | Let exceptions escape a destructor |
### Modern C++ Features
| Do | Don't |
|----|-------|
| Use `auto` when it improves readability (iterators, lambdas, long template types) | Use `auto` when it hides an important or non-obvious type |
| Use range-based `for` loops over index-based loops when the index isn't needed | Write manual index loops just to iterate a container |
| Use lambdas for short, local callbacks and algorithm predicates | Write a named functor class for a one-off, trivial operation |
| Use structured bindings for multi-value returns (`auto [key, value] = ...`) | Unpack pairs/tuples manually with `.first` / `.second` when structured bindings are clearer |
| Use `constexpr` / `consteval` for compile-time constants and computations | Use `#define` macros for constants |
| Use `enum class` over plain `enum` | Use unscoped `enum` (pollutes namespace, weak typing) |
| Use `override` and `final` explicitly on overriding virtual functions | Omit `override`, relying on the compiler to catch signature mismatches |
| Use `nullptr` | Use `NULL` or `0` for pointers |
| Prefer `std::array` / `std::vector` over C-style arrays | Use raw C-style arrays for anything beyond fixed, tiny buffers |
| Pass small, cheap-to-copy types by value; pass large objects by `const&` | Pass large objects by value out of habit |
| Use `std::string_view` for read-only, non-owning string parameters | Take `const std::string&` when the caller may pass a string literal or substring |
---
## 11. Library Linking and Runtime Loading
How a component is built and linked shapes how strictly some of the above rules should be enforced.
### Static Linking
- Libraries are compiled directly into the final binary at build time.
- Header/implementation split still matters for compile-time isolation, but ABI stability across the boundary is **not** a concern — the whole binary is rebuilt together.
- Inline-heavy, template-heavy, and header-only code (common with `auto`, templates, `constexpr`) is safe here since everything resolves at compile time in one binary.
### Dynamic Linking (Shared Libraries)
- Applies to `.so` (Linux), `.dll` (Windows), and `.dylib` (macOS) targets.
- The public headers exposed across a shared-library boundary form an **ABI contract** — style choices here have real consequences:
- Avoid exposing `inline` functions, templates, or `constexpr` values in the public API surface if the library and its consumers may be compiled by different compilers/versions — inlined code bakes assumptions into the caller.
- Avoid changing the layout of any `class`/`struct` exposed across the boundary (adding/reordering member variables) without a version bump — it breaks binary compatibility for already-compiled consumers.
- Prefer exposing a stable, minimal interface at the boundary (see [Part II: C API Style](#part-ii-c-api-style)), and keep the `PascalCase` classes with full modern C++ usage as **internal implementation detail** behind it.
- Mark exported symbols explicitly (e.g. `__declspec(dllexport)` / `__attribute__((visibility("default")))`), and keep everything else hidden by default, to keep the ABI surface intentional and small.
### Runtime Loading (dlopen and LoadLibrary)
- Any function loaded dynamically at runtime **must** be declared with C linkage (`extern "C"`) to avoid name-mangling issues — this is exactly the case [Part II: C API Style](#part-ii-c-api-style) exists for, even in an otherwise all-`.hpp` C++ codebase.
- Keep the loaded interface as a plain function-pointer table or `extern "C"` API — no classes, no exceptions crossing the boundary (an exception thrown from a plugin loaded via `dlopen` and caught in the host app is undefined behavior across incompatible runtimes/compilers).
- Document plugin/module boundaries clearly with `.hpp` interface headers describing the contract, even though the actual crossing point is a plain C-style vtable or function table.
### Summary
| Context | Modern C++ features across the boundary | ABI stability required |
|---|---|---|
| Static link | Freely (templates, `auto`, inline, `constexpr`) | No — whole program rebuilt together |
| Dynamic link (`.so`/`.dll`) | Restrict at the public boundary; free internally | Yes — layout & symbol changes are breaking |
| Runtime load (`dlopen`) | `extern "C"` only at the boundary ([Part II](#part-ii-c-api-style) style) | Yes — plus no exceptions/classes across it |
> **See also:** [Boundary Rule of Thumb](#17-boundary-rule-of-thumb) — decides, file by file, whether a piece of code follows this section or Part II · [Part II: C API Style](#part-ii-c-api-style) — the actual convention referenced throughout this section.
---
## 12. Miscellaneous
- Mark single-argument constructors `explicit` unless implicit conversion is intended.
- Prefer `const` correctness everywhere it applies.
---
# Part II: C API Style
This section governs any code written as **plain C**, or any `extern "C"` surface exposed *from* a C++ library for consumption by C callers, dynamic loaders (`dlopen`), or other languages via FFI. Nothing in this section applies to internal C++ implementation — only to the boundary itself and to files that are genuinely compiled as C.
## 13. Naming Conventions (C API)
| Symbol Type | Convention | Example |
|-------------------------|----------------------|----------------------------------------|
| Public API functions | `snake_case` with library prefix | `net_manager_connect(NetManager* mgr);` |
| Types (`struct`/`enum`/typedef) | `PascalCase` with library prefix, `_t` suffix optional | `NetManagerT` or `NetManager` |
| Struct fields | `snake_case` | `int connection_timeout;` |
| Function parameters | `snake_case` | `int retry_count` |
| Constants / `#define` | `ALL_CAPS_SNAKE_CASE` with library prefix | `#define NET_MAX_RETRIES 5` |
| Enum values | `ALL_CAPS_SNAKE_CASE` with library + enum prefix | `NET_STATE_CONNECTED` |
| Internal global variables (translation-unit-visible, mutable) | `g_snake_case` | `int g_active_connection_count;` |
| Static variables (function-local or file-scope `static`) | `s_snake_case` | `static int s_retry_attempts;` |
| File names | `snake_case` | `net_manager.h` / `net_manager.c` |
- Every public symbol (function, type, macro, enum value) is **prefixed with the library/module name** to avoid collisions in the global C symbol namespace, since C has no namespaces.
- `snake_case` is used throughout the C API — this is a deliberate departure from the `camelCase`/`PascalCase` used in Part I, and is one of the clearest visual signals that a symbol belongs to the C boundary rather than internal C++.
- The `g_`/`s_` prefixes are for **internal, non-exported** state — the kind of file-scope `static` variable that never appears in a public header. A genuinely public global exported from the API surface is unusual enough that it should get the full library prefix instead (`net_manager_default_timeout`, not `g_net_manager_default_timeout`) — don't stack both prefixes on the same symbol.
---
## 14. File Extensions and Structure
- **`.h`** for all public C API headers — must be valid to include from both C and C++ translation units.
- **`.c`** for files actually compiled as C. If the "C API" is really a C++ implementation exposing an `extern "C"` shim, the shim itself lives in a `.cpp` file but declares its exported functions in a `.h` header guarded for C++ (see below).
- One `.h` per logical module/library surface (e.g. `net_manager.h`), mirroring the one-header-per-component rule from Part I, but scoped to the *public* API rather than one per internal class.
```c
/* net_manager.h */
#ifndef NET_MANAGER_H
#define NET_MANAGER_H
#ifdef __cplusplus
extern "C" {
#endif
typedef struct NetManager NetManager;
NetManager* net_manager_create(void);
void net_manager_destroy(NetManager* mgr);
int net_manager_connect(NetManager* mgr, const char* host, int port);
#ifdef __cplusplus
}
#endif
#endif /* NET_MANAGER_H */
```
- Use traditional `#ifndef`/`#define`/`#endif` header guards in `.h` files rather than `#pragma once` — `.h` files must remain portable to strict C compilers where `#pragma once` support is less universally guaranteed.
- Always wrap declarations in `extern "C" { ... }` guarded by `#ifdef __cplusplus`, so the same header works whether included from a C or a C++ translation unit.
---
## 15. Indentation and Braces
Match Part I for consistency across the codebase: **tabs** for indentation, **Allman** brace placement.
```c
int net_manager_connect(NetManager* mgr, const char* host, int port)
{
if (mgr == NULL)
{
return NET_ERROR_INVALID_ARGUMENT;
}
return connect_impl(mgr, host, port);
}
```
---
## 16. API Design Do's and Don'ts
| Do | Don't |
|----|-------|
| Return an integer error code / status enum from every fallible function | Throw or let a C++ exception cross into `extern "C"` code (undefined behavior for C callers) |
| Use opaque pointers (`typedef struct NetManager NetManager;` with the definition hidden in the `.c`/`.cpp`) to hide implementation details and preserve ABI stability | Expose full `struct` layouts in the public header if the layout may change — this breaks binary compatibility |
| Provide explicit `_create` / `_destroy` (or `_init` / `_free`) pairs for any type owning resources | Rely on constructors/destructors (C has none) or leave lifetime management implicit |
| Take a `void* user_data` parameter on any callback-accepting function | Assume the caller can supply capturing closures — plain C function pointers can't capture state |
| Check all pointer parameters for `NULL` before dereferencing at public entry points | Assume public API callers always pass valid pointers |
| Keep the header C89/C99-compatible (no `//` comments, no variable declarations mid-block if targeting strict C89) unless the project's minimum C standard is confirmed higher | Use C++-only syntax anywhere in a `.h` that must remain includable from C |
| Document ownership transfer explicitly in comments (e.g. "caller owns the returned pointer and must call `net_manager_destroy`") | Leave ownership of returned pointers ambiguous |
---
## 17. Boundary Rule of Thumb
- If a file is `.cpp`/`.hpp` and never crosses a dynamic-library, plugin, or FFI boundary: **use [Part I](#part-i-c-style), full stop.**
- If a file declares or implements symbols meant to be called from C, another language, or loaded via `dlopen`/`LoadLibrary`: **use [Part II](#part-ii-c-api-style)** for that surface, even if the implementation behind it is C++.
- Never mix the two conventions within the same file — a `.h` should never contain `camelCase` C++-flavored declarations, and a `.hpp` should never contain `snake_case` C-flavored ones. Where a C++ class needs a C API, write a thin `extern "C"` shim (Part II style) that internally calls into the real C++ implementation (Part I style).
> **See also:** [Library Linking and Runtime Loading](#11-library-linking-and-runtime-loading) — the dynamic-library and `dlopen` boundaries that make this rule necessary in the first place.
-32
View File
@@ -1,32 +0,0 @@
#pragma once
//
// cliforge.hpp
//
// cliforge: a small header-only C++20 library for building CLIs whose
// commands are just free functions.
//
// #include <cliforge/cliforge.hpp>
//
// void greet(std::string name, bool loud) {
// std::cout << (loud ? "HELLO, " : "Hello, ") << name << (loud ? "!!" : ".") << "\n";
// }
//
// int main(int argc, char** argv) {
// cliforge::Engine cli("greeter");
// cli.command()
// .keyword("greet", "Say hello to someone")
// .parameter("name", "Who to greet")
// .flag("loud", "l", "SHOUT the greeting")
// .describe("Greets someone by name")
// .action(&greet);
// return cli.run(argc, argv);
// }
//
// See README.md for the full guide.
//
#include "command.hpp"
#include "engine.hpp"
#include "function_traits.hpp"
#include "levenshtein.hpp"
#include "slot.hpp"
#include "value.hpp"
-884
View File
@@ -1,884 +0,0 @@
#pragma once
//
// command.hpp
//
// Command is the fluent builder AND the parser for one CLI command. A
// command is built like:
//
// engine.command()
// .keyword("project")
// .parameter<std::string>("name", "Project name")
// .keyword("delete")
// .flag("force", 'f', "Skip the confirmation prompt")
// .option<std::string>("reason", 'r', "Why it's being deleted")
// .describe("Delete a project by name")
// .action(&deleteProject);
//
// Every parameter/option is explicitly typed where it's declared, so the
// engine never has to invent a type from the bound function -- `.action()`
// instead *validates* that the function's corresponding argument is a
// consistent binding (bool for a flag; T, std::optional<T>, or
// std::vector<T> for a parameter/option; by value, by reference, or by
// const reference all work). Mismatches are RegistrationErrors caught at
// startup, not silent surprises at run time.
//
#include <algorithm>
#include <cctype>
#include <functional>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <tuple>
#include <type_traits>
#include <typeindex>
#include <vector>
#include "function_traits.hpp"
#include "levenshtein.hpp"
#include "slot.hpp"
#include "value.hpp"
namespace cliforge {
namespace detail {
// --- small type-trait helpers used only for action() binding ----------
template <typename T>
struct is_optional : std::false_type {};
template <typename T>
struct is_optional<std::optional<T>> : std::true_type {
using inner = T;
};
template <typename T>
inline constexpr bool is_optional_v = is_optional<T>::value;
template <typename T>
struct is_vector : std::false_type {};
template <typename T>
struct is_vector<std::vector<T>> : std::true_type {
using inner = T;
};
template <typename T>
inline constexpr bool is_vector_v = is_vector<T>::value;
template <typename T>
inline constexpr bool is_builtin_scalar_v =
std::is_same_v<T, int8_t> || std::is_same_v<T, uint8_t> ||
std::is_same_v<T, int16_t> || std::is_same_v<T, uint16_t> ||
std::is_same_v<T, int32_t> || std::is_same_v<T, uint32_t> ||
std::is_same_v<T, int64_t> || std::is_same_v<T, uint64_t> ||
std::is_same_v<T, float> || std::is_same_v<T, double> ||
std::is_same_v<T, std::string> || std::is_same_v<T, char> ||
std::is_same_v<T, bool>;
// A token is where the unstructured (flags/options) part begins if it
// starts with a dash and isn't shaped like a negative number -- that way
// `build -5 -3 -1` (variadic int parameters) still works even though the
// tokens start with '-'.
inline bool looksLikeOptionStart(std::string_view tok) {
if (tok.size() < 2 || tok[0] != '-') return false;
if (tok[1] == '-') return true; // "--foo"
if (std::isdigit(static_cast<unsigned char>(tok[1]))) return false; // "-5"
return true; // "-n", "-ndi"
}
inline bool isValidKeyword(std::string_view s) {
if (s.empty()) return false;
if (s[0] == '-') return false; // reserved for flags/options
for (char c : s) {
if (!(std::isalnum(static_cast<unsigned char>(c)) || c == '.' ||
c == '_' || c == '-')) {
return false;
}
}
return true;
}
// A human-readable name for a bound action() argument type, used in
// RegistrationError messages. Only built-in scalars and enums are ever
// legal here (anything else is already rejected by parameter<T>()'s own
// static_assert), so this never needs to handle the general case.
template <typename T>
std::string friendlyTypeName() {
if constexpr (is_builtin_scalar_v<T>) {
return TypeOf<T>::get().displayName;
} else if constexpr (std::is_enum_v<T>) {
return "a different enum type";
} else {
return "an unsupported type";
}
}
} // namespace detail
// Result of trying to line a command's structured (keyword+parameter)
// signature up against the front of the user's token list.
struct StructuredMatch {
bool success = false;
std::size_t consumed = 0; // tokens belonging to the structured part
int keywordScore = 0; // number of literal keywords matched
std::vector<Value> paramValues;
// Set when every literal keyword matched but a parameter's value
// either failed to parse or was never given -- lets the Engine
// surface this specific, actionable error instead of a generic
// "no match" + fuzzy suggestions.
std::string error;
};
class Command {
public:
// Keywords carry no description of their own -- .describe() on the
// command is what shows up in help; a keyword is purely a literal
// token to match.
Command& keyword(std::string literal) {
if (unstructuredStarted_) {
throw RegistrationError(
"can't declare keyword('" + literal +
"') after a flag()/option() was added -- keywords must "
"come entirely before the unstructured part");
}
if (structuredSealed_) {
throw RegistrationError(
"can't declare keyword('" + literal +
"') after a variadic parameter -- a variadic parameter "
"must be the last thing in the structured part");
}
if (!detail::isValidKeyword(literal)) {
throw RegistrationError(
"invalid keyword '" + literal +
"': keywords may contain letters, digits, '.', '_' and "
"'-', and may not start with '-'");
}
Slot s;
s.kind = SlotKind::Keyword;
s.name = std::move(literal);
structured_.push_back(std::move(s));
lastSlot_ = &structured_.back();
return *this;
}
// T is explicit and required: every parameter is typed at the point
// it's declared, not inferred later from action(). Called before any
// flag()/option(), this is a fixed-position structured parameter;
// called after, it's a "loose" parameter matched positionally among
// other loose parameters regardless of where flags/options fall
// around it.
template <typename T>
Command& parameter(std::string name, std::string description, bool variadic = false) {
if (name.empty()) {
throw RegistrationError("parameter name may not be empty");
}
Slot s;
s.kind = SlotKind::Parameter;
s.name = std::move(name);
s.description = std::move(description);
s.variadic = variadic;
s.cppType = std::type_index(typeid(T));
if constexpr (std::is_enum_v<T>) {
s.awaitingChoices = true; // choices<T>(...) must follow immediately
} else {
static_assert(detail::is_builtin_scalar_v<T>,
"cliforge: unsupported parameter type -- use a fixed-width int, "
"float/double, std::string, char, bool, or an enum (with "
"choices<T>() called immediately after)");
s.type = TypeOf<T>::get();
}
if (!unstructuredStarted_) {
if (structuredSealed_) {
throw RegistrationError(
"can't declare parameter('" + s.name +
"') after a variadic parameter -- a variadic parameter "
"must be the last thing in the structured part");
}
structured_.push_back(std::move(s));
lastSlot_ = &structured_.back();
if (variadic) structuredSealed_ = true;
} else {
if (looseSealed_) {
throw RegistrationError(
"can't declare parameter('" + s.name +
"') after a variadic parameter -- a variadic parameter "
"must be the last parameter declared");
}
unstructured_.push_back(std::move(s));
lastSlot_ = &unstructured_.back();
if (variadic) looseSealed_ = true;
}
return *this;
}
// shortName is 0 (the default) for "no short form".
Command& flag(std::string longName, char shortName, std::string description) {
longName = stripDashes(std::move(longName));
validateLongName(longName);
validateShortName(shortName);
checkNameFree(longName, shortName);
Slot s;
s.kind = SlotKind::Flag;
s.name = std::move(longName);
s.shortName = shortName;
s.description = std::move(description);
unstructured_.push_back(std::move(s));
lastSlot_ = &unstructured_.back();
unstructuredStarted_ = true;
return *this;
}
// T is explicit and required, same as parameter<T>(). Options are
// always optional in the calling convention (see action()) -- there
// is no way to declare a "required" option, by design.
template <typename T>
Command& option(std::string longName, char shortName, std::string description,
bool variadic = false) {
longName = stripDashes(std::move(longName));
validateLongName(longName);
validateShortName(shortName);
checkNameFree(longName, shortName);
Slot s;
s.kind = SlotKind::Option;
s.name = std::move(longName);
s.shortName = shortName;
s.description = std::move(description);
s.variadic = variadic;
s.cppType = std::type_index(typeid(T));
if constexpr (std::is_enum_v<T>) {
s.awaitingChoices = true;
} else {
static_assert(detail::is_builtin_scalar_v<T>,
"cliforge: unsupported option type -- use a fixed-width int, "
"float/double, std::string, char, bool, or an enum (with "
"choices<T>() called immediately after)");
s.type = TypeOf<T>::get();
}
unstructured_.push_back(std::move(s));
lastSlot_ = &unstructured_.back();
unstructuredStarted_ = true;
return *this;
}
Command& describe(std::string description) {
description_ = std::move(description);
return *this;
}
// Restricts the most-recently-declared parameter/option to a fixed
// set of named values -- this is how custom enums plug in (T is
// deduced from the initializer list, no need to spell it out again),
// and it doubles as a generic "choice" restriction for any type. T
// must match the type declared at parameter<T>()/option<T>().
template <typename T>
Command& choices(std::initializer_list<std::pair<std::string, T>> values,
std::string typeName = "") {
if (lastSlot_ == nullptr ||
(lastSlot_->kind != SlotKind::Parameter && lastSlot_->kind != SlotKind::Option)) {
throw RegistrationError(
"choices<>() must directly follow the parameter<T>() or "
"option<T>() it restricts");
}
if (!lastSlot_->cppType || *lastSlot_->cppType != std::type_index(typeid(T))) {
throw RegistrationError("choices<>() type doesn't match the type declared for '" +
lastSlot_->name + "'");
}
std::vector<std::pair<std::string, T>> table(values);
lastSlot_->type =
makeChoiceTypeInfo<T>(typeName.empty() ? std::string("enum") : std::move(typeName), table);
lastSlot_->awaitingChoices = false;
return *this;
}
// Binds this command to a free function (or captureless/capturing
// lambda, or std::function). Argument types are validated against
// what was already declared via parameter<T>()/option<T>()/flag() --
// action() never invents a type, it only checks consistency -- and
// matched, in order, to [parameters in declaration order] then
// [flags/options/loose-parameters in declaration order]. Arguments
// may be taken by value, by reference, or by const reference.
template <typename Func>
Command& action(Func f) {
using Traits = detail::function_traits<std::decay_t<Func>>;
if (structured_.empty() ||
std::none_of(structured_.begin(), structured_.end(), [](const Slot& s) {
return s.kind == SlotKind::Keyword;
})) {
throw RegistrationError(
"a command must declare at least one keyword() so it can "
"be identified on the command line");
}
for (const Slot& s : structured_) checkNotAwaitingChoices(s);
for (const Slot& s : unstructured_) checkNotAwaitingChoices(s);
std::vector<Slot*> ordered = orderedBindableSlots();
if (ordered.size() != Traits::arity) {
throw RegistrationError(
"action() function takes " + std::to_string(Traits::arity) +
" argument(s) but this command declared " +
std::to_string(ordered.size()) +
" parameter/flag/option slot(s) -- these must match 1:1, "
"in declaration order (parameters first, then flags/options)");
}
bindArgs(std::function{f}, ordered, std::make_index_sequence<Traits::arity>{});
sealed_ = true;
return *this;
}
bool sealed() const { return sealed_; }
const std::string& description() const { return description_; }
// --- matching / parsing entry points, used by Engine -------------
StructuredMatch tryMatchStructured(const std::vector<std::string>& tokens) const {
StructuredMatch m;
std::size_t i = 0;
try {
for (std::size_t s = 0; s < structured_.size(); ++s) {
const Slot& slot = structured_[s];
bool isLast = (s + 1 == structured_.size());
if (slot.kind == SlotKind::Keyword) {
if (i >= tokens.size() || tokens[i] != slot.name) return StructuredMatch{};
++i;
++m.keywordScore;
} else {
if (slot.variadic && isLast) {
std::vector<Scalar> collected;
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) {
collected.push_back(slot.type.parse(tokens[i], slot.name));
++i;
}
m.paramValues.push_back(Value::ofVector(std::move(collected)));
} else {
if (i >= tokens.size() || detail::looksLikeOptionStart(tokens[i])) {
// Every keyword up to here matched literally, so
// this is almost certainly the intended command
// -- just missing a required argument.
throw CliError(
"missing required argument '<" + slot.name +
">': expected a value of type " + slot.type.displayName,
ErrorKind::MissingArgument);
}
m.paramValues.push_back(Value::ofScalar(slot.type.parse(tokens[i], slot.name)));
++i;
}
}
}
} catch (const CliError& e) {
// Either a type mismatch (ParseError, a CliError) or the
// MissingArgument thrown just above -- both mean "this is the
// command you meant, but something about the value is wrong",
// so remember it instead of treating it as a plain non-match.
m.success = false;
m.error = e.what();
return m;
}
m.success = true;
m.consumed = i;
return m;
}
// Parses the (order-agnostic) flags/options/loose-parameters tail.
// Throws CliError (with a specific ErrorKind) describing exactly what
// went wrong: an unrecognized flag/option, a missing option value, a
// missing/extra positional argument, or a type mismatch.
std::vector<Value> parseUnstructured(const std::vector<std::string>& tokens) const {
std::vector<bool> provided(unstructured_.size(), false);
std::vector<std::vector<Scalar>> collected(unstructured_.size());
std::vector<std::string_view> looseTokens; // views into `tokens`, which outlives this call
looseTokens.reserve(tokens.size());
std::size_t i = 0;
while (i < tokens.size()) {
std::string_view tok = tokens[i];
if (!detail::looksLikeOptionStart(tok)) {
// Not flag/option syntax (includes negative numbers like
// "-5") -- it's a value for the next loose parameter.
looseTokens.push_back(tok);
++i;
continue;
}
if (tok[1] == '-') {
std::string_view rest = tok.substr(2);
auto eq = rest.find('=');
bool hasInline = (eq != std::string_view::npos);
std::string_view longName = hasInline ? rest.substr(0, eq) : rest;
std::string_view inlineValue = hasInline ? rest.substr(eq + 1) : std::string_view{};
int idx = findByLong(longName);
if (idx < 0) throwUnknown(tok);
++i;
consumeSlot(*this, idx, hasInline, inlineValue, tokens, i, provided, collected, tok);
} else {
std::string_view rest = tok.substr(1);
if (rest.size() == 1) {
int idx = findByShort(rest[0]);
if (idx < 0) throwUnknown(tok);
++i;
consumeSlot(*this, idx, false, std::string_view{}, tokens, i, provided, collected, tok);
} else {
// Combined short flags, e.g. "-ndi" == -n -d -i.
for (char c : rest) {
int idx = findByShort(c);
if (idx < 0) {
throw CliError(std::string("unknown flag '-") + c +
"' in combined group '" + std::string(tok) + "'" +
suggestFlagOption(std::string("-") + c),
ErrorKind::UnknownOption);
}
if (unstructured_[static_cast<size_t>(idx)].kind != SlotKind::Flag) {
throw CliError(
std::string("'-") + c + "' in combined group '" + std::string(tok) +
"' is an option (needs a value), so it can't "
"be combined with other short flags");
}
provided[static_cast<size_t>(idx)] = true;
}
++i;
}
}
}
// Assign the collected loose tokens to any declared loose
// Parameter slots, positionally, in declaration order -- this is
// independent of where those tokens fell relative to flags/options.
std::vector<std::size_t> looseParamIdx;
for (std::size_t idx = 0; idx < unstructured_.size(); ++idx) {
if (unstructured_[idx].kind == SlotKind::Parameter) looseParamIdx.push_back(idx);
}
std::vector<Value> looseValues(looseParamIdx.size());
std::size_t li = 0;
for (std::size_t k = 0; k < looseParamIdx.size(); ++k) {
const Slot& slot = unstructured_[looseParamIdx[k]];
bool isLastLoose = (k + 1 == looseParamIdx.size());
if (slot.variadic && isLastLoose) {
std::vector<Scalar> vals;
vals.reserve(looseTokens.size() > li ? looseTokens.size() - li : 0);
while (li < looseTokens.size()) {
vals.push_back(slot.type.parse(looseTokens[li], slot.name));
++li;
}
looseValues[k] = Value::ofVector(std::move(vals));
} else {
if (li >= looseTokens.size()) {
throw CliError("missing required argument '<" + slot.name +
">': expected a value of type " + slot.type.displayName,
ErrorKind::MissingArgument);
}
looseValues[k] = Value::ofScalar(slot.type.parse(looseTokens[li], slot.name));
++li;
}
}
if (li < looseTokens.size()) {
std::size_t extra = looseTokens.size() - li;
std::string msg = "too many arguments: unexpected '" + std::string(looseTokens[li]) + "'";
if (extra > 1) msg += " (and " + std::to_string(extra - 1) + " more)";
throw CliError(msg, ErrorKind::TooManyArguments);
}
// Assemble the final values in unstructured_'s declaration order --
// this is what gets paired with the bound function's arguments.
std::vector<Value> out;
out.reserve(unstructured_.size());
std::size_t looseCursor = 0;
for (std::size_t idx = 0; idx < unstructured_.size(); ++idx) {
const Slot& slot = unstructured_[idx];
if (slot.kind == SlotKind::Parameter) {
out.push_back(std::move(looseValues[looseCursor++]));
} else if (slot.kind == SlotKind::Flag) {
out.push_back(Value::ofScalar(Scalar{provided[idx]}));
} else if (slot.variadic) {
out.push_back(Value::ofVector(collected[idx]));
} else if (provided[idx]) {
out.push_back(Value::ofScalar(collected[idx][0]));
} else {
out.push_back(Value{}); // options are always optional -> unset means nullopt
}
}
return out;
}
void invoke(std::vector<Value> allValues) const { invoke_(allValues); }
std::size_t structuredSlotCount() const { return structured_.size(); }
// Lenient prefix check used only for --help / `help <words>` : does
// `tokens` look like the start of (or a complete instance of) this
// command, without requiring every required argument to actually be
// present? Keywords still must match exactly; parameter slots accept
// any token; running out of input mid-command is fine (that's exactly
// what makes it a "prefix"). Used to find *every* command consistent
// with what's been typed so far, so an ambiguous prefix can show help
// for all of them instead of guessing one.
bool couldMatchPrefix(const std::vector<std::string>& tokens) const {
std::size_t i = 0;
for (std::size_t s = 0; s < structured_.size(); ++s) {
if (i >= tokens.size()) break; // ran out of input -- a valid, incomplete prefix
const Slot& slot = structured_[s];
bool isLast = (s + 1 == structured_.size());
if (slot.kind == SlotKind::Keyword) {
if (tokens[i] != slot.name) return false;
++i;
} else if (slot.variadic && isLast) {
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) ++i;
} else {
if (detail::looksLikeOptionStart(tokens[i])) break; // structured part left incomplete
++i;
}
}
// Whatever tokens remain must be plausible flags/options (assumed
// fine without deep validation) or fit a declared loose parameter.
std::vector<const Slot*> looseParams;
for (const Slot& s : unstructured_) {
if (s.kind == SlotKind::Parameter) looseParams.push_back(&s);
}
std::size_t looseIdx = 0;
for (; i < tokens.size(); ++i) {
if (detail::looksLikeOptionStart(tokens[i])) continue;
if (looseIdx >= looseParams.size()) return false; // nothing left to absorb this token
if (!looseParams[looseIdx]->variadic) ++looseIdx;
}
return true;
}
// --- help / usage --------------------------------------------------
std::string usageLine() const {
std::ostringstream os;
for (std::size_t s = 0; s < structured_.size(); ++s) {
const Slot& slot = structured_[s];
if (s) os << ' ';
if (slot.kind == SlotKind::Keyword) {
os << slot.name;
} else {
os << '<' << slot.name << (slot.variadic ? "..." : "") << '>';
}
}
bool anyFlagOrOption = std::any_of(unstructured_.begin(), unstructured_.end(),
[](const Slot& s) { return s.kind != SlotKind::Parameter; });
if (anyFlagOrOption) os << " [OPTIONS]";
for (const Slot& slot : unstructured_) {
if (slot.kind != SlotKind::Parameter) continue;
os << " <" << slot.name << (slot.variadic ? "..." : "") << '>';
}
return os.str();
}
std::string helpText(const std::string& progName) const {
std::ostringstream os;
os << "Usage: " << progName << " " << usageLine() << "\n";
if (!description_.empty()) os << "\n" << description_ << "\n";
bool anyParams = std::any_of(structured_.begin(), structured_.end(),
[](const Slot& s) { return s.kind == SlotKind::Parameter; }) ||
std::any_of(unstructured_.begin(), unstructured_.end(),
[](const Slot& s) { return s.kind == SlotKind::Parameter; });
if (anyParams) {
os << "\nArguments:\n";
for (const Slot& slot : structured_) {
if (slot.kind != SlotKind::Parameter) continue;
std::string label = "<" + slot.name + (slot.variadic ? "..." : "") + ">";
os << " " << padRight(label, 22) << describeType(slot) << slot.description << "\n";
}
for (const Slot& slot : unstructured_) {
if (slot.kind != SlotKind::Parameter) continue;
std::string label = "<" + slot.name + (slot.variadic ? "..." : "") + ">";
os << " " << padRight(label, 22) << describeType(slot) << slot.description << "\n";
}
}
bool anyFlagOrOption = std::any_of(unstructured_.begin(), unstructured_.end(),
[](const Slot& s) { return s.kind != SlotKind::Parameter; });
os << "\nOptions:\n";
if (anyFlagOrOption) {
for (const Slot& slot : unstructured_) {
if (slot.kind == SlotKind::Parameter) continue;
std::string names = slot.shortName == '\0'
? (" --" + slot.name)
: (std::string("-") + slot.shortName + ", --" + slot.name);
if (slot.kind == SlotKind::Option) {
names += " <" + slot.type.displayName + (slot.variadic ? "...>" : ">");
}
os << " " << padRight(names, 28) << slot.description << "\n";
}
}
os << " " << padRight("-h, --help", 28) << "Show this help message\n";
return os.str();
}
std::vector<std::string> flagOptionNames() const {
std::vector<std::string> out;
for (const Slot& s : unstructured_) {
if (s.kind == SlotKind::Parameter) continue;
out.push_back("--" + s.name);
if (s.shortName != '\0') out.push_back(std::string("-") + s.shortName);
}
return out;
}
// Fuzzy structural distance used for "did you mean...?" suggestions.
struct FuzzyScore {
std::size_t distance = 0;
std::size_t keywordChars = 0; // denominator for a relative threshold
};
FuzzyScore fuzzyStructuralDistance(const std::vector<std::string>& tokens) const {
std::size_t i = 0;
FuzzyScore score;
for (std::size_t s = 0; s < structured_.size(); ++s) {
const Slot& slot = structured_[s];
bool isLast = (s + 1 == structured_.size());
if (slot.kind == SlotKind::Keyword) {
score.keywordChars += slot.name.size();
if (i >= tokens.size()) {
score.distance += slot.name.size(); // command is longer than what was typed
continue;
}
score.distance += detail::levenshtein(tokens[i], slot.name);
++i;
} else if (slot.variadic && isLast) {
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) ++i;
} else if (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) {
++i; // parameters are wildcards: any token "fits", no penalty
}
}
score.distance += (tokens.size() > i) ? (tokens.size() - i) : 0; // leftover tokens
return score;
}
private:
std::vector<Slot> structured_;
std::vector<Slot> unstructured_;
std::string description_;
std::function<void(std::vector<Value>&)> invoke_;
Slot* lastSlot_ = nullptr;
bool unstructuredStarted_ = false;
bool structuredSealed_ = false;
bool looseSealed_ = false;
bool sealed_ = false;
static std::string stripDashes(std::string s) {
while (!s.empty() && s.front() == '-') s.erase(s.begin());
return s;
}
void validateLongName(const std::string& s) const {
if (s.empty()) throw RegistrationError("a flag/option needs a non-empty long name");
if (s == "help") {
throw RegistrationError("'help' is reserved for the built-in --help flag");
}
for (char c : s) {
if (!(std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_')) {
throw RegistrationError("invalid long name '--" + s + "'");
}
}
}
void validateShortName(char c) const {
if (c == '\0') return; // no short form
if (!std::isalnum(static_cast<unsigned char>(c))) {
throw RegistrationError(std::string("short names must be a single letter/digit, got '-") +
c + "'");
}
if (c == 'h') throw RegistrationError("'-h' is reserved for the built-in --help flag");
}
void checkNameFree(const std::string& longName, char shortName) const {
for (const Slot& s : unstructured_) {
if (s.kind == SlotKind::Parameter) continue; // different namespace (bare, not "--name")
if (s.name == longName) {
throw RegistrationError("duplicate long name '--" + longName + "'");
}
if (shortName != '\0' && s.shortName == shortName) {
throw RegistrationError(std::string("duplicate short name '-") + shortName + "'");
}
}
}
static void checkNotAwaitingChoices(const Slot& s) {
if (s.awaitingChoices) {
throw RegistrationError("'" + s.name +
"' is a custom enum type and needs choices<T>(...) "
"called right after it is declared");
}
}
std::vector<Slot*> orderedBindableSlots() {
std::vector<Slot*> out;
for (Slot& s : structured_) {
if (s.kind == SlotKind::Parameter) out.push_back(&s);
}
for (Slot& s : unstructured_) out.push_back(&s);
return out;
}
// Validates (never invents) that a slot's declared type matches T.
template <typename T>
static void checkTypeMatches(const Slot& slot) {
if (!slot.cppType || *slot.cppType != std::type_index(typeid(T))) {
throw RegistrationError("'" + slot.name + "' was declared as " + slot.type.displayName +
" but action() expects " + detail::friendlyTypeName<T>());
}
}
// ArgT is the *raw* action() parameter type, e.g. `const std::string&`
// or `std::vector<int32_t>` -- validated after stripping cv/ref.
template <typename ArgT>
static void configureSlot(Slot& slot) {
using Stripped = std::remove_cvref_t<ArgT>;
if (slot.kind == SlotKind::Flag) {
if constexpr (!std::is_same_v<Stripped, bool>) {
throw RegistrationError("flag '--" + slot.name + "' must bind to bool in action()");
}
return;
}
if constexpr (detail::is_optional_v<Stripped>) {
using Inner = typename detail::is_optional<Stripped>::inner;
if (slot.kind == SlotKind::Parameter) {
throw RegistrationError("parameter '<" + slot.name +
">' can't bind to std::optional<> -- positional "
"parameters are always required");
}
if (slot.variadic) {
throw RegistrationError("option '--" + slot.name +
"' is variadic; bind it to std::vector<>, not std::optional<>");
}
checkTypeMatches<Inner>(slot);
} else if constexpr (detail::is_vector_v<Stripped>) {
using Inner = typename detail::is_vector<Stripped>::inner;
if (!slot.variadic) {
throw RegistrationError("'" + slot.name +
"' binds to std::vector<> but wasn't declared variadic=true");
}
checkTypeMatches<Inner>(slot);
} else {
if (slot.variadic) {
throw RegistrationError("'" + slot.name +
"' was declared variadic=true; bind it to std::vector<>");
}
if (slot.kind == SlotKind::Option) {
throw RegistrationError("option '--" + slot.name +
"' must bind to std::optional<T> (or std::vector<T> if "
"variadic) -- options are always optional, never required");
}
checkTypeMatches<Stripped>(slot);
}
}
template <typename ArgT>
static ArgT extractArg(const Value& v) {
if constexpr (std::is_same_v<ArgT, bool>) {
return v.as<bool>();
} else if constexpr (detail::is_optional_v<ArgT>) {
using Inner = typename detail::is_optional<ArgT>::inner;
return v.hasValue() ? std::optional<Inner>(v.as<Inner>()) : std::nullopt;
} else if constexpr (detail::is_vector_v<ArgT>) {
using Inner = typename detail::is_vector<ArgT>::inner;
return v.asVector<Inner>();
} else {
return v.as<ArgT>();
}
}
// Extracted values are materialized into a tuple of *value* types
// first, then handed to f via std::apply -- std::get on that (named,
// lvalue) tuple yields lvalues, which is what lets f's parameters be
// declared by value, by reference, or by const reference and all
// still bind correctly.
template <typename Ret, typename... Args, std::size_t... I>
void bindArgs(std::function<Ret(Args...)> f, std::vector<Slot*>& ordered,
std::index_sequence<I...>) {
(configureSlot<Args>(*ordered[I]), ...);
invoke_ = [f = std::move(f)](std::vector<Value>& values) {
std::tuple<std::remove_cvref_t<Args>...> extracted{
extractArg<std::remove_cvref_t<Args>>(values[I])...};
std::apply(f, extracted);
};
}
int findByLong(std::string_view name) const {
for (std::size_t i = 0; i < unstructured_.size(); ++i) {
if (unstructured_[i].kind != SlotKind::Parameter && unstructured_[i].name == name) {
return static_cast<int>(i);
}
}
return -1;
}
int findByShort(char c) const {
if (c == '\0') return -1;
for (std::size_t i = 0; i < unstructured_.size(); ++i) {
if (unstructured_[i].kind != SlotKind::Parameter && unstructured_[i].shortName == c) {
return static_cast<int>(i);
}
}
return -1;
}
std::string suggestFlagOption(const std::string& given) const {
auto names = flagOptionNames();
names.push_back("--help");
names.push_back("-h");
auto close = detail::closestMatches(given, names, 1);
if (close.empty()) return "";
return " -- did you mean '" + close.front() + "'?";
}
[[noreturn]] void throwUnknown(std::string_view tok) const {
throw CliError("unknown flag/option '" + std::string(tok) + "' for this command" +
suggestFlagOption(std::string(tok)),
ErrorKind::UnknownOption);
}
static std::string describeType(const Slot& slot) {
return "<" + slot.type.displayName + "> ";
}
static std::string padRight(const std::string& s, std::size_t width) {
if (s.size() >= width) return s + " ";
return s + std::string(width - s.size(), ' ');
}
static void consumeSlot(const Command& self, int idxIn, bool hasInline,
std::string_view inlineValue, const std::vector<std::string>& tokens,
std::size_t& i, std::vector<bool>& provided,
std::vector<std::vector<Scalar>>& collected, std::string_view tokForError) {
std::size_t idx = static_cast<std::size_t>(idxIn);
const Slot& slot = self.unstructured_[idx];
if (slot.kind == SlotKind::Flag) {
if (hasInline) throw CliError("flag '--" + slot.name + "' does not take a value");
provided[idx] = true;
return;
}
// Option
if (hasInline) {
collected[idx].push_back(slot.type.parse(inlineValue, slot.name));
provided[idx] = true;
if (slot.variadic) {
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) {
collected[idx].push_back(slot.type.parse(tokens[i], slot.name));
++i;
}
}
return;
}
if (slot.variadic) {
std::size_t before = i;
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) {
collected[idx].push_back(slot.type.parse(tokens[i], slot.name));
++i;
}
if (i == before) {
throw CliError("option '" + std::string(tokForError) + "' requires at least one value",
ErrorKind::MissingValue);
}
provided[idx] = true;
} else {
if (i >= tokens.size() || detail::looksLikeOptionStart(tokens[i])) {
throw CliError("option '" + std::string(tokForError) + "' requires a value",
ErrorKind::MissingValue);
}
collected[idx] = {slot.type.parse(tokens[i], slot.name)};
provided[idx] = true;
++i;
}
}
};
} // namespace cliforge
-262
View File
@@ -1,262 +0,0 @@
#pragma once
//
// engine.hpp
//
// Engine owns every registered Command and is the single entry point:
//
// Engine cli;
// cli.describe("A demo CLI");
// cli.command()....action(&fn);
// return cli.run(argc, argv);
//
#include <algorithm>
#include <iostream>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "command.hpp"
#include "levenshtein.hpp"
namespace cliforge {
class Engine {
public:
explicit Engine(std::string programName = "") : programName_(std::move(programName)) {}
Engine& describe(std::string description) {
description_ = std::move(description);
return *this;
}
// Registers a new, empty command and returns a reference to it so it
// can be built up with the fluent .keyword()/.parameter()/... API.
// The Engine owns it for the rest of the program's lifetime.
Command& command() {
commands_.push_back(std::make_unique<Command>());
return *commands_.back();
}
int run(int argc, char** argv) {
if (programName_.empty() && argc > 0) programName_ = basename(argv[0]);
for (auto& cmd : commands_) {
if (!cmd->sealed()) {
std::cerr << "cliforge BUG: a command was registered without calling .action() "
"on it -- every command must be bound to a function.\n";
return 2;
}
}
std::vector<std::string> tokens;
for (int i = 1; i < argc; ++i) tokens.emplace_back(argv[i]);
if (tokens.empty()) {
printGlobalHelp(std::cout);
return 0;
}
// Centralized help handling: `help [words...]`, or --help/-h
// appearing anywhere. Both funnel into the same prefix search, so
// an ambiguous prefix (e.g. "project --help" matching create/
// delete/info) shows help for every command it's consistent with,
// instead of silently picking one or matching nothing.
std::optional<std::vector<std::string>> helpQuery;
if (tokens[0] == "help") {
helpQuery = std::vector<std::string>(tokens.begin() + 1, tokens.end());
} else if (auto it = std::find_if(tokens.begin(), tokens.end(),
[](const std::string& t) { return t == "--help" || t == "-h"; });
it != tokens.end()) {
std::vector<std::string> q = tokens;
q.erase(q.begin() + (it - tokens.begin()));
helpQuery = std::move(q);
}
if (helpQuery) {
return showHelp(*helpQuery);
}
struct Candidate {
Command* cmd;
StructuredMatch match;
};
std::vector<Candidate> candidates;
std::string bestPartialError;
std::vector<Command*> bestPartialCmds; // every command tied for the best partial score
int bestPartialScore = -1;
for (auto& cmd : commands_) {
StructuredMatch m = cmd->tryMatchStructured(tokens);
if (m.success) {
candidates.push_back({cmd.get(), std::move(m)});
} else if (!m.error.empty()) {
if (m.keywordScore > bestPartialScore) {
bestPartialScore = m.keywordScore;
bestPartialError = m.error;
bestPartialCmds.clear();
bestPartialCmds.push_back(cmd.get());
} else if (m.keywordScore == bestPartialScore) {
// Genuinely tied -- e.g. "project" alone is an equally
// incomplete prefix of create/delete/info, all of which
// report the identical "missing <name>" error. Rather
// than silently picking whichever was registered first
// (which read as an arbitrary, misleading suggestion),
// remember all of them and show every option.
bestPartialCmds.push_back(cmd.get());
}
}
}
if (candidates.empty()) {
if (!bestPartialCmds.empty()) {
// Every keyword matched some command, just with a bad or
// missing argument value -- this is much more actionable
// than a generic "no command matches" + fuzzy suggestions.
std::cerr << "Error: " << bestPartialError << "\n";
if (bestPartialCmds.size() == 1) {
std::cerr << "Usage: " << programName_ << " " << bestPartialCmds.front()->usageLine()
<< "\n";
} else {
std::cerr << "'" << join(tokens) << "' matches " << bestPartialCmds.size()
<< " commands:\n";
for (auto* cmd : bestPartialCmds) {
std::string line = programName_ + " " + cmd->usageLine();
std::cerr << " " << padRight(line, 46) << firstLine(cmd->description()) << "\n";
}
}
return 1;
}
std::cerr << "Error: no command matches '" << join(tokens) << "'.\n";
suggestCommands(tokens, std::cerr);
return 1;
}
std::sort(candidates.begin(), candidates.end(), [](const Candidate& a, const Candidate& b) {
if (a.match.keywordScore != b.match.keywordScore)
return a.match.keywordScore > b.match.keywordScore;
return a.cmd->structuredSlotCount() > b.cmd->structuredSlotCount();
});
std::string firstError;
Command* firstErrorCmd = nullptr;
bool haveError = false;
for (auto& c : candidates) {
std::vector<std::string> remaining(tokens.begin() + static_cast<long>(c.match.consumed),
tokens.end());
try {
std::vector<Value> values = c.cmd->parseUnstructured(remaining);
std::vector<Value> all = c.match.paramValues;
for (auto& v : values) all.push_back(std::move(v));
c.cmd->invoke(std::move(all));
return 0;
} catch (const CliError& e) {
// Candidates are ranked best-first (most keyword matches
// first), so the *first* failure is the one most likely to
// be what the user actually meant -- keep that, not
// whichever candidate happens to fail last.
if (!haveError) {
firstError = e.what();
firstErrorCmd = c.cmd;
haveError = true;
}
}
}
std::cerr << "Error: " << firstError << "\n";
if (firstErrorCmd) std::cerr << "Usage: " << programName_ << " " << firstErrorCmd->usageLine() << "\n";
return 1;
}
private:
std::string programName_;
std::string description_;
std::vector<std::unique_ptr<Command>> commands_;
static std::string basename(const std::string& path) {
auto pos = path.find_last_of("/\\");
return pos == std::string::npos ? path : path.substr(pos + 1);
}
static std::string join(const std::vector<std::string>& tokens) {
std::string out;
for (std::size_t i = 0; i < tokens.size(); ++i) {
if (i) out += ' ';
out += tokens[i];
}
return out;
}
// Finds every command consistent with `queryTokens` as a prefix and
// shows its help -- one command's help if there's exactly one match,
// all of them (clearly separated) if the prefix is genuinely
// ambiguous, or the global command list plus a hint if there's none.
int showHelp(const std::vector<std::string>& queryTokens) const {
if (queryTokens.empty()) {
printGlobalHelp(std::cout);
return 0;
}
std::vector<Command*> matches;
for (auto& cmd : commands_) {
if (cmd->couldMatchPrefix(queryTokens)) matches.push_back(cmd.get());
}
if (matches.empty()) {
std::cout << "No command matches '" << join(queryTokens) << "'.\n\n";
printGlobalHelp(std::cout);
return 1;
}
if (matches.size() == 1) {
std::cout << matches.front()->helpText(programName_);
return 0;
}
std::cout << "'" << join(queryTokens) << "' matches " << matches.size()
<< " commands:\n\n";
for (std::size_t i = 0; i < matches.size(); ++i) {
if (i) std::cout << "\n";
std::cout << matches[i]->helpText(programName_);
}
return 0;
}
void printGlobalHelp(std::ostream& os) const {
os << "Usage: " << programName_ << " <command> [ARGUMENTS] [OPTIONS]\n";
if (!description_.empty()) os << "\n" << description_ << "\n";
os << "\nCommands:\n";
for (auto& cmd : commands_) {
std::string line = programName_ + " " + cmd->usageLine();
os << " " << padRight(line, 46) << firstLine(cmd->description()) << "\n";
}
os << "\nRun '" << programName_ << " help <command>' or add --help/-h to any command "
<< "for details.\n";
}
void suggestCommands(const std::vector<std::string>& tokens, std::ostream& os) const {
std::vector<std::pair<double, Command*>> scored;
for (auto& cmd : commands_) {
Command::FuzzyScore s = cmd->fuzzyStructuralDistance(tokens);
double ratio = s.keywordChars ? static_cast<double>(s.distance) / static_cast<double>(s.keywordChars)
: 1.0;
scored.emplace_back(ratio, cmd.get());
}
std::sort(scored.begin(), scored.end(),
[](auto& a, auto& b) { return a.first < b.first; });
std::vector<Command*> close;
for (auto& [ratio, cmd] : scored) {
if (close.size() >= 3) break;
if (ratio <= 0.5) close.push_back(cmd);
}
if (close.empty()) return;
os << "\nDid you mean:\n";
for (auto* cmd : close) os << " " << programName_ << " " << cmd->usageLine() << "\n";
}
static std::string firstLine(const std::string& s) {
auto pos = s.find('\n');
return pos == std::string::npos ? s : s.substr(0, pos);
}
static std::string padRight(const std::string& s, std::size_t width) {
if (s.size() >= width) return s + " ";
return s + std::string(width - s.size(), ' ');
}
};
} // namespace cliforge
-65
View File
@@ -1,65 +0,0 @@
#pragma once
//
// function_traits.hpp
//
// Deduces the return type and argument-type pack of "anything callable":
// plain free-function pointers, non-generic lambdas (captureless or
// capturing), and std::function instances. This is what lets
// Command::action() figure out, at compile time, exactly which C++ types
// it needs to convert parsed CLI tokens into -- without the user ever
// having to spell the types out twice.
//
#include <cstddef>
#include <functional>
#include <tuple>
namespace cliforge::detail {
template <typename T>
struct function_traits;
// Plain free function: void foo(std::string, int)
template <typename Ret, typename... Args>
struct function_traits<Ret (*)(Args...)> {
using return_type = Ret;
static constexpr std::size_t arity = sizeof...(Args);
template <std::size_t I>
using arg = std::tuple_element_t<I, std::tuple<Args...>>;
using args_tuple = std::tuple<Args...>;
};
// std::function<void(std::string, int)>
template <typename Ret, typename... Args>
struct function_traits<std::function<Ret(Args...)>> {
using return_type = Ret;
static constexpr std::size_t arity = sizeof...(Args);
template <std::size_t I>
using arg = std::tuple_element_t<I, std::tuple<Args...>>;
using args_tuple = std::tuple<Args...>;
};
// Pointer-to-member-function of operator(), const and non-const, used to
// peel apart lambdas (which are just anonymous functors under the hood).
template <typename C, typename Ret, typename... Args>
struct function_traits<Ret (C::*)(Args...) const> {
using return_type = Ret;
static constexpr std::size_t arity = sizeof...(Args);
template <std::size_t I>
using arg = std::tuple_element_t<I, std::tuple<Args...>>;
using args_tuple = std::tuple<Args...>;
};
template <typename C, typename Ret, typename... Args>
struct function_traits<Ret (C::*)(Args...)> {
using return_type = Ret;
static constexpr std::size_t arity = sizeof...(Args);
template <std::size_t I>
using arg = std::tuple_element_t<I, std::tuple<Args...>>;
using args_tuple = std::tuple<Args...>;
};
// Fallback: any other callable (lambda, functor) -- peel off operator().
template <typename F>
struct function_traits : function_traits<decltype(&F::operator())> {};
} // namespace cliforge::detail
-61
View File
@@ -1,61 +0,0 @@
#pragma once
//
// levenshtein.hpp
//
// Plain edit-distance + a small "closest matches" helper, used to power
// the "did you mean...?" suggestions for unknown commands, flags and
// options.
//
#include <algorithm>
#include <string>
#include <vector>
namespace cliforge::detail {
inline std::size_t levenshtein(const std::string& a, const std::string& b) {
const std::size_t n = a.size(), m = b.size();
std::vector<std::size_t> prev(m + 1), cur(m + 1);
for (std::size_t j = 0; j <= m; ++j) prev[j] = j;
for (std::size_t i = 1; i <= n; ++i) {
cur[0] = i;
for (std::size_t j = 1; j <= m; ++j) {
std::size_t cost = (a[i - 1] == b[j - 1]) ? 0 : 1;
cur[j] = std::min({prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost});
}
std::swap(prev, cur);
}
return prev[m];
}
// Returns up to `limit` candidates from `pool` that are close to `query`,
// ordered by distance. Anything farther than a generous relative
// threshold is dropped so we never suggest something wildly unrelated.
inline std::vector<std::string> closestMatches(const std::string& query,
const std::vector<std::string>& pool,
std::size_t limit = 3) {
std::vector<std::pair<std::size_t, std::string>> scored;
scored.reserve(pool.size());
for (const auto& candidate : pool) {
std::size_t d = levenshtein(query, candidate);
// A prefix relationship (e.g. "--arch" for "--architectures") is a
// very strong signal -- treat it as near-identical even though its
// raw edit distance is large, so common abbreviations still surface.
if (query.size() >= 2 && candidate.size() > query.size() &&
candidate.compare(0, query.size(), query) == 0) {
d = std::min<std::size_t>(d, 1);
}
scored.emplace_back(d, candidate);
}
std::sort(scored.begin(), scored.end(),
[](auto& a, auto& b) { return a.first < b.first; });
std::size_t threshold = std::max<std::size_t>(3, query.size() / 2);
std::vector<std::string> out;
for (auto& [dist, name] : scored) {
if (out.size() >= limit) break;
if (dist <= threshold) out.push_back(name);
}
return out;
}
} // namespace cliforge::detail
-62
View File
@@ -1,62 +0,0 @@
#pragma once
//
// slot.hpp
//
// A `Slot` is one piece of a command's signature: a literal keyword, a
// typed positional parameter, a boolean flag, or a typed option. The
// same struct is reused for all four so Command can keep them in plain
// vectors and the matcher/parser code doesn't need four parallel types.
//
#include <optional>
#include <string>
#include <typeindex>
#include "value.hpp"
namespace cliforge {
enum class SlotKind { Keyword, Parameter, Flag, Option };
struct Slot {
SlotKind kind{};
// Keyword: the literal text the user must type (e.g. "delete").
// Parameter: the display name (e.g. "name" -> shown as <name>).
// Flag/Option: the long name WITHOUT the leading "--" (e.g. "force").
std::string name;
// Flags/Options only: single-letter short name (e.g. 'f'), or '\0'
// if no short form was registered. A plain char instead of a string
// avoids a heap allocation (and the temptation to compare via
// std::string) for what is always exactly zero or one character.
char shortName = '\0';
std::string description;
// Parameter/Option only: may this slot consume more than one token?
// Only the *last* structured parameter, or the *last* loose
// parameter, or any option, may be variadic.
bool variadic = false;
// Set immediately by parameter<T>()/option<T>(), or by choices<T>(),
// never deferred -- so the type is always known and displayable even
// before action() is called.
TypeInfo type;
// The exact C++ type T declared via parameter<T>()/option<T>(), kept
// so action() can *validate* (not invent) the bound function's
// corresponding argument type. Unset for Keyword/Flag slots.
std::optional<std::type_index> cppType;
// True from parameter<T>()/option<T>() (when T is an enum) until the
// matching choices<T>() call supplies the name table; action() checks
// this is false for every slot before binding.
bool awaitingChoices = false;
std::string longFlag() const { return "--" + name; }
std::string shortFlag() const {
return shortName ? std::string("-") + shortName : std::string();
}
};
} // namespace cliforge
-353
View File
@@ -1,353 +0,0 @@
#pragma once
//
// value.hpp
//
// The type system. A `Scalar` is a closed variant over every primitive
// type the engine understands. A `Value` wraps either a single Scalar
// (for ordinary parameters/options) or a vector of them (for variadic
// parameters/options). `TypeInfo` bundles a display name with a parser:
// for the built-in scalar types this is a *plain stateless function
// pointer* (no heap allocation, unlike std::function), and only the
// rarer enum/choice-restricted case pays for a one-time, shared, heap
// allocation to hold its name table.
//
#include <algorithm>
#include <charconv>
#include <cstdint>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <type_traits>
#include <variant>
#include <vector>
namespace cliforge {
// ---------------------------------------------------------------------
// Exceptions
// ---------------------------------------------------------------------
// Every distinct reason a run-time parse can fail. Exposed on CliError so
// callers (and Engine internally, for its own error-selection logic) can
// branch on *why* something failed, not just read a free-text message.
enum class ErrorKind {
Generic,
UnknownOption, // a flag/option name isn't recognized for this command
TooManyArguments, // more positional values than the command declares
MissingArgument, // a required positional value (or option value) was never given
MissingValue, // an option/flag was named but its value token is missing
TypeMismatch, // a token couldn't convert to the slot's declared type
};
// Base of every error the engine raises while registering commands or
// parsing user input. Engine::run() catches CliError and prints it
// nicely instead of letting it escape as an unhandled exception.
class CliError : public std::runtime_error {
public:
explicit CliError(const std::string& msg, ErrorKind kind = ErrorKind::Generic)
: std::runtime_error(msg), kind_(kind) {}
ErrorKind kind() const noexcept { return kind_; }
private:
ErrorKind kind_;
};
// A token couldn't be converted to the type a slot expects -- always
// ErrorKind::TypeMismatch.
class ParseError : public CliError {
public:
explicit ParseError(const std::string& msg) : CliError(msg, ErrorKind::TypeMismatch) {}
};
// Something is wrong with how a command was *built* (mismatched arity
// between declared slots and the bound function, duplicate names, a
// choices<T>() call whose T doesn't match the declared type, an option
// bound to a non-optional type, an action() parameter type that doesn't
// match what was declared, etc). These fire at program startup (many of
// them at compile time via static_assert), not at end-user parse time.
struct RegistrationError : CliError {
explicit RegistrationError(const std::string& msg) : CliError(msg, ErrorKind::Generic) {}
};
// ---------------------------------------------------------------------
// Enum storage
// ---------------------------------------------------------------------
// Custom enums aren't a native Scalar alternative (there are infinitely
// many enum types), so when a slot is restricted to an enum via
// choices<T>(), the underlying value is boxed here. Value::as<T>() knows
// to unbox + static_cast back to the caller's real enum type.
struct EnumTag {
long long value;
};
// ---------------------------------------------------------------------
// Scalar: the closed set of representable primitive values
// ---------------------------------------------------------------------
using Scalar = std::variant<int8_t, uint8_t, int16_t, uint16_t, int32_t,
uint32_t, int64_t, uint64_t, float, double,
std::string, char, bool, EnumTag>;
enum class Kind {
I8, U8, I16, U16, I32, U32, I64, U64, F32, F64, Str, Char, Bool, Enum
};
// ---------------------------------------------------------------------
// TypeInfo: how to parse + describe one type
// ---------------------------------------------------------------------
// Holds the name table for an enum/choice-restricted slot. Built once at
// registration time (a single, amortized allocation via shared_ptr);
// parsing against it never allocates beyond the error path.
struct ChoiceTable {
std::vector<std::pair<std::string, Scalar>> entries;
Scalar parse(std::string_view token, std::string_view label) const {
for (const auto& [name, val] : entries) {
if (name == token) return val;
}
std::string allowed;
for (size_t i = 0; i < entries.size(); ++i) {
if (i) allowed += ", ";
allowed += entries[i].first;
}
throw ParseError("type mismatch for '" + std::string(label) + "': got '" +
std::string(token) + "', expected one of: " + allowed);
}
};
struct TypeInfo {
Kind kind{};
std::string displayName;
// Stateless parser for built-in scalar types -- a plain function
// pointer costs nothing to store or copy, unlike std::function.
Scalar (*parsePlain)(std::string_view token, std::string_view label) = nullptr;
// Populated only for choices<T>()-restricted slots.
std::shared_ptr<const ChoiceTable> choiceTable;
bool valid() const { return parsePlain != nullptr || choiceTable != nullptr; }
Scalar parse(std::string_view token, std::string_view label) const {
return choiceTable ? choiceTable->parse(token, label) : parsePlain(token, label);
}
};
namespace detail {
template <typename Int>
Int parseInteger(std::string_view token, std::string_view label, std::string_view typeName) {
Int out{};
auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), out);
if (ec != std::errc{} || ptr != token.data() + token.size()) {
throw ParseError("type mismatch for '" + std::string(label) + "': got '" +
std::string(token) + "', expected " + std::string(typeName));
}
return out;
}
template <typename Float>
Float parseFloating(std::string_view token, std::string_view label, std::string_view typeName) {
Float out{};
auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), out);
if (ec != std::errc{} || ptr != token.data() + token.size()) {
throw ParseError("type mismatch for '" + std::string(label) + "': got '" +
std::string(token) + "', expected " + std::string(typeName));
}
return out;
}
inline std::string toLower(std::string_view s) {
std::string out(s);
std::transform(out.begin(), out.end(), out.begin(),
[](unsigned char c) { return std::tolower(c); });
return out;
}
} // namespace detail
// Maps a C++ type T to its Kind + a ready-to-use TypeInfo. Specialized
// below for every built-in type; enums go through choices<T>() instead
// since they need a name<->value table supplied by the caller.
template <typename T>
struct TypeOf; // intentionally undefined for unsupported T
#define CLIFORGE_INT_TYPE(CPP_T, KIND, NAME) \
template <> \
struct TypeOf<CPP_T> { \
static TypeInfo get() { \
TypeInfo t; \
t.kind = Kind::KIND; \
t.displayName = NAME; \
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar { \
return Scalar{detail::parseInteger<CPP_T>(tok, lbl, NAME)}; \
}; \
return t; \
} \
};
CLIFORGE_INT_TYPE(int8_t, I8, "int8")
CLIFORGE_INT_TYPE(uint8_t, U8, "uint8")
CLIFORGE_INT_TYPE(int16_t, I16, "int16")
CLIFORGE_INT_TYPE(uint16_t, U16, "uint16")
CLIFORGE_INT_TYPE(int32_t, I32, "int32")
CLIFORGE_INT_TYPE(uint32_t, U32, "uint32")
CLIFORGE_INT_TYPE(int64_t, I64, "int64")
CLIFORGE_INT_TYPE(uint64_t, U64, "uint64")
#undef CLIFORGE_INT_TYPE
template <>
struct TypeOf<float> {
static TypeInfo get() {
TypeInfo t;
t.kind = Kind::F32;
t.displayName = "float32";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar {
return Scalar{detail::parseFloating<float>(tok, lbl, "float32")};
};
return t;
}
};
template <>
struct TypeOf<double> {
static TypeInfo get() {
TypeInfo t;
t.kind = Kind::F64;
t.displayName = "float64";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar {
return Scalar{detail::parseFloating<double>(tok, lbl, "float64")};
};
return t;
}
};
template <>
struct TypeOf<std::string> {
static TypeInfo get() {
TypeInfo t;
t.kind = Kind::Str;
t.displayName = "string";
t.parsePlain = [](std::string_view tok, std::string_view) -> Scalar {
return Scalar{std::string(tok)};
};
return t;
}
};
template <>
struct TypeOf<char> {
static TypeInfo get() {
TypeInfo t;
t.kind = Kind::Char;
t.displayName = "char";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar {
if (tok.size() != 1) {
throw ParseError("type mismatch for '" + std::string(lbl) + "': got '" +
std::string(tok) + "', expected a single char");
}
return Scalar{tok[0]};
};
return t;
}
};
template <>
struct TypeOf<bool> {
static TypeInfo get() {
TypeInfo t;
t.kind = Kind::Bool;
t.displayName = "bool";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar {
std::string lower = detail::toLower(tok);
if (lower == "true" || lower == "1" || lower == "yes" || lower == "on") {
return Scalar{true};
}
if (lower == "false" || lower == "0" || lower == "no" || lower == "off") {
return Scalar{false};
}
throw ParseError("type mismatch for '" + std::string(lbl) + "': got '" +
std::string(tok) + "', expected bool (true/false, 1/0, yes/no, on/off)");
};
return t;
}
};
// Builds a TypeInfo for a `choices<T>()` restricted slot: only the
// listed string keys are accepted, each mapped to a T. Works both for
// genuine C++ enums (boxed into EnumTag) and for restricting a built-in
// type (e.g. a string option limited to a fixed set of values). The
// choice table is converted to type-erased Scalars once, here, so
// nothing downstream needs to be templated on T again.
template <typename T>
TypeInfo makeChoiceTypeInfo(std::string typeName,
const std::vector<std::pair<std::string, T>>& choices) {
auto table = std::make_shared<ChoiceTable>();
table->entries.reserve(choices.size());
for (const auto& [name, val] : choices) {
if constexpr (std::is_enum_v<T>) {
table->entries.emplace_back(name, Scalar{EnumTag{static_cast<long long>(val)}});
} else {
table->entries.emplace_back(name, Scalar{val});
}
}
TypeInfo t;
t.kind = Kind::Enum;
t.displayName = std::move(typeName);
t.choiceTable = std::move(table);
return t;
}
// ---------------------------------------------------------------------
// Value: what actually flows out of the parser and into bound functions
// ---------------------------------------------------------------------
class Value {
public:
Value() = default;
static Value ofScalar(Scalar s) {
Value v;
v.data_ = std::move(s);
return v;
}
static Value ofVector(std::vector<Scalar> v) {
Value out;
out.data_ = std::move(v);
return out;
}
bool hasValue() const { return !std::holds_alternative<std::monostate>(data_); }
bool isVector() const { return std::holds_alternative<std::vector<Scalar>>(data_); }
template <typename T>
T as() const {
const Scalar& s = std::get<Scalar>(data_);
if constexpr (std::is_enum_v<T>) {
return static_cast<T>(std::get<EnumTag>(s).value);
} else {
return std::get<T>(s);
}
}
template <typename T>
std::vector<T> asVector() const {
const auto& vec = std::get<std::vector<Scalar>>(data_);
std::vector<T> out;
out.reserve(vec.size());
for (const Scalar& s : vec) {
if constexpr (std::is_enum_v<T>) {
out.push_back(static_cast<T>(std::get<EnumTag>(s).value));
} else {
out.push_back(std::get<T>(s));
}
}
return out;
}
private:
std::variant<std::monostate, Scalar, std::vector<Scalar>> data_;
};
} // namespace cliforge
+38
View File
@@ -0,0 +1,38 @@
@echo off
REM Configure (if needed) and build the project with Ninja.
REM
REM Usage (from the project root):
REM scripts\build.bat [BUILD_TYPE]
REM
REM BUILD_TYPE Debug^|Release^|RelWithDebInfo^|MinSizeRel (default: Release)
REM
REM Set BUILD_DIR to build somewhere other than out\build (see Configuration.cmake).
setlocal
cd /d "%~dp0.."
if "%BUILD_DIR%"=="" set "BUILD_DIR=out\build"
set "BUILD_TYPE=%~1"
if "%BUILD_TYPE%"=="" set "BUILD_TYPE=Release"
where cmake >nul 2>nul
if errorlevel 1 (
echo error: cmake not found on PATH.
exit /b 1
)
where ninja >nul 2>nul
if errorlevel 1 (
echo error: ninja not found on PATH.
exit /b 1
)
echo ==^> Configuring (%BUILD_TYPE%) into %BUILD_DIR%\
cmake -G Ninja -S . -B "%BUILD_DIR%" -DCMAKE_BUILD_TYPE=%BUILD_TYPE%
if errorlevel 1 exit /b 1
echo ==^> Building
cmake --build "%BUILD_DIR%"
if errorlevel 1 exit /b 1
echo ==^> Done. Run a module with: scripts\run.bat ^<target-name^>
exit /b 0
+26 -4
View File
@@ -1,5 +1,27 @@
mkdir -p build
cd build
#!/usr/bin/env bash
# Configure (if needed) and build the project with Ninja.
#
# Usage (from the project root):
# scripts/build.sh [BUILD_TYPE]
#
# BUILD_TYPE Debug|Release|RelWithDebInfo|MinSizeRel (default: Release)
#
# Set BUILD_DIR to build somewhere other than out/build (see Configuration.cmake).
set -euo pipefail
cmake ..
cmake --build .
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/.."
BUILD_DIR="${BUILD_DIR:-out/build}"
BUILD_TYPE="${1:-Release}"
command -v cmake >/dev/null 2>&1 || { echo "error: cmake not found on PATH." >&2; exit 1; }
command -v ninja >/dev/null 2>&1 || { echo "error: ninja not found on PATH." >&2; exit 1; }
echo "==> Configuring (${BUILD_TYPE}) into ${BUILD_DIR}/"
cmake -G Ninja -S . -B "${BUILD_DIR}" -DCMAKE_BUILD_TYPE="${BUILD_TYPE}"
echo "==> Building"
cmake --build "${BUILD_DIR}"
echo "==> Done. Run a module with: scripts/run.sh <target-name>"
+36
View File
@@ -0,0 +1,36 @@
@echo off
REM Build Doxygen documentation for every module (see cmake\Documentation.cmake)
REM and print the path to the landing page. A no-op with a clear message if
REM Doxygen isn't installed or ENABLE_DOXYGEN=OFF.
REM
REM Output goes to out\docs\ (DOCS_OUTPUT_DIR in Configuration.cmake) --
REM note this is a sibling of out\build, not nested inside it. If you've
REM overridden DOCS_OUTPUT_DIR at configure time, also set DOCS_DIR here to
REM match so the printed path is correct (docs still land in the right
REM place either way -- this only affects the message below).
REM
REM Usage (from the project root):
REM scripts\docs.bat REM every module
REM scripts\docs.bat core_lib REM just one module's docs (docs_<target>)
setlocal
cd /d "%~dp0.."
if "%BUILD_DIR%"=="" set "BUILD_DIR=out\build"
if "%DOCS_DIR%"=="" set "DOCS_DIR=out\docs"
if not exist "%BUILD_DIR%" (
echo error: "%BUILD_DIR%" not found. Run scripts\build.bat first.
exit /b 1
)
if "%~1"=="" (
cmake --build "%BUILD_DIR%" --target docs
if exist "%DOCS_DIR%\index.html" (
echo ==^> Open: %DOCS_DIR%\index.html
)
) else (
cmake --build "%BUILD_DIR%" --target docs_%1
echo ==^> Open: %DOCS_DIR%\%1\html\index.html
)
exit /b %ERRORLEVEL%
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Build Doxygen documentation for every module (see cmake/Documentation.cmake)
# and print the path to the landing page. A no-op with a clear message if
# Doxygen isn't installed or ENABLE_DOXYGEN=OFF.
#
# Output goes to out/docs/ (DOCS_OUTPUT_DIR in Configuration.cmake) --
# note this is a sibling of out/build, not nested inside it. If you've
# overridden DOCS_OUTPUT_DIR at configure time, also set DOCS_DIR here to
# match so the printed path is correct (docs still land in the right
# place either way -- this only affects the message below).
#
# Usage (from the project root):
# scripts/docs.sh # every module
# scripts/docs.sh core_lib # just one module's docs (docs_<target>)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/.."
BUILD_DIR="${BUILD_DIR:-out/build}"
DOCS_DIR="${DOCS_DIR:-out/docs}"
if [ ! -d "$BUILD_DIR" ]; then
echo "error: '${BUILD_DIR}' not found. Run scripts/build.sh first." >&2
exit 1
fi
if [ $# -ge 1 ]; then
TARGET="docs_$1"
cmake --build "${BUILD_DIR}" --target "${TARGET}"
echo "==> Open: ${DOCS_DIR}/$1/html/index.html"
else
cmake --build "${BUILD_DIR}" --target docs
if [ -f "${DOCS_DIR}/index.html" ]; then
echo "==> Open: ${DOCS_DIR}/index.html"
fi
fi
+30
View File
@@ -0,0 +1,30 @@
@echo off
REM Install this project's own build artifacts to
REM out\install\<platform>-<arch>-<compiler>\ (INSTALL_OUTPUT_DIR in
REM Configuration.cmake; see CMakeLists.txt's install() DESTINATION paths).
REM
REM Always passes --component to `cmake --install`. Without it, any
REM vendored/fetched dependency's own install() rules (see
REM cmake\Dependencies.cmake) would also run, typically dumping files under
REM CMAKE_INSTALL_PREFIX -- COMPONENT is what keeps `cmake --install`
REM scoped to just this project's own targets.
REM
REM Usage (from the project root):
REM scripts\install.bat [BUILD_TYPE]
setlocal
cd /d "%~dp0.."
if "%BUILD_DIR%"=="" set "BUILD_DIR=out\build"
set "BUILD_TYPE=%~1"
if "%BUILD_TYPE%"=="" set "BUILD_TYPE=Release"
set "COMPONENT=demo"
if not exist "%BUILD_DIR%" (
echo error: "%BUILD_DIR%" not found. Run scripts\build.bat first.
exit /b 1
)
echo ==^> Installing (component: %COMPONENT%)
cmake --install "%BUILD_DIR%" --config %BUILD_TYPE% --component %COMPONENT%
exit /b %ERRORLEVEL%
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Install this project's own build artifacts to
# out/install/<platform>-<arch>-<compiler>/ (INSTALL_OUTPUT_DIR in
# Configuration.cmake; see CMakeLists.txt's install() DESTINATION paths).
#
# Always passes --component to `cmake --install`. Without it, any
# vendored/fetched dependency's own install() rules (see
# cmake/Dependencies.cmake) would also run, typically dumping files under
# CMAKE_INSTALL_PREFIX (e.g. /usr/local) -- COMPONENT is what keeps
# `cmake --install` scoped to just this project's own targets.
#
# Usage (from the project root):
# scripts/install.sh [BUILD_TYPE]
#
# BUILD_TYPE Debug|Release|RelWithDebInfo|MinSizeRel (default: Release) --
# only relevant for a Ninja Multi-Config build.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/.."
BUILD_DIR="${BUILD_DIR:-out/build}"
BUILD_TYPE="${1:-Release}"
COMPONENT="demo" # must match PROJECT_CODE_NAME in CMakeLists.txt
if [ ! -d "$BUILD_DIR" ]; then
echo "error: '${BUILD_DIR}' not found. Run scripts/build.sh first." >&2
exit 1
fi
echo "==> Installing (component: ${COMPONENT})"
cmake --install "${BUILD_DIR}" --config "${BUILD_TYPE}" --component "${COMPONENT}"
+80
View File
@@ -0,0 +1,80 @@
@echo off
REM Run an executable module by its CMake target name (as registered in
REM that module's own CMakeLists.txt), resolved via the manifest that
REM _write_module_manifest() generates at configure time -- so this script
REM never needs updating when modules are added, removed, or renamed.
REM
REM Usage (from the project root):
REM scripts\run.bat <target-name> [-- program-args...]
REM
REM Examples:
REM scripts\run.bat hello_exe
REM scripts\run.bat farewell_exe -- Ale
setlocal enabledelayedexpansion
cd /d "%~dp0.."
if "%BUILD_DIR%"=="" set "BUILD_DIR=out\build"
if "%~1"=="" goto usage
set "TARGET_NAME=%~1"
shift
REM Collect any remaining args to pass through to the program (skipping a
REM leading "--" separator if present).
set "PASSTHROUGH="
set "skip_next_dashes=1"
:collect
if "%~1"=="" goto collected
if "!skip_next_dashes!"=="1" if "%~1"=="--" (
set "skip_next_dashes=0"
shift
goto collect
)
set "skip_next_dashes=0"
set "PASSTHROUGH=!PASSTHROUGH! "%~1""
shift
goto collect
:collected
set "MANIFEST="
for %%F in ("%BUILD_DIR%\module_manifest-*.txt") do (
if not defined MANIFEST set "MANIFEST=%%F"
)
if not defined MANIFEST (
echo error: no build manifest found in "%BUILD_DIR%". Run scripts\build.bat first. 1>&2
exit /b 1
)
set "BIN_PATH="
for /f "usebackq tokens=1,* delims==" %%K in ("%MANIFEST%") do (
if "%%K"=="%TARGET_NAME%" set "BIN_PATH=%%L"
)
if not defined BIN_PATH (
echo error: no executable target named "%TARGET_NAME%". 1>&2
echo Available targets: 1>&2
for /f "usebackq tokens=1 delims==" %%T in ("%MANIFEST%") do echo %%T 1>&2
exit /b 1
)
if not exist "%BIN_PATH%" (
echo error: "%BIN_PATH%" was not found. Rebuild with scripts\build.bat. 1>&2
exit /b 1
)
"%BIN_PATH%" !PASSTHROUGH!
exit /b %ERRORLEVEL%
:usage
echo Usage: %~nx0 ^<cmake-target-name^> [-- program-args...] 1>&2
echo. 1>&2
echo Available executable targets: 1>&2
set "found=0"
for %%F in ("%BUILD_DIR%\module_manifest-*.txt") do (
set "found=1"
for /f "usebackq tokens=1 delims==" %%T in ("%%F") do echo %%T 1>&2
)
if "!found!"=="0" echo (none found -- did you run scripts\build.bat?) 1>&2
exit /b 1
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Run an executable module by its CMake target name (as registered in
# that module's own CMakeLists.txt), resolved via the manifest that
# _write_module_manifest() generates at configure time -- so this script
# never needs updating when modules are added, removed, or renamed.
#
# Usage (from the project root):
# scripts/run.sh <target-name> [-- program-args...]
#
# Examples:
# scripts/run.sh hello_exe
# scripts/run.sh farewell_exe -- Ale
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/.."
BUILD_DIR="${BUILD_DIR:-out/build}"
list_targets() {
local manifest
for manifest in "${BUILD_DIR}"/module_manifest-*.txt; do
[ -e "$manifest" ] || continue
cut -d= -f1 "$manifest"
done | sort -u
}
usage() {
echo "Usage: $0 <cmake-target-name> [-- program-args...]" >&2
echo >&2
echo "Available executable targets:" >&2
local targets
targets="$(list_targets)"
if [ -n "$targets" ]; then
echo "$targets" | sed 's/^/ /' >&2
else
echo " (none found -- did you run scripts/build.sh?)" >&2
fi
exit 1
}
[ $# -ge 1 ] || usage
TARGET_NAME="$1"
shift
if [ "${1:-}" = "--" ]; then
shift
fi
MANIFEST=""
for m in "${BUILD_DIR}"/module_manifest-*.txt; do
[ -e "$m" ] || continue
MANIFEST="$m"
break
done
if [ -z "$MANIFEST" ]; then
echo "error: no build manifest found in '${BUILD_DIR}'. Run scripts/build.sh first." >&2
exit 1
fi
BIN_PATH="$(grep "^${TARGET_NAME}=" "$MANIFEST" | head -n1 | cut -d= -f2- || true)"
if [ -z "$BIN_PATH" ]; then
echo "error: no executable target named '${TARGET_NAME}'." >&2
echo "Available targets:" >&2
list_targets | sed 's/^/ /' >&2
exit 1
fi
if [ ! -x "$BIN_PATH" ]; then
echo "error: '${BIN_PATH}' was not found or is not executable. Rebuild with scripts/build.sh." >&2
exit 1
fi
exec "$BIN_PATH" "$@"
+1
View File
@@ -0,0 +1 @@
add_lib_module(cliforge OUTPUT_NAME "cliforge")
@@ -0,0 +1,8 @@
#pragma once
#include "Command.hpp"
#include "Engine.hpp"
#include "FunctionTraits.hpp"
#include "Levenshtein.hpp"
#include "Slot.hpp"
#include "Value.hpp"
@@ -0,0 +1,473 @@
#pragma once
// Command is the fluent builder AND the parser for one CLI command. A
// command is built like:
//
// engine.command()
// .keyword("project")
// .parameter<std::string>("name", "Project name")
// .keyword("delete")
// .flag("force", 'f', "Skip the confirmation prompt")
// .option<std::string>("reason", 'r', "Why it's being deleted")
// .describe("Delete a project by name")
// .action(&deleteProject);
//
// Every parameter/option is explicitly typed where it's declared, so the
// engine never has to invent a type from the bound function -- `.action()`
// instead *validates* that the function's corresponding argument is a
// consistent binding (bool for a flag; T, std::optional<T>, or
// std::vector<T> for a parameter/option; by value, by reference, or by
// const reference all work). Mismatches are RegistrationErrors caught at
// startup, not silent surprises at run time.
#include <algorithm>
#include <cctype>
#include <functional>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <tuple>
#include <type_traits>
#include <typeindex>
#include <vector>
#include "FunctionTraits.hpp"
#include "Levenshtein.hpp"
#include "Slot.hpp"
#include "Value.hpp"
namespace cliforge
{
namespace detail
{
// --- small type-trait helpers used only for action() binding ----------
template <typename T>
struct is_optional : std::false_type {};
template <typename T>
struct is_optional<std::optional<T>> : std::true_type {
using inner = T;
};
template <typename T>
inline constexpr bool is_optional_v = is_optional<T>::value;
template <typename T>
struct is_vector : std::false_type {};
template <typename T>
struct is_vector<std::vector<T>> : std::true_type {
using inner = T;
};
template <typename T>
inline constexpr bool is_vector_v = is_vector<T>::value;
template <typename T>
inline constexpr bool is_builtin_scalar_v =
std::is_same_v<T, int8_t> || std::is_same_v<T, uint8_t> ||
std::is_same_v<T, int16_t> || std::is_same_v<T, uint16_t> ||
std::is_same_v<T, int32_t> || std::is_same_v<T, uint32_t> ||
std::is_same_v<T, int64_t> || std::is_same_v<T, uint64_t> ||
std::is_same_v<T, float> || std::is_same_v<T, double> ||
std::is_same_v<T, std::string> || std::is_same_v<T, char> ||
std::is_same_v<T, bool>;
// A token is where the unstructured (flags/options) part begins if it
// starts with a dash and isn't shaped like a negative number -- that way
// `build -5 -3 -1` (variadic int parameters) still works even though the
// tokens start with '-'.
bool looksLikeOptionStart(std::string_view tok);
bool isValidKeyword(std::string_view s);
// A human-readable name for a bound action() argument type, used in
// RegistrationError messages. Only built-in scalars and enums are ever
// legal here (anything else is already rejected by parameter<T>()'s own
// static_assert), so this never needs to handle the general case.
template <typename T>
std::string friendlyTypeName()
{
if constexpr (is_builtin_scalar_v<T>)
{
return TypeOf<T>::get().displayName;
}
else if constexpr (std::is_enum_v<T>)
{
return "a different enum type";
}
else
{
return "an unsupported type";
}
}
}
// Result of trying to line a command's structured (keyword+parameter)
// signature up against the front of the user's token list.
struct StructuredMatch
{
bool success = false;
std::size_t consumed = 0; // tokens belonging to the structured part
int keywordScore = 0; // number of literal keywords matched
std::vector<Value> paramValues;
// Set when every literal keyword matched but a parameter's value
// either failed to parse or was never given -- lets the Engine
// surface this specific, actionable error instead of a generic
// "no match" + fuzzy suggestions.
std::string error;
};
class Command
{
public:
// Keywords carry no description of their own -- .describe() on the
// command is what shows up in help; a keyword is purely a literal
// token to match.
Command& keyword(std::string literal);
// T is explicit and required: every parameter is typed at the point
// it's declared, not inferred later from action(). Called before any
// flag()/option(), this is a fixed-position structured parameter;
// called after, it's a "loose" parameter matched positionally among
// other loose parameters regardless of where flags/options fall
// around it.
template <typename T>
Command& parameter(std::string name, std::string description, bool variadic = false)
{
if (name.empty())
{
throw RegistrationError("parameter name may not be empty");
}
Slot s;
s.kind = SlotKind::Parameter;
s.name = std::move(name);
s.description = std::move(description);
s.variadic = variadic;
s.cppType = std::type_index(typeid(T));
if constexpr (std::is_enum_v<T>)
{
s.awaitingChoices = true; // choices<T>(...) must follow immediately
}
else
{
static_assert(detail::is_builtin_scalar_v<T>,
"cliforge: unsupported parameter type -- use a fixed-width int, "
"float/double, std::string, char, bool, or an enum (with "
"choices<T>() called immediately after)");
s.type = TypeOf<T>::get();
}
if (!unstructuredStarted_)
{
if (structuredSealed_)
{
throw RegistrationError(
"can't declare parameter('" + s.name +
"') after a variadic parameter -- a variadic parameter "
"must be the last thing in the structured part");
}
structured_.push_back(std::move(s));
lastSlot_ = &structured_.back();
if (variadic)
structuredSealed_ = true;
}
else
{
if (looseSealed_)
{
throw RegistrationError(
"can't declare parameter('" + s.name +
"') after a variadic parameter -- a variadic parameter "
"must be the last parameter declared");
}
unstructured_.push_back(std::move(s));
lastSlot_ = &unstructured_.back();
if (variadic) looseSealed_ = true;
}
return *this;
}
// shortName is 0 (the default) for "no short form".
Command& flag(std::string longName, char shortName, std::string description);
// T is explicit and required, same as parameter<T>(). Options are
// always optional in the calling convention (see action()) -- there
// is no way to declare a "required" option, by design.
template <typename T>
Command& option(std::string longName, char shortName, std::string description, bool variadic = false)
{
longName = stripDashes(std::move(longName));
validateLongName(longName);
validateShortName(shortName);
checkNameFree(longName, shortName);
Slot s;
s.kind = SlotKind::Option;
s.name = std::move(longName);
s.shortName = shortName;
s.description = std::move(description);
s.variadic = variadic;
s.cppType = std::type_index(typeid(T));
if constexpr (std::is_enum_v<T>)
{
s.awaitingChoices = true;
}
else
{
static_assert(detail::is_builtin_scalar_v<T>,
"cliforge: unsupported option type -- use a fixed-width int, "
"float/double, std::string, char, bool, or an enum (with "
"choices<T>() called immediately after)"
);
s.type = TypeOf<T>::get();
}
unstructured_.push_back(std::move(s));
lastSlot_ = &unstructured_.back();
unstructuredStarted_ = true;
return *this;
}
Command& describe(std::string description);
// Restricts the most-recently-declared parameter/option to a fixed
// set of named values -- this is how custom enums plug in (T is
// deduced from the initializer list, no need to spell it out again),
// and it doubles as a generic "choice" restriction for any type. T
// must match the type declared at parameter<T>()/option<T>().
template <typename T>
Command& choices(std::initializer_list<std::pair<std::string, T>> values, std::string typeName = "") {
if (lastSlot_ == nullptr || (lastSlot_->kind != SlotKind::Parameter && lastSlot_->kind != SlotKind::Option))
{
throw RegistrationError(
"choices<>() must directly follow the parameter<T>() or "
"option<T>() it restricts"
);
}
if (!lastSlot_->cppType || *lastSlot_->cppType != std::type_index(typeid(T)))
{
throw RegistrationError("choices<>() type doesn't match the type declared for '" +
lastSlot_->name + "'"
);
}
std::vector<std::pair<std::string, T>> table(values);
lastSlot_->type = makeChoiceTypeInfo<T>(typeName.empty() ? std::string("enum") : std::move(typeName), table);
lastSlot_->awaitingChoices = false;
return *this;
}
// Binds this command to a free function (or captureless/capturing
// lambda, or std::function). Argument types are validated against
// what was already declared via parameter<T>()/option<T>()/flag() --
// action() never invents a type, it only checks consistency -- and
// matched, in order, to [parameters in declaration order] then
// [flags/options/loose-parameters in declaration order]. Arguments
// may be taken by value, by reference, or by const reference.
template <typename Func>
Command& action(Func f)
{
using Traits = detail::function_traits<std::decay_t<Func>>;
if (structured_.empty() || std::none_of(structured_.begin(), structured_.end(), [](const Slot& s)
{
return s.kind == SlotKind::Keyword;
}))
{
throw RegistrationError(
"a command must declare at least one keyword() so it can "
"be identified on the command line");
}
for (const Slot& s : structured_) checkNotAwaitingChoices(s);
for (const Slot& s : unstructured_) checkNotAwaitingChoices(s);
std::vector<Slot*> ordered = orderedBindableSlots();
if (ordered.size() != Traits::arity)
{
throw RegistrationError(
"action() function takes " + std::to_string(Traits::arity) +
" argument(s) but this command declared " +
std::to_string(ordered.size()) +
" parameter/flag/option slot(s) -- these must match 1:1, "
"in declaration order (parameters first, then flags/options)");
}
bindArgs(std::function{f}, ordered, std::make_index_sequence<Traits::arity>{});
sealed_ = true;
return *this;
}
bool sealed() const;
const std::string& description() const;
// --- matching / parsing entry points, used by Engine -------------
StructuredMatch tryMatchStructured(const std::vector<std::string>& tokens) const;
// Parses the (order-agnostic) flags/options/loose-parameters tail.
// Throws CliError (with a specific ErrorKind) describing exactly what
// went wrong: an unrecognized flag/option, a missing option value, a
// missing/extra positional argument, or a type mismatch.
std::vector<Value> parseUnstructured(const std::vector<std::string>& tokens) const;
void invoke(std::vector<Value> allValues) const;
std::size_t structuredSlotCount() const;
// Lenient prefix check used only for --help / `help <words>` : does
// `tokens` look like the start of (or a complete instance of) this
// command, without requiring every required argument to actually be
// present? Keywords still must match exactly; parameter slots accept
// any token; running out of input mid-command is fine (that's exactly
// what makes it a "prefix"). Used to find *every* command consistent
// with what's been typed so far, so an ambiguous prefix can show help
// for all of them instead of guessing one.
bool couldMatchPrefix(const std::vector<std::string>& tokens) const;
// --- help / usage --------------------------------------------------
std::string usageLine() const;
std::string helpText(const std::string& progName) const;
std::vector<std::string> flagOptionNames() const;
// Fuzzy structural distance used for "did you mean...?" suggestions.
struct FuzzyScore {
std::size_t distance = 0;
std::size_t keywordChars = 0; // denominator for a relative threshold
};
FuzzyScore fuzzyStructuralDistance(const std::vector<std::string>& tokens) const;
private:
std::vector<Slot> structured_;
std::vector<Slot> unstructured_;
std::string description_;
std::function<void(std::vector<Value>&)> invoke_;
Slot* lastSlot_ = nullptr;
bool unstructuredStarted_ = false;
bool structuredSealed_ = false;
bool looseSealed_ = false;
bool sealed_ = false;
static std::string stripDashes(std::string s);
void validateLongName(const std::string& s) const;
void validateShortName(char c) const;
void checkNameFree(const std::string& longName, char shortName) const;
static void checkNotAwaitingChoices(const Slot& s);
std::vector<Slot*> orderedBindableSlots();
// Validates (never invents) that a slot's declared type matches T.
template <typename T>
static void checkTypeMatches(const Slot& slot) {
if (!slot.cppType || *slot.cppType != std::type_index(typeid(T))) {
throw RegistrationError("'" + slot.name + "' was declared as " + slot.type.displayName +
" but action() expects " + detail::friendlyTypeName<T>());
}
}
// ArgT is the *raw* action() parameter type, e.g. `const std::string&`
// or `std::vector<int32_t>` -- validated after stripping cv/ref.
template <typename ArgT>
static void configureSlot(Slot& slot) {
using Stripped = std::remove_cvref_t<ArgT>;
if (slot.kind == SlotKind::Flag) {
if constexpr (!std::is_same_v<Stripped, bool>) {
throw RegistrationError("flag '--" + slot.name + "' must bind to bool in action()");
}
return;
}
if constexpr (detail::is_optional_v<Stripped>) {
using Inner = typename detail::is_optional<Stripped>::inner;
if (slot.kind == SlotKind::Parameter) {
throw RegistrationError("parameter '<" + slot.name +
">' can't bind to std::optional<> -- positional "
"parameters are always required");
}
if (slot.variadic) {
throw RegistrationError("option '--" + slot.name +
"' is variadic; bind it to std::vector<>, not std::optional<>");
}
checkTypeMatches<Inner>(slot);
} else if constexpr (detail::is_vector_v<Stripped>) {
using Inner = typename detail::is_vector<Stripped>::inner;
if (!slot.variadic) {
throw RegistrationError("'" + slot.name +
"' binds to std::vector<> but wasn't declared variadic=true");
}
checkTypeMatches<Inner>(slot);
} else {
if (slot.variadic) {
throw RegistrationError("'" + slot.name +
"' was declared variadic=true; bind it to std::vector<>");
}
if (slot.kind == SlotKind::Option) {
throw RegistrationError("option '--" + slot.name +
"' must bind to std::optional<T> (or std::vector<T> if "
"variadic) -- options are always optional, never required");
}
checkTypeMatches<Stripped>(slot);
}
}
template <typename ArgT>
static ArgT extractArg(const Value& v) {
if constexpr (std::is_same_v<ArgT, bool>) {
return v.as<bool>();
} else if constexpr (detail::is_optional_v<ArgT>) {
using Inner = typename detail::is_optional<ArgT>::inner;
return v.hasValue() ? std::optional<Inner>(v.as<Inner>()) : std::nullopt;
} else if constexpr (detail::is_vector_v<ArgT>) {
using Inner = typename detail::is_vector<ArgT>::inner;
return v.asVector<Inner>();
} else {
return v.as<ArgT>();
}
}
// Extracted values are materialized into a tuple of *value* types
// first, then handed to f via std::apply -- std::get on that (named,
// lvalue) tuple yields lvalues, which is what lets f's parameters be
// declared by value, by reference, or by const reference and all
// still bind correctly.
template <typename Ret, typename... Args, std::size_t... I>
void bindArgs(std::function<Ret(Args...)> f, std::vector<Slot*>& ordered,
std::index_sequence<I...>) {
(configureSlot<Args>(*ordered[I]), ...);
invoke_ = [f = std::move(f)](std::vector<Value>& values) {
std::tuple<std::remove_cvref_t<Args>...> extracted{
extractArg<std::remove_cvref_t<Args>>(values[I])...};
std::apply(f, extracted);
};
}
int findByLong(std::string_view name) const;
int findByShort(char c) const;
std::string suggestFlagOption(const std::string& given) const;
[[noreturn]] void throwUnknown(std::string_view tok) const;
static std::string describeType(const Slot& slot);
static std::string padRight(const std::string& s, std::size_t width);
static void consumeSlot(const Command& self, int idxIn, bool hasInline,
std::string_view inlineValue,
const std::vector<std::string>& tokens, std::size_t& i,
std::vector<bool>& provided,
std::vector<std::vector<Scalar>>& collected,
std::string_view tokForError);
};
}
@@ -0,0 +1,56 @@
#pragma once
// Engine owns every registered Command and is the single entry point:
//
// Engine cli;
// cli.describe("A demo CLI");
// cli.command()....action(&fn);
// return cli.run(argc, argv);
#include <algorithm>
#include <iostream>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "Command.hpp"
#include "Levenshtein.hpp"
namespace cliforge
{
class Engine
{
public:
explicit Engine(std::string programName = "");
Engine& describe(std::string description);
// Registers a new, empty command and returns a reference to it so it
// can be built up with the fluent .keyword()/.parameter()/... API.
// The Engine owns it for the rest of the program's lifetime.
Command& command();
int run(int argc, char** argv);
private:
std::string programName_;
std::string description_;
std::vector<std::unique_ptr<Command>> commands_;
static std::string basename(const std::string& path);
static std::string join(const std::vector<std::string>& tokens);
// Finds every command consistent with `queryTokens` as a prefix and
// shows its help -- one command's help if there's exactly one match,
// all of them (clearly separated) if the prefix is genuinely
// ambiguous, or the global command list plus a hint if there's none.
int showHelp(const std::vector<std::string>& queryTokens) const;
void printGlobalHelp(std::ostream& os) const;
void suggestCommands(const std::vector<std::string>& tokens, std::ostream& os) const;
static std::string firstLine(const std::string& s);
static std::string padRight(const std::string& s, std::size_t width);
};
}
@@ -0,0 +1,61 @@
#pragma once
// Deduces the return type and argument-type pack of "anything callable":
// plain free-function pointers, non-generic lambdas (captureless or
// capturing), and std::function instances. This is what lets
// Command::action() figure out, at compile time, exactly which C++ types
// it needs to convert parsed CLI tokens into -- without the user ever
// having to spell the types out twice.
#include <cstddef>
#include <functional>
#include <tuple>
namespace cliforge::detail
{
template <typename T> struct function_traits;
// Plain free function: void foo(std::string, int)
template <typename Ret, typename... Args> struct function_traits<Ret (*)(Args...)>
{
using return_type = Ret;
static constexpr std::size_t arity = sizeof...(Args);
template <std::size_t I> using arg = std::tuple_element_t<I, std::tuple<Args...>>;
using args_tuple = std::tuple<Args...>;
};
// std::function<void(std::string, int)>
template <typename Ret, typename... Args> struct function_traits<std::function<Ret(Args...)>>
{
using return_type = Ret;
static constexpr std::size_t arity = sizeof...(Args);
template <std::size_t I> using arg = std::tuple_element_t<I, std::tuple<Args...>>;
using args_tuple = std::tuple<Args...>;
};
// Pointer-to-member-function of operator(), const and non-const, used to
// peel apart lambdas (which are just anonymous functors under the hood).
template <typename C, typename Ret, typename... Args>
struct function_traits<Ret (C::*)(Args...) const>
{
using return_type = Ret;
static constexpr std::size_t arity = sizeof...(Args);
template <std::size_t I> using arg = std::tuple_element_t<I, std::tuple<Args...>>;
using args_tuple = std::tuple<Args...>;
};
template <typename C, typename Ret, typename... Args>
struct function_traits<Ret (C::*)(Args...)>
{
using return_type = Ret;
static constexpr std::size_t arity = sizeof...(Args);
template <std::size_t I> using arg = std::tuple_element_t<I, std::tuple<Args...>>;
using args_tuple = std::tuple<Args...>;
};
// Fallback: any other callable (lambda, functor) -- peel off operator().
template <typename F> struct function_traits : function_traits<decltype(&F::operator())>
{
};
}
@@ -0,0 +1,21 @@
#pragma once
// Plain edit-distance + a small "closest matches" helper, used to power
// the "did you mean...?" suggestions for unknown commands, flags and
// options.
#include <algorithm>
#include <string>
#include <vector>
namespace cliforge::detail
{
std::size_t levenshtein(const std::string& a, const std::string& b);
// Returns up to `limit` candidates from `pool` that are close to `query`,
// ordered by distance. Anything farther than a generous relative
// threshold is dropped so we never suggest something wildly unrelated.
std::vector<std::string> closestMatches(const std::string& query,
const std::vector<std::string>& pool,
std::size_t limit = 3);
}
+64
View File
@@ -0,0 +1,64 @@
#pragma once
// A `Slot` is one piece of a command's signature: a literal keyword, a
// typed positional parameter, a boolean flag, or a typed option. The
// same struct is reused for all four so Command can keep them in plain
// vectors and the matcher/parser code doesn't need four parallel types.
#include <optional>
#include <string>
#include <typeindex>
#include "Value.hpp"
namespace cliforge
{
enum class SlotKind
{
Keyword,
Parameter,
Flag,
Option
};
struct Slot
{
SlotKind kind{};
// Keyword: the literal text the user must type (e.g. "delete").
// Parameter: the display name (e.g. "name" -> shown as <name>).
// Flag/Option: the long name WITHOUT the leading "--" (e.g. "force").
std::string name;
// Flags/Options only: single-letter short name (e.g. 'f'), or '\0'
// if no short form was registered. A plain char instead of a string
// avoids a heap allocation (and the temptation to compare via
// std::string) for what is always exactly zero or one character.
char shortName = '\0';
std::string description;
// Parameter/Option only: may this slot consume more than one token?
// Only the *last* structured parameter, or the *last* loose
// parameter, or any option, may be variadic.
bool variadic = false;
// Set immediately by parameter<T>()/option<T>(), or by choices<T>(),
// never deferred -- so the type is always known and displayable even
// before action() is called.
TypeInfo type;
// The exact C++ type T declared via parameter<T>()/option<T>(), kept
// so action() can *validate* (not invent) the bound function's
// corresponding argument type. Unset for Keyword/Flag slots.
std::optional<std::type_index> cppType;
// True from parameter<T>()/option<T>() (when T is an enum) until the
// matching choices<T>() call supplies the name table; action() checks
// this is false for every slot before binding.
bool awaitingChoices = false;
std::string longFlag() const;
std::string shortFlag() const;
};
}
+367
View File
@@ -0,0 +1,367 @@
#pragma once
// The type system. A `Scalar` is a closed variant over every primitive
// type the engine understands. A `Value` wraps either a single Scalar
// (for ordinary parameters/options) or a vector of them (for variadic
// parameters/options). `TypeInfo` bundles a display name with a parser:
// for the built-in scalar types this is a *plain stateless function
// pointer* (no heap allocation, unlike std::function), and only the
// rarer enum/choice-restricted case pays for a one-time, shared, heap
// allocation to hold its name table.
#include <algorithm>
#include <charconv>
#include <cstdint>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <type_traits>
#include <variant>
#include <vector>
namespace cliforge
{
// ---------------------------------------------------------------------
// Exceptions
// ---------------------------------------------------------------------
// Every distinct reason a run-time parse can fail. Exposed on CliError so
// callers (and Engine internally, for its own error-selection logic) can
// branch on *why* something failed, not just read a free-text message.
enum class ErrorKind
{
Generic,
UnknownOption, // a flag/option name isn't recognized for this command
TooManyArguments, // more positional values than the command declares
MissingArgument, // a required positional value (or option value) was never given
MissingValue, // an option/flag was named but its value token is missing
TypeMismatch, // a token couldn't convert to the slot's declared type
};
// Base of every error the engine raises while registering commands or
// parsing user input. Engine::run() catches CliError and prints it
// nicely instead of letting it escape as an unhandled exception.
class CliError : public std::runtime_error
{
public:
explicit CliError(const std::string& msg, ErrorKind kind = ErrorKind::Generic);
ErrorKind kind() const noexcept;
private:
ErrorKind kind_;
};
// A token couldn't be converted to the type a slot expects -- always
// ErrorKind::TypeMismatch.
class ParseError : public CliError
{
public:
explicit ParseError(const std::string& msg);
};
// Something is wrong with how a command was *built* (mismatched arity
// between declared slots and the bound function, duplicate names, a
// choices<T>() call whose T doesn't match the declared type, an option
// bound to a non-optional type, an action() parameter type that doesn't
// match what was declared, etc). These fire at program startup (many of
// them at compile time via static_assert), not at end-user parse time.
struct RegistrationError : CliError
{
explicit RegistrationError(const std::string& msg);
};
// ---------------------------------------------------------------------
// Enum storage
// ---------------------------------------------------------------------
// Custom enums aren't a native Scalar alternative (there are infinitely
// many enum types), so when a slot is restricted to an enum via
// choices<T>(), the underlying value is boxed here. Value::as<T>() knows
// to unbox + static_cast back to the caller's real enum type.
struct EnumTag
{
long long value;
};
// ---------------------------------------------------------------------
// Scalar: the closed set of representable primitive values
// ---------------------------------------------------------------------
using Scalar = std::variant<int8_t, uint8_t, int16_t, uint16_t, int32_t, uint32_t, int64_t,
uint64_t, float, double, std::string, char, bool, EnumTag>;
enum class Kind
{
I8,
U8,
I16,
U16,
I32,
U32,
I64,
U64,
F32,
F64,
Str,
Char,
Bool,
Enum
};
// ---------------------------------------------------------------------
// TypeInfo: how to parse + describe one type
// ---------------------------------------------------------------------
// Holds the name table for an enum/choice-restricted slot. Built once at
// registration time (a single, amortized allocation via shared_ptr);
// parsing against it never allocates beyond the error path.
struct ChoiceTable
{
std::vector<std::pair<std::string, Scalar>> entries;
Scalar parse(std::string_view token, std::string_view label) const;
};
struct TypeInfo
{
Kind kind{};
std::string displayName;
// Stateless parser for built-in scalar types -- a plain function
// pointer costs nothing to store or copy, unlike std::function.
Scalar (*parsePlain)(std::string_view token, std::string_view label) = nullptr;
// Populated only for choices<T>()-restricted slots.
std::shared_ptr<const ChoiceTable> choiceTable;
bool valid() const;
Scalar parse(std::string_view token, std::string_view label) const;
};
namespace detail
{
template <typename Int>
Int parseInteger(std::string_view token, std::string_view label, std::string_view typeName)
{
Int out{};
auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), out);
if (ec != std::errc{} || ptr != token.data() + token.size())
{
throw ParseError("type mismatch for '" + std::string(label) + "': got '" +
std::string(token) + "', expected " + std::string(typeName));
}
return out;
}
template <typename Float>
Float parseFloating(std::string_view token, std::string_view label,
std::string_view typeName)
{
Float out{};
auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), out);
if (ec != std::errc{} || ptr != token.data() + token.size())
{
throw ParseError("type mismatch for '" + std::string(label) + "': got '" +
std::string(token) + "', expected " + std::string(typeName));
}
return out;
}
std::string toLower(std::string_view s);
}
// Maps a C++ type T to its Kind + a ready-to-use TypeInfo. Specialized
// below for every built-in type; enums go through choices<T>() instead
// since they need a name<->value table supplied by the caller.
template <typename T> struct TypeOf; // intentionally undefined for unsupported T
#define CLIFORGE_INT_TYPE(CPP_T, KIND, NAME) \
template <> struct TypeOf<CPP_T> \
{ \
static TypeInfo get() \
{ \
TypeInfo t; \
t.kind = Kind::KIND; \
t.displayName = NAME; \
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar \
{ return Scalar{detail::parseInteger<CPP_T>(tok, lbl, NAME)}; }; \
return t; \
} \
};
CLIFORGE_INT_TYPE(int8_t, I8, "int8")
CLIFORGE_INT_TYPE(uint8_t, U8, "uint8")
CLIFORGE_INT_TYPE(int16_t, I16, "int16")
CLIFORGE_INT_TYPE(uint16_t, U16, "uint16")
CLIFORGE_INT_TYPE(int32_t, I32, "int32")
CLIFORGE_INT_TYPE(uint32_t, U32, "uint32")
CLIFORGE_INT_TYPE(int64_t, I64, "int64")
CLIFORGE_INT_TYPE(uint64_t, U64, "uint64")
#undef CLIFORGE_INT_TYPE
template <> struct TypeOf<float>
{
static TypeInfo get()
{
TypeInfo t;
t.kind = Kind::F32;
t.displayName = "float32";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar
{ return Scalar{detail::parseFloating<float>(tok, lbl, "float32")}; };
return t;
}
};
template <> struct TypeOf<double>
{
static TypeInfo get()
{
TypeInfo t;
t.kind = Kind::F64;
t.displayName = "float64";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar
{ return Scalar{detail::parseFloating<double>(tok, lbl, "float64")}; };
return t;
}
};
template <> struct TypeOf<std::string>
{
static TypeInfo get()
{
TypeInfo t;
t.kind = Kind::Str;
t.displayName = "string";
t.parsePlain = [](std::string_view tok, std::string_view) -> Scalar
{ return Scalar{std::string(tok)}; };
return t;
}
};
template <> struct TypeOf<char>
{
static TypeInfo get()
{
TypeInfo t;
t.kind = Kind::Char;
t.displayName = "char";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar
{
if (tok.size() != 1)
{
throw ParseError("type mismatch for '" + std::string(lbl) + "': got '" +
std::string(tok) + "', expected a single char");
}
return Scalar{tok[0]};
};
return t;
}
};
template <> struct TypeOf<bool>
{
static TypeInfo get()
{
TypeInfo t;
t.kind = Kind::Bool;
t.displayName = "bool";
t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar
{
std::string lower = detail::toLower(tok);
if (lower == "true" || lower == "1" || lower == "yes" || lower == "on")
{
return Scalar{true};
}
if (lower == "false" || lower == "0" || lower == "no" || lower == "off")
{
return Scalar{false};
}
throw ParseError("type mismatch for '" + std::string(lbl) + "': got '" +
std::string(tok) +
"', expected bool (true/false, 1/0, yes/no, on/off)");
};
return t;
}
};
// Builds a TypeInfo for a `choices<T>()` restricted slot: only the
// listed string keys are accepted, each mapped to a T. Works both for
// genuine C++ enums (boxed into EnumTag) and for restricting a built-in
// type (e.g. a string option limited to a fixed set of values). The
// choice table is converted to type-erased Scalars once, here, so
// nothing downstream needs to be templated on T again.
template <typename T>
TypeInfo makeChoiceTypeInfo(std::string typeName,
const std::vector<std::pair<std::string, T>>& choices)
{
auto table = std::make_shared<ChoiceTable>();
table->entries.reserve(choices.size());
for (const auto& [name, val] : choices)
{
if constexpr (std::is_enum_v<T>)
{
table->entries.emplace_back(name, Scalar{EnumTag{static_cast<long long>(val)}});
}
else
{
table->entries.emplace_back(name, Scalar{val});
}
}
TypeInfo t;
t.kind = Kind::Enum;
t.displayName = std::move(typeName);
t.choiceTable = std::move(table);
return t;
}
// ---------------------------------------------------------------------
// Value: what actually flows out of the parser and into bound functions
// ---------------------------------------------------------------------
class Value
{
public:
Value();
static Value ofScalar(Scalar s);
static Value ofVector(std::vector<Scalar> v);
bool hasValue() const;
bool isVector() const;
template <typename T> T as() const
{
const Scalar& s = std::get<Scalar>(data_);
if constexpr (std::is_enum_v<T>)
{
return static_cast<T>(std::get<EnumTag>(s).value);
}
else
{
return std::get<T>(s);
}
}
template <typename T> std::vector<T> asVector() const
{
const auto& vec = std::get<std::vector<Scalar>>(data_);
std::vector<T> out;
out.reserve(vec.size());
for (const Scalar& s : vec)
{
if constexpr (std::is_enum_v<T>)
{
out.push_back(static_cast<T>(std::get<EnumTag>(s).value));
}
else
{
out.push_back(std::get<T>(s));
}
}
return out;
}
private:
std::variant<std::monostate, Scalar, std::vector<Scalar>> data_;
};
}
+712
View File
@@ -0,0 +1,712 @@
#include "CLIForge/Command.hpp"
namespace cliforge
{
namespace detail
{
bool looksLikeOptionStart(std::string_view tok)
{
if (tok.size() < 2 || tok[0] != '-')
return false;
if (tok[1] == '-')
return true; // "--foo"
if (std::isdigit(static_cast<unsigned char>(tok[1])))
return false; // "-5"
return true; // "-n", "-ndi"
}
bool isValidKeyword(std::string_view s)
{
if (s.empty())
return false;
if (s[0] == '-')
return false; // reserved for flags/options
for (char c : s)
{
if (!(std::isalnum(static_cast<unsigned char>(c)) || c == '.' || c == '_' ||
c == '-'))
{
return false;
}
}
return true;
}
}
Command& Command::keyword(std::string literal)
{
if (unstructuredStarted_)
{
throw RegistrationError("can't declare keyword('" + literal +
"') after a flag()/option() was added -- keywords must "
"come entirely before the unstructured part");
}
if (structuredSealed_)
{
throw RegistrationError("can't declare keyword('" + literal +
"') after a variadic parameter -- a variadic parameter "
"must be the last thing in the structured part");
}
if (!detail::isValidKeyword(literal))
{
throw RegistrationError("invalid keyword '" + literal +
"': keywords may contain letters, digits, '.', '_' and "
"'-', and may not start with '-'");
}
Slot s;
s.kind = SlotKind::Keyword;
s.name = std::move(literal);
structured_.push_back(std::move(s));
lastSlot_ = &structured_.back();
return *this;
}
// shortName is 0 (the default) for "no short form".
Command& Command::flag(std::string longName, char shortName, std::string description)
{
longName = stripDashes(std::move(longName));
validateLongName(longName);
validateShortName(shortName);
checkNameFree(longName, shortName);
Slot s;
s.kind = SlotKind::Flag;
s.name = std::move(longName);
s.shortName = shortName;
s.description = std::move(description);
unstructured_.push_back(std::move(s));
lastSlot_ = &unstructured_.back();
unstructuredStarted_ = true;
return *this;
}
Command& Command::describe(std::string description)
{
description_ = std::move(description);
return *this;
}
bool Command::sealed() const
{
return sealed_;
}
const std::string& Command::description() const
{
return description_;
}
// --- matching / parsing entry points, used by Engine -------------
StructuredMatch Command::tryMatchStructured(const std::vector<std::string>& tokens) const
{
StructuredMatch m;
std::size_t i = 0;
try
{
for (std::size_t s = 0; s < structured_.size(); ++s)
{
const Slot& slot = structured_[s];
bool isLast = (s + 1 == structured_.size());
if (slot.kind == SlotKind::Keyword)
{
if (i >= tokens.size() || tokens[i] != slot.name)
return StructuredMatch{};
++i;
++m.keywordScore;
}
else
{
if (slot.variadic && isLast)
{
std::vector<Scalar> collected;
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
{
collected.push_back(slot.type.parse(tokens[i], slot.name));
++i;
}
m.paramValues.push_back(Value::ofVector(std::move(collected)));
}
else
{
if (i >= tokens.size() || detail::looksLikeOptionStart(tokens[i]))
{
// Every keyword up to here matched literally, so
// this is almost certainly the intended command
// -- just missing a required argument.
throw CliError("missing required argument '<" + slot.name +
">': expected a value of type " +
slot.type.displayName,
ErrorKind::MissingArgument);
}
m.paramValues.push_back(
Value::ofScalar(slot.type.parse(tokens[i], slot.name)));
++i;
}
}
}
}
catch (const CliError& e)
{
// Either a type mismatch (ParseError, a CliError) or the
// MissingArgument thrown just above -- both mean "this is the
// command you meant, but something about the value is wrong",
// so remember it instead of treating it as a plain non-match.
m.success = false;
m.error = e.what();
return m;
}
m.success = true;
m.consumed = i;
return m;
}
// Parses the (order-agnostic) flags/options/loose-parameters tail.
// Throws CliError (with a specific ErrorKind) describing exactly what
// went wrong: an unrecognized flag/option, a missing option value, a
// missing/extra positional argument, or a type mismatch.
std::vector<Value> Command::parseUnstructured(const std::vector<std::string>& tokens) const
{
std::vector<bool> provided(unstructured_.size(), false);
std::vector<std::vector<Scalar>> collected(unstructured_.size());
std::vector<std::string_view>
looseTokens; // views into `tokens`, which outlives this call
looseTokens.reserve(tokens.size());
std::size_t i = 0;
while (i < tokens.size())
{
std::string_view tok = tokens[i];
if (!detail::looksLikeOptionStart(tok))
{
// Not flag/option syntax (includes negative numbers like
// "-5") -- it's a value for the next loose parameter.
looseTokens.push_back(tok);
++i;
continue;
}
if (tok[1] == '-')
{
std::string_view rest = tok.substr(2);
auto eq = rest.find('=');
bool hasInline = (eq != std::string_view::npos);
std::string_view longName = hasInline ? rest.substr(0, eq) : rest;
std::string_view inlineValue =
hasInline ? rest.substr(eq + 1) : std::string_view{};
int idx = findByLong(longName);
if (idx < 0)
throwUnknown(tok);
++i;
consumeSlot(*this, idx, hasInline, inlineValue, tokens, i, provided, collected,
tok);
}
else
{
std::string_view rest = tok.substr(1);
if (rest.size() == 1)
{
int idx = findByShort(rest[0]);
if (idx < 0)
throwUnknown(tok);
++i;
consumeSlot(*this, idx, false, std::string_view{}, tokens, i, provided,
collected, tok);
}
else
{
// Combined short flags, e.g. "-ndi" == -n -d -i.
for (char c : rest)
{
int idx = findByShort(c);
if (idx < 0)
{
throw CliError(std::string("unknown flag '-") + c +
"' in combined group '" + std::string(tok) +
"'" + suggestFlagOption(std::string("-") + c),
ErrorKind::UnknownOption);
}
if (unstructured_[static_cast<size_t>(idx)].kind != SlotKind::Flag)
{
throw CliError(std::string("'-") + c + "' in combined group '" +
std::string(tok) +
"' is an option (needs a value), so it can't "
"be combined with other short flags");
}
provided[static_cast<size_t>(idx)] = true;
}
++i;
}
}
}
// Assign the collected loose tokens to any declared loose
// Parameter slots, positionally, in declaration order -- this is
// independent of where those tokens fell relative to flags/options.
std::vector<std::size_t> looseParamIdx;
for (std::size_t idx = 0; idx < unstructured_.size(); ++idx)
{
if (unstructured_[idx].kind == SlotKind::Parameter)
looseParamIdx.push_back(idx);
}
std::vector<Value> looseValues(looseParamIdx.size());
std::size_t li = 0;
for (std::size_t k = 0; k < looseParamIdx.size(); ++k)
{
const Slot& slot = unstructured_[looseParamIdx[k]];
bool isLastLoose = (k + 1 == looseParamIdx.size());
if (slot.variadic && isLastLoose)
{
std::vector<Scalar> vals;
vals.reserve(looseTokens.size() > li ? looseTokens.size() - li : 0);
while (li < looseTokens.size())
{
vals.push_back(slot.type.parse(looseTokens[li], slot.name));
++li;
}
looseValues[k] = Value::ofVector(std::move(vals));
}
else
{
if (li >= looseTokens.size())
{
throw CliError("missing required argument '<" + slot.name +
">': expected a value of type " + slot.type.displayName,
ErrorKind::MissingArgument);
}
looseValues[k] = Value::ofScalar(slot.type.parse(looseTokens[li], slot.name));
++li;
}
}
if (li < looseTokens.size())
{
std::size_t extra = looseTokens.size() - li;
std::string msg =
"too many arguments: unexpected '" + std::string(looseTokens[li]) + "'";
if (extra > 1)
msg += " (and " + std::to_string(extra - 1) + " more)";
throw CliError(msg, ErrorKind::TooManyArguments);
}
// Assemble the final values in unstructured_'s declaration order --
// this is what gets paired with the bound function's arguments.
std::vector<Value> out;
out.reserve(unstructured_.size());
std::size_t looseCursor = 0;
for (std::size_t idx = 0; idx < unstructured_.size(); ++idx)
{
const Slot& slot = unstructured_[idx];
if (slot.kind == SlotKind::Parameter)
{
out.push_back(std::move(looseValues[looseCursor++]));
}
else if (slot.kind == SlotKind::Flag)
{
out.push_back(Value::ofScalar(Scalar{provided[idx]}));
}
else if (slot.variadic)
{
out.push_back(Value::ofVector(collected[idx]));
}
else if (provided[idx])
{
out.push_back(Value::ofScalar(collected[idx][0]));
}
else
{
out.push_back(Value{}); // options are always optional -> unset means nullopt
}
}
return out;
}
void Command::invoke(std::vector<Value> allValues) const
{
invoke_(allValues);
}
std::size_t Command::structuredSlotCount() const
{
return structured_.size();
}
bool Command::couldMatchPrefix(const std::vector<std::string>& tokens) const
{
std::size_t i = 0;
for (std::size_t s = 0; s < structured_.size(); ++s)
{
if (i >= tokens.size())
break; // ran out of input -- a valid, incomplete prefix
const Slot& slot = structured_[s];
bool isLast = (s + 1 == structured_.size());
if (slot.kind == SlotKind::Keyword)
{
if (tokens[i] != slot.name)
return false;
++i;
}
else if (slot.variadic && isLast)
{
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
++i;
}
else
{
if (detail::looksLikeOptionStart(tokens[i]))
break; // structured part left incomplete
++i;
}
}
// Whatever tokens remain must be plausible flags/options (assumed
// fine without deep validation) or fit a declared loose parameter.
std::vector<const Slot*> looseParams;
for (const Slot& s : unstructured_)
{
if (s.kind == SlotKind::Parameter)
looseParams.push_back(&s);
}
std::size_t looseIdx = 0;
for (; i < tokens.size(); ++i)
{
if (detail::looksLikeOptionStart(tokens[i]))
continue;
if (looseIdx >= looseParams.size())
return false; // nothing left to absorb this token
if (!looseParams[looseIdx]->variadic)
++looseIdx;
}
return true;
}
// --- help / usage --------------------------------------------------
std::string Command::usageLine() const
{
std::ostringstream os;
for (std::size_t s = 0; s < structured_.size(); ++s)
{
const Slot& slot = structured_[s];
if (s)
os << ' ';
if (slot.kind == SlotKind::Keyword)
{
os << slot.name;
}
else
{
os << '<' << slot.name << (slot.variadic ? "..." : "") << '>';
}
}
bool anyFlagOrOption =
std::any_of(unstructured_.begin(), unstructured_.end(),
[](const Slot& s) { return s.kind != SlotKind::Parameter; });
if (anyFlagOrOption)
os << " [OPTIONS]";
for (const Slot& slot : unstructured_)
{
if (slot.kind != SlotKind::Parameter)
continue;
os << " <" << slot.name << (slot.variadic ? "..." : "") << '>';
}
return os.str();
}
std::string Command::helpText(const std::string& progName) const
{
std::ostringstream os;
os << "Usage: " << progName << " " << usageLine() << "\n";
if (!description_.empty())
os << "\n" << description_ << "\n";
bool anyParams =
std::any_of(structured_.begin(), structured_.end(),
[](const Slot& s) { return s.kind == SlotKind::Parameter; }) ||
std::any_of(unstructured_.begin(), unstructured_.end(),
[](const Slot& s) { return s.kind == SlotKind::Parameter; });
if (anyParams)
{
os << "\nArguments:\n";
for (const Slot& slot : structured_)
{
if (slot.kind != SlotKind::Parameter)
continue;
std::string label = "<" + slot.name + (slot.variadic ? "..." : "") + ">";
os << " " << padRight(label, 22) << describeType(slot) << slot.description
<< "\n";
}
for (const Slot& slot : unstructured_)
{
if (slot.kind != SlotKind::Parameter)
continue;
std::string label = "<" + slot.name + (slot.variadic ? "..." : "") + ">";
os << " " << padRight(label, 22) << describeType(slot) << slot.description
<< "\n";
}
}
bool anyFlagOrOption =
std::any_of(unstructured_.begin(), unstructured_.end(),
[](const Slot& s) { return s.kind != SlotKind::Parameter; });
os << "\nOptions:\n";
if (anyFlagOrOption)
{
for (const Slot& slot : unstructured_)
{
if (slot.kind == SlotKind::Parameter)
continue;
std::string names =
slot.shortName == '\0'
? (" --" + slot.name)
: (std::string("-") + slot.shortName + ", --" + slot.name);
if (slot.kind == SlotKind::Option)
{
names += " <" + slot.type.displayName + (slot.variadic ? "...>" : ">");
}
os << " " << padRight(names, 28) << slot.description << "\n";
}
}
os << " " << padRight("-h, --help", 28) << "Show this help message\n";
return os.str();
}
std::vector<std::string> Command::flagOptionNames() const
{
std::vector<std::string> out;
for (const Slot& s : unstructured_)
{
if (s.kind == SlotKind::Parameter)
continue;
out.push_back("--" + s.name);
if (s.shortName != '\0')
out.push_back(std::string("-") + s.shortName);
}
return out;
}
Command::FuzzyScore Command::fuzzyStructuralDistance(const std::vector<std::string>& tokens) const
{
std::size_t i = 0;
FuzzyScore score;
for (std::size_t s = 0; s < structured_.size(); ++s)
{
const Slot& slot = structured_[s];
bool isLast = (s + 1 == structured_.size());
if (slot.kind == SlotKind::Keyword)
{
score.keywordChars += slot.name.size();
if (i >= tokens.size())
{
score.distance += slot.name.size(); // command is longer than what was typed
continue;
}
score.distance += detail::levenshtein(tokens[i], slot.name);
++i;
}
else if (slot.variadic && isLast)
{
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
++i;
}
else if (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
{
++i; // parameters are wildcards: any token "fits", no penalty
}
}
score.distance += (tokens.size() > i) ? (tokens.size() - i) : 0; // leftover tokens
return score;
}
std::string Command::stripDashes(std::string s)
{
while (!s.empty() && s.front() == '-')
s.erase(s.begin());
return s;
}
void Command::validateLongName(const std::string& s) const
{
if (s.empty())
throw RegistrationError("a flag/option needs a non-empty long name");
if (s == "help")
{
throw RegistrationError("'help' is reserved for the built-in --help flag");
}
for (char c : s)
{
if (!(std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_'))
{
throw RegistrationError("invalid long name '--" + s + "'");
}
}
}
void Command::validateShortName(char c) const
{
if (c == '\0')
return; // no short form
if (!std::isalnum(static_cast<unsigned char>(c)))
{
throw RegistrationError(
std::string("short names must be a single letter/digit, got '-") + c + "'");
}
if (c == 'h')
throw RegistrationError("'-h' is reserved for the built-in --help flag");
}
void Command::checkNameFree(const std::string& longName, char shortName) const
{
for (const Slot& s : unstructured_)
{
if (s.kind == SlotKind::Parameter)
continue; // different namespace (bare, not "--name")
if (s.name == longName)
{
throw RegistrationError("duplicate long name '--" + longName + "'");
}
if (shortName != '\0' && s.shortName == shortName)
{
throw RegistrationError(std::string("duplicate short name '-") + shortName +
"'");
}
}
}
void Command::checkNotAwaitingChoices(const Slot& s)
{
if (s.awaitingChoices)
{
throw RegistrationError("'" + s.name +
"' is a custom enum type and needs choices<T>(...) "
"called right after it is declared");
}
}
std::vector<Slot*> Command::orderedBindableSlots()
{
std::vector<Slot*> out;
for (Slot& s : structured_)
{
if (s.kind == SlotKind::Parameter)
out.push_back(&s);
}
for (Slot& s : unstructured_)
out.push_back(&s);
return out;
}
int Command::findByLong(std::string_view name) const
{
for (std::size_t i = 0; i < unstructured_.size(); ++i)
{
if (unstructured_[i].kind != SlotKind::Parameter && unstructured_[i].name == name)
{
return static_cast<int>(i);
}
}
return -1;
}
int Command::findByShort(char c) const
{
if (c == '\0')
return -1;
for (std::size_t i = 0; i < unstructured_.size(); ++i)
{
if (unstructured_[i].kind != SlotKind::Parameter && unstructured_[i].shortName == c)
{
return static_cast<int>(i);
}
}
return -1;
}
std::string Command::suggestFlagOption(const std::string& given) const
{
auto names = flagOptionNames();
names.push_back("--help");
names.push_back("-h");
auto close = detail::closestMatches(given, names, 1);
if (close.empty())
return "";
return " -- did you mean '" + close.front() + "'?";
}
[[noreturn]] void Command::throwUnknown(std::string_view tok) const
{
throw CliError("unknown flag/option '" + std::string(tok) + "' for this command" +
suggestFlagOption(std::string(tok)),
ErrorKind::UnknownOption);
}
std::string Command::describeType(const Slot& slot)
{
return "<" + slot.type.displayName + "> ";
}
std::string Command::padRight(const std::string& s, std::size_t width)
{
if (s.size() >= width)
return s + " ";
return s + std::string(width - s.size(), ' ');
}
void Command::consumeSlot(const Command& self, int idxIn, bool hasInline,
std::string_view inlineValue,
const std::vector<std::string>& tokens, std::size_t& i,
std::vector<bool>& provided,
std::vector<std::vector<Scalar>>& collected,
std::string_view tokForError)
{
std::size_t idx = static_cast<std::size_t>(idxIn);
const Slot& slot = self.unstructured_[idx];
if (slot.kind == SlotKind::Flag)
{
if (hasInline)
throw CliError("flag '--" + slot.name + "' does not take a value");
provided[idx] = true;
return;
}
// Option
if (hasInline)
{
collected[idx].push_back(slot.type.parse(inlineValue, slot.name));
provided[idx] = true;
if (slot.variadic)
{
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
{
collected[idx].push_back(slot.type.parse(tokens[i], slot.name));
++i;
}
}
return;
}
if (slot.variadic)
{
std::size_t before = i;
while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i]))
{
collected[idx].push_back(slot.type.parse(tokens[i], slot.name));
++i;
}
if (i == before)
{
throw CliError("option '" + std::string(tokForError) +
"' requires at least one value",
ErrorKind::MissingValue);
}
provided[idx] = true;
}
else
{
if (i >= tokens.size() || detail::looksLikeOptionStart(tokens[i]))
{
throw CliError("option '" + std::string(tokForError) + "' requires a value",
ErrorKind::MissingValue);
}
collected[idx] = {slot.type.parse(tokens[i], slot.name)};
provided[idx] = true;
++i;
}
};
}
+295
View File
@@ -0,0 +1,295 @@
#pragma once
#include "CLIForge/Engine.hpp"
namespace cliforge
{
Engine::Engine(std::string programName) : programName_(std::move(programName)) {}
Engine& Engine::describe(std::string description)
{
description_ = std::move(description);
return *this;
}
// Registers a new, empty command and returns a reference to it so it
// can be built up with the fluent .keyword()/.parameter()/... API.
// The Engine owns it for the rest of the program's lifetime.
Command& Engine::command()
{
commands_.push_back(std::make_unique<Command>());
return *commands_.back();
}
int Engine::run(int argc, char** argv)
{
if (programName_.empty() && argc > 0)
programName_ = basename(argv[0]);
for (auto& cmd : commands_)
{
if (!cmd->sealed())
{
std::cerr << "cliforge BUG: a command was registered without calling .action() "
"on it -- every command must be bound to a function.\n";
return 2;
}
}
std::vector<std::string> tokens;
for (int i = 1; i < argc; ++i)
tokens.emplace_back(argv[i]);
if (tokens.empty())
{
printGlobalHelp(std::cout);
return 0;
}
// Centralized help handling: `help [words...]`, or --help/-h
// appearing anywhere. Both funnel into the same prefix search, so
// an ambiguous prefix (e.g. "project --help" matching create/
// delete/info) shows help for every command it's consistent with,
// instead of silently picking one or matching nothing.
std::optional<std::vector<std::string>> helpQuery;
if (tokens[0] == "help")
{
helpQuery = std::vector<std::string>(tokens.begin() + 1, tokens.end());
}
else if (auto it = std::find_if(tokens.begin(), tokens.end(), [](const std::string& t)
{ return t == "--help" || t == "-h"; });
it != tokens.end())
{
std::vector<std::string> q = tokens;
q.erase(q.begin() + (it - tokens.begin()));
helpQuery = std::move(q);
}
if (helpQuery)
{
return showHelp(*helpQuery);
}
struct Candidate
{
Command* cmd;
StructuredMatch match;
};
std::vector<Candidate> candidates;
std::string bestPartialError;
std::vector<Command*> bestPartialCmds; // every command tied for the best partial score
int bestPartialScore = -1;
for (auto& cmd : commands_)
{
StructuredMatch m = cmd->tryMatchStructured(tokens);
if (m.success)
{
candidates.push_back({cmd.get(), std::move(m)});
}
else if (!m.error.empty())
{
if (m.keywordScore > bestPartialScore)
{
bestPartialScore = m.keywordScore;
bestPartialError = m.error;
bestPartialCmds.clear();
bestPartialCmds.push_back(cmd.get());
}
else if (m.keywordScore == bestPartialScore)
{
// Genuinely tied -- e.g. "project" alone is an equally
// incomplete prefix of create/delete/info, all of which
// report the identical "missing <name>" error. Rather
// than silently picking whichever was registered first
// (which read as an arbitrary, misleading suggestion),
// remember all of them and show every option.
bestPartialCmds.push_back(cmd.get());
}
}
}
if (candidates.empty())
{
if (!bestPartialCmds.empty())
{
// Every keyword matched some command, just with a bad or
// missing argument value -- this is much more actionable
// than a generic "no command matches" + fuzzy suggestions.
std::cerr << "Error: " << bestPartialError << "\n";
if (bestPartialCmds.size() == 1)
{
std::cerr << "Usage: " << programName_ << " "
<< bestPartialCmds.front()->usageLine() << "\n";
}
else
{
std::cerr << "'" << join(tokens) << "' matches " << bestPartialCmds.size()
<< " commands:\n";
for (auto* cmd : bestPartialCmds)
{
std::string line = programName_ + " " + cmd->usageLine();
std::cerr << " " << padRight(line, 46) << firstLine(cmd->description())
<< "\n";
}
}
return 1;
}
std::cerr << "Error: no command matches '" << join(tokens) << "'.\n";
suggestCommands(tokens, std::cerr);
return 1;
}
std::sort(candidates.begin(), candidates.end(),
[](const Candidate& a, const Candidate& b)
{
if (a.match.keywordScore != b.match.keywordScore)
return a.match.keywordScore > b.match.keywordScore;
return a.cmd->structuredSlotCount() > b.cmd->structuredSlotCount();
});
std::string firstError;
Command* firstErrorCmd = nullptr;
bool haveError = false;
for (auto& c : candidates)
{
std::vector<std::string> remaining(tokens.begin() + static_cast<long>(c.match.consumed),
tokens.end());
try
{
std::vector<Value> values = c.cmd->parseUnstructured(remaining);
std::vector<Value> all = c.match.paramValues;
for (auto& v : values)
all.push_back(std::move(v));
c.cmd->invoke(std::move(all));
return 0;
}
catch (const CliError& e)
{
// Candidates are ranked best-first (most keyword matches
// first), so the *first* failure is the one most likely to
// be what the user actually meant -- keep that, not
// whichever candidate happens to fail last.
if (!haveError)
{
firstError = e.what();
firstErrorCmd = c.cmd;
haveError = true;
}
}
}
std::cerr << "Error: " << firstError << "\n";
if (firstErrorCmd)
std::cerr << "Usage: " << programName_ << " " << firstErrorCmd->usageLine() << "\n";
return 1;
}
std::string Engine::basename(const std::string& path)
{
auto pos = path.find_last_of("/\\");
return pos == std::string::npos ? path : path.substr(pos + 1);
}
std::string Engine::join(const std::vector<std::string>& tokens)
{
std::string out;
for (std::size_t i = 0; i < tokens.size(); ++i)
{
if (i)
out += ' ';
out += tokens[i];
}
return out;
}
// Finds every command consistent with `queryTokens` as a prefix and
// shows its help -- one command's help if there's exactly one match,
// all of them (clearly separated) if the prefix is genuinely
// ambiguous, or the global command list plus a hint if there's none.
int Engine::showHelp(const std::vector<std::string>& queryTokens) const
{
if (queryTokens.empty())
{
printGlobalHelp(std::cout);
return 0;
}
std::vector<Command*> matches;
for (auto& cmd : commands_)
{
if (cmd->couldMatchPrefix(queryTokens))
matches.push_back(cmd.get());
}
if (matches.empty())
{
std::cout << "No command matches '" << join(queryTokens) << "'.\n\n";
printGlobalHelp(std::cout);
return 1;
}
if (matches.size() == 1)
{
std::cout << matches.front()->helpText(programName_);
return 0;
}
std::cout << "'" << join(queryTokens) << "' matches " << matches.size() << " commands:\n\n";
for (std::size_t i = 0; i < matches.size(); ++i)
{
if (i)
std::cout << "\n";
std::cout << matches[i]->helpText(programName_);
}
return 0;
}
void Engine::printGlobalHelp(std::ostream& os) const
{
os << "Usage: " << programName_ << " <command> [ARGUMENTS] [OPTIONS]\n";
if (!description_.empty())
os << "\n" << description_ << "\n";
os << "\nCommands:\n";
for (auto& cmd : commands_)
{
std::string line = programName_ + " " + cmd->usageLine();
os << " " << padRight(line, 46) << firstLine(cmd->description()) << "\n";
}
os << "\nRun '" << programName_ << " help <command>' or add --help/-h to any command "
<< "for details.\n";
}
void Engine::suggestCommands(const std::vector<std::string>& tokens, std::ostream& os) const
{
std::vector<std::pair<double, Command*>> scored;
for (auto& cmd : commands_)
{
Command::FuzzyScore s = cmd->fuzzyStructuralDistance(tokens);
double ratio = s.keywordChars ? static_cast<double>(s.distance) /
static_cast<double>(s.keywordChars)
: 1.0;
scored.emplace_back(ratio, cmd.get());
}
std::sort(scored.begin(), scored.end(), [](auto& a, auto& b) { return a.first < b.first; });
std::vector<Command*> close;
for (auto& [ratio, cmd] : scored)
{
if (close.size() >= 3)
break;
if (ratio <= 0.5)
close.push_back(cmd);
}
if (close.empty())
return;
os << "\nDid you mean:\n";
for (auto* cmd : close)
os << " " << programName_ << " " << cmd->usageLine() << "\n";
}
std::string Engine::firstLine(const std::string& s)
{
auto pos = s.find('\n');
return pos == std::string::npos ? s : s.substr(0, pos);
}
std::string Engine::padRight(const std::string& s, std::size_t width)
{
if (s.size() >= width)
return s + " ";
return s + std::string(width - s.size(), ' ');
}
}
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include "CLIForge/Levenshtein.hpp"
namespace cliforge::detail
{
std::size_t levenshtein(const std::string& a, const std::string& b)
{
const std::size_t n = a.size(), m = b.size();
std::vector<std::size_t> prev(m + 1), cur(m + 1);
for (std::size_t j = 0; j <= m; ++j)
prev[j] = j;
for (std::size_t i = 1; i <= n; ++i)
{
cur[0] = i;
for (std::size_t j = 1; j <= m; ++j)
{
std::size_t cost = (a[i - 1] == b[j - 1]) ? 0 : 1;
cur[j] = std::min({prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost});
}
std::swap(prev, cur);
}
return prev[m];
}
// Returns up to `limit` candidates from `pool` that are close to `query`,
// ordered by distance. Anything farther than a generous relative
// threshold is dropped so we never suggest something wildly unrelated.
std::vector<std::string> closestMatches(const std::string& query,
const std::vector<std::string>& pool,
std::size_t limit)
{
std::vector<std::pair<std::size_t, std::string>> scored;
scored.reserve(pool.size());
for (const auto& candidate : pool)
{
std::size_t d = levenshtein(query, candidate);
// A prefix relationship (e.g. "--arch" for "--architectures") is a
// very strong signal -- treat it as near-identical even though its
// raw edit distance is large, so common abbreviations still surface.
if (query.size() >= 2 && candidate.size() > query.size() &&
candidate.compare(0, query.size(), query) == 0)
{
d = std::min<std::size_t>(d, 1);
}
scored.emplace_back(d, candidate);
}
std::sort(scored.begin(), scored.end(), [](auto& a, auto& b) { return a.first < b.first; });
std::size_t threshold = std::max<std::size_t>(3, query.size() / 2);
std::vector<std::string> out;
for (auto& [dist, name] : scored)
{
if (out.size() >= limit)
break;
if (dist <= threshold)
out.push_back(name);
}
return out;
}
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "CLIForge/Slot.hpp"
namespace cliforge
{
std::string Slot::longFlag() const
{
return "--" + name;
}
std::string Slot::shortFlag() const
{
return shortName ? std::string("-") + shortName : std::string();
}
}
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include "CLIForge/Value.hpp"
namespace cliforge
{
CliError::CliError(const std::string& msg, ErrorKind kind)
: std::runtime_error(msg), kind_(kind) {}
ErrorKind CliError::kind() const noexcept
{
return kind_;
}
ParseError::ParseError(const std::string& msg) : CliError(msg, ErrorKind::TypeMismatch) {}
RegistrationError::RegistrationError(const std::string& msg) : CliError(msg, ErrorKind::Generic) {}
Scalar ChoiceTable::parse(std::string_view token, std::string_view label) const
{
for (const auto& [name, val] : entries)
{
if (name == token)
return val;
}
std::string allowed;
for (size_t i = 0; i < entries.size(); ++i)
{
if (i)
allowed += ", ";
allowed += entries[i].first;
}
throw ParseError("type mismatch for '" + std::string(label) + "': got '" +
std::string(token) + "', expected one of: " + allowed);
}
bool TypeInfo::valid() const
{
return parsePlain != nullptr || choiceTable != nullptr;
}
Scalar TypeInfo::parse(std::string_view token, std::string_view label) const
{
return choiceTable ? choiceTable->parse(token, label) : parsePlain(token, label);
}
namespace detail
{
std::string toLower(std::string_view s)
{
std::string out(s);
std::transform(out.begin(), out.end(), out.begin(),
[](unsigned char c) { return std::tolower(c); });
return out;
}
}
Value::Value() = default;
Value Value::ofScalar(Scalar s)
{
Value v;
v.data_ = std::move(s);
return v;
}
Value Value::ofVector(std::vector<Scalar> v)
{
Value out;
out.data_ = std::move(v);
return out;
}
bool Value::hasValue() const
{
return !std::holds_alternative<std::monostate>(data_);
}
bool Value::isVector() const
{
return std::holds_alternative<std::vector<Scalar>>(data_);
}
}
+1
View File
@@ -0,0 +1 @@
add_exe_module(example OUTPUT_NAME "example" DEPENDS cliforge)
@@ -13,7 +13,7 @@
#include <string>
#include <vector>
#include <cliforge/cliforge.hpp>
#include <CLIForge/CLIForge.hpp>
using cliforge::Engine;
-144
View File
@@ -1,144 +0,0 @@
// Exercises the *registration-time* safety guards: things a programmer
// could get wrong while wiring up commands, which should fail loudly and
// clearly at startup rather than misbehaving at run time -- plus a few
// "this should now succeed" checks for behavior that's supposed to work.
#include <cstdio>
#include <optional>
#include <string>
#include <vector>
#include <cliforge/cliforge.hpp>
using cliforge::Command;
using cliforge::Engine;
using cliforge::RegistrationError;
enum class Color { Red, Green };
void noop(const std::string&) {}
void noop2(const std::string&, bool) {}
void noopOptStrStr(const std::optional<std::string>&, const std::string&) {}
void noopBoolOptVec(bool, const std::optional<std::string>&, const std::vector<std::string>&) {}
void noopWrongType(int32_t) {}
void noopColor(Color) {}
#define EXPECT_THROW(label, expr) \
do { \
try { \
expr; \
std::printf("[FAIL] %-45s did not throw\n", label); \
} catch (const RegistrationError& e) { \
std::printf("[ OK ] %-45s -> %s\n", label, e.what()); \
} \
} while (0)
#define EXPECT_OK(label, expr) \
do { \
try { \
expr; \
std::printf("[ OK ] %-45s (correctly did not throw)\n", label); \
} catch (const RegistrationError& e) { \
std::printf("[FAIL] %-45s threw: %s\n", label, e.what()); \
} \
} while (0)
int main() {
Engine cli;
EXPECT_THROW("keyword after flag", ({
cli.command()
.keyword("a")
.flag("x", '\0', "")
.keyword("b"); // keywords must come before flags/options
}));
EXPECT_THROW("keyword after variadic parameter", ({
cli.command()
.keyword("a")
.parameter<std::string>("p", "", /*variadic=*/true)
.keyword("b"); // variadic must be last in the structured part
}));
EXPECT_THROW("loose parameter after variadic loose parameter", ({
cli.command()
.keyword("a")
.flag("f", '\0', "")
.parameter<std::string>("p1", "", /*variadic=*/true)
.parameter<std::string>("p2", ""); // variadic must be the last parameter
}));
EXPECT_THROW("action() arity mismatch", ({
cli.command().keyword("a").parameter<std::string>("p", "").action(&noop2);
// noop2 takes 2 args, only 1 slot declared
}));
EXPECT_THROW("no keyword at all", ({
cli.command().parameter<std::string>("p", "").action(&noop);
// a command must have at least one keyword
}));
EXPECT_THROW("option bound to a required (bare) type", ({
cli.command().keyword("a").option<std::string>("o", '\0', "").action(&noop);
// options must be std::optional<T> or std::vector<T> -- never bare T
}));
EXPECT_THROW("declared type doesn't match action() type", ({
cli.command()
.keyword("a")
.parameter<std::string>("p", "")
.action(&noopWrongType); // declared string, action expects int32_t
}));
EXPECT_THROW("enum parameter without choices<T>()", ({
cli.command()
.keyword("a")
.parameter<Color>("c", "")
.action(&noopColor); // never called .choices<Color>(...)
}));
EXPECT_THROW("duplicate long flag name", ({
cli.command()
.keyword("a")
.flag("force", 'f', "")
.flag("force", 'g', "");
}));
EXPECT_THROW("duplicate short flag name", ({
cli.command()
.keyword("a")
.flag("force", 'f', "")
.flag("full", 'f', "");
}));
EXPECT_THROW("reserved --help name", ({ cli.command().keyword("a").flag("help", '\0', ""); }));
EXPECT_THROW("reserved -h short name", ({ cli.command().keyword("a").flag("x", 'h', ""); }));
EXPECT_THROW("invalid keyword charset", ({ cli.command().keyword("bad keyword!"); }));
EXPECT_THROW("keyword starting with dash", ({ cli.command().keyword("-bad"); }));
EXPECT_OK("parameter() after option() (loose parameter)", ({
cli.command()
.keyword("a")
.option<std::string>("o", '\0', "")
.parameter<std::string>("p", "")
.action(&noopOptStrStr);
}));
EXPECT_OK("flag/option/loose-parameter fully interleaved", ({
cli.command()
.keyword("b")
.flag("f", '\0', "")
.option<std::string>("o", '\0', "")
.parameter<std::string>("p", "", /*variadic=*/true)
.action(&noopBoolOptVec);
}));
EXPECT_OK("action() taking arguments by const reference", ({
cli.command().keyword("c").parameter<std::string>("p", "").action(&noop);
}));
std::printf("\nAll registration-guard checks completed.\n");
return 0;
}
-40
View File
@@ -1,40 +0,0 @@
#include <cstdint>
#include <cstdio>
#include <optional>
#include <string>
#include <vector>
#include <cliforge/cliforge.hpp>
using cliforge::Engine;
void allTypes(int8_t a, uint8_t b, int16_t c, uint16_t d, int32_t e, uint32_t f, int64_t g,
uint64_t h, float fl, double db, const std::string& s, char ch, bool bl) {
std::printf(
"i8=%d u8=%u i16=%d u16=%u i32=%d u32=%u i64=%lld u64=%llu f=%g d=%g s=%s c=%c b=%d\n",
a, b, c, d, e, f, static_cast<long long>(g), static_cast<unsigned long long>(h),
static_cast<double>(fl), db, s.c_str(), ch, bl);
}
int main(int argc, char** argv) {
Engine cli("typetest");
cli.command()
.keyword("all")
.parameter<int8_t>("a", "int8")
.parameter<uint8_t>("b", "uint8")
.parameter<int16_t>("c", "int16")
.parameter<uint16_t>("d", "uint16")
.parameter<int32_t>("e", "int32")
.parameter<uint32_t>("f", "uint32")
.parameter<int64_t>("g", "int64")
.parameter<uint64_t>("h", "uint64")
.parameter<float>("fl", "float32")
.parameter<double>("db", "float64")
.parameter<std::string>("s", "string")
.parameter<char>("ch", "char")
.flag("bl", 'b', "bool flag")
.describe("Exercises every scalar type")
.action(&allTypes);
return cli.run(argc, argv);
}