Add project files.
This commit is contained in:
commit
3087fcea8a
71 files changed
+8325
No files matched your search
+117
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
# .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
|
||||||
|
# InlineOnly (not Empty or None): out-of-class function definitions always
|
||||||
|
# get full Allman expansion regardless of length, same as before -- this
|
||||||
|
# only additionally allows a function *defined inside a class body* to
|
||||||
|
# stay a compact one-liner if it's short enough to fit. That's specifically
|
||||||
|
# for the trivial-one-liner exception to "avoid inline member definitions"
|
||||||
|
# in Class Member Organization (style guide §6) -- a plain getter like
|
||||||
|
# `int getValue() const { return m_value; }` stays inline-and-compact;
|
||||||
|
# anything defined out-of-class still always gets the full brace expansion.
|
||||||
|
AllowShortFunctionsOnASingleLine: InlineOnly
|
||||||
|
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
|
||||||
|
|
||||||
|
# Parameters/arguments: never one-per-line (style guide §8). When a
|
||||||
|
# signature/call doesn't fit on one line, keep everything together on a
|
||||||
|
# single continuation line if it fits there (AllowAll...OnNextLine), and
|
||||||
|
# fall back to packing multiple per line (BinPack...) rather than ever
|
||||||
|
# breaking to one per line. This can't force a genuinely long signature
|
||||||
|
# to fit within ColumnLimit without wrapping at all -- doing that would
|
||||||
|
# require ColumnLimit: 0 project-wide, which would also stop comments and
|
||||||
|
# ordinary expressions from ever wrapping. See §8 for the full rule.
|
||||||
|
#
|
||||||
|
# COMPATIBILITY NOTE: clang-format 20 changed BinPackParameters (and the
|
||||||
|
# equivalent for call arguments) from a boolean to an enum, and later
|
||||||
|
# versions deprecated it again in favor of a nested PackParameters/
|
||||||
|
# PackArguments option. The boolean form below is correct for clang-format
|
||||||
|
# through 19 (still the most common version in practice, e.g. Ubuntu
|
||||||
|
# 24.04 LTS ships 18). On clang-format 20 or newer, replace `true` with
|
||||||
|
# `BinPack` on both lines if you find these are being silently ignored.
|
||||||
|
BinPackArguments: true
|
||||||
|
BinPackParameters: true
|
||||||
|
AllowAllArgumentsOnNextLine: true
|
||||||
|
AllowAllParametersOfDeclarationOnNextLine: true
|
||||||
|
|
||||||
|
BreakConstructorInitializers: BeforeColon
|
||||||
|
ConstructorInitializerIndentWidth: 4
|
||||||
|
PenaltyReturnTypeOnItsOwnLine: 1000
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
# .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.
|
||||||
|
#
|
||||||
|
# readability-redundant-inline-specifier is a narrow, complementary aid for
|
||||||
|
# the "avoid inline member definitions" rule in §6 (Class Member
|
||||||
|
# Organization): it only catches an explicit `inline` keyword that's
|
||||||
|
# already redundant (e.g. on a function defined in the class body, which
|
||||||
|
# is implicitly inline regardless). It does not catch — and nothing in
|
||||||
|
# this file catches — an inline definition that omits the keyword
|
||||||
|
# entirely, which is the actual, common case the style guide rule is
|
||||||
|
# about; that one stays a code-review judgment call. See TOOLING.md.
|
||||||
|
Checks: >
|
||||||
|
-*,
|
||||||
|
readability-identifier-naming,
|
||||||
|
readability-redundant-inline-specifier,
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+438
@@ -0,0 +1,438 @@
|
|||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
|
||||||
|
[Dd]ebug/x64/
|
||||||
|
[Dd]ebugPublic/x64/
|
||||||
|
[Rr]elease/x64/
|
||||||
|
[Rr]eleases/x64/
|
||||||
|
bin/x64/
|
||||||
|
obj/x64/
|
||||||
|
|
||||||
|
[Dd]ebug/x86/
|
||||||
|
[Dd]ebugPublic/x86/
|
||||||
|
[Rr]elease/x86/
|
||||||
|
[Rr]eleases/x86/
|
||||||
|
bin/x86/
|
||||||
|
obj/x86/
|
||||||
|
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
[Aa][Rr][Mm]64[Ee][Cc]/
|
||||||
|
bld/
|
||||||
|
[Oo]bj/
|
||||||
|
[Oo]ut/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Build results on 'Bin' directories
|
||||||
|
**/[Bb]in/*
|
||||||
|
# Uncomment if you have tasks that rely on *.refresh files to move binaries
|
||||||
|
# (https://github.com/github/gitignore/pull/3736)
|
||||||
|
#!**/[Bb]in/*.refresh
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
*.trx
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Approval Tests result files
|
||||||
|
*.received.*
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET Core
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
.artifacts/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.idb
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||||
|
!Directory.Build.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
**/.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
**/.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
**/.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
#tools/**
|
||||||
|
#!tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
MSBuild_Logs/
|
||||||
|
|
||||||
|
# AWS SAM Build and Temporary Artifacts folder
|
||||||
|
.aws-sam
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
**/.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
**/.localhistory/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
**/.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Built Visual Studio Code Extensions
|
||||||
|
*.vsix
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# >>> Configuration.cmake generated paths -- BEGIN (do not edit by hand; edit Configuration.cmake and reconfigure) >>>
|
||||||
|
.cache/tools/
|
||||||
|
compile_commands.json
|
||||||
|
out/
|
||||||
|
out/dist/
|
||||||
|
out/docs/
|
||||||
|
out/install/
|
||||||
|
# <<< Configuration.cmake generated paths -- END <<<
|
||||||
+359
@@ -0,0 +1,359 @@
|
|||||||
|
### 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)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# -------------------------------------------------------
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# -------------------------------------------------------
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# -------------------------------------------------------
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# -------------------------------------------------------
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
# -------------------------------------------------------
|
||||||
|
# Hot reload -- see cmake/HotReload.cmake for
|
||||||
|
# hot_reload_enable_patchable_functions(<target>). Call it on any
|
||||||
|
# SHARED module target you intend to hot-reload via source/HotReload;
|
||||||
|
# see source/examples/HotReloadDemo for a full worked example.
|
||||||
|
# -------------------------------------------------------
|
||||||
|
include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/HotReload.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"
|
||||||
|
"${COMPILE_COMMANDS_DESTINATION}"
|
||||||
|
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()
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 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 Ninja and Doxygen), kept
|
||||||
|
# separate so a clean `rm -rf out/` doesn't force a re-download.
|
||||||
|
#
|
||||||
|
# .gitignore is kept in sync with the paths below automatically, every
|
||||||
|
# configure -- see the bottom of this file. This file is the source of
|
||||||
|
# truth; .gitignore (and anything else that needs these paths) should
|
||||||
|
# never hardcode a copy of them.
|
||||||
|
#
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
set(COMPILE_COMMANDS_DESTINATION "${CMAKE_SOURCE_DIR}/compile_commands.json" CACHE FILEPATH
|
||||||
|
"Where compile_commands.json is copied to after every build, for clangd/clang-tidy tooling (see CMakeLists.txt)")
|
||||||
|
|
||||||
|
# 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 Ninja and 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)")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# .gitignore sync
|
||||||
|
#
|
||||||
|
# Keeps a marked block in .gitignore in sync with the actual, current
|
||||||
|
# values of the path variables above -- every configure, automatically.
|
||||||
|
# Nothing outside that block (e.g. the Visual Studio template this
|
||||||
|
# project's .gitignore starts from) is ever touched. This is what makes
|
||||||
|
# this file the real source of truth: change OUT_DIR (or override it with
|
||||||
|
# -DOUT_DIR=...) and .gitignore updates on the next configure instead of
|
||||||
|
# silently drifting out of sync with what actually gets generated.
|
||||||
|
#
|
||||||
|
# If .gitignore doesn't exist yet, it's created with just this block. If
|
||||||
|
# it exists but has no markers yet, the block is appended. If the markers
|
||||||
|
# are already there, only the content between them is replaced. The file
|
||||||
|
# is only rewritten when the computed content actually differs, so a
|
||||||
|
# normal reconfigure doesn't spuriously touch it (or its mtime).
|
||||||
|
#
|
||||||
|
# A path is only added if it resolves inside the repo (CMAKE_SOURCE_DIR)
|
||||||
|
# -- e.g. an overridden TOOLCACHE_DIR pointing somewhere else entirely is
|
||||||
|
# silently skipped, since .gitignore has nothing meaningful to say about
|
||||||
|
# a location outside the repository it lives in.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set(_pmn_gitignore_begin "# >>> Configuration.cmake generated paths -- BEGIN (do not edit by hand; edit Configuration.cmake and reconfigure) >>>")
|
||||||
|
set(_pmn_gitignore_end "# <<< Configuration.cmake generated paths -- END <<<")
|
||||||
|
|
||||||
|
function(_pmn_gitignore_relpath ABSOLUTE_VALUE OUT_VAR)
|
||||||
|
file(RELATIVE_PATH _pmn_rel "${CMAKE_SOURCE_DIR}" "${ABSOLUTE_VALUE}")
|
||||||
|
if(_pmn_rel MATCHES "^\\.\\.")
|
||||||
|
set(${OUT_VAR} "" PARENT_SCOPE)
|
||||||
|
else()
|
||||||
|
set(${OUT_VAR} "${_pmn_rel}" PARENT_SCOPE)
|
||||||
|
endif()
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
set(_pmn_gitignore_entries "")
|
||||||
|
foreach(_pmn_dir_var OUT_DIR DIST_OUTPUT_DIR INSTALL_OUTPUT_DIR DOCS_OUTPUT_DIR TOOLCACHE_DIR)
|
||||||
|
_pmn_gitignore_relpath("${${_pmn_dir_var}}" _pmn_rel)
|
||||||
|
if(_pmn_rel)
|
||||||
|
list(APPEND _pmn_gitignore_entries "${_pmn_rel}/")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
_pmn_gitignore_relpath("${COMPILE_COMMANDS_DESTINATION}" _pmn_cc_rel)
|
||||||
|
if(_pmn_cc_rel)
|
||||||
|
list(APPEND _pmn_gitignore_entries "${_pmn_cc_rel}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
list(REMOVE_DUPLICATES _pmn_gitignore_entries)
|
||||||
|
list(SORT _pmn_gitignore_entries)
|
||||||
|
|
||||||
|
set(_pmn_gitignore_block_content "")
|
||||||
|
foreach(_pmn_entry ${_pmn_gitignore_entries})
|
||||||
|
string(APPEND _pmn_gitignore_block_content "${_pmn_entry}\n")
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
set(_pmn_gitignore_path "${CMAKE_SOURCE_DIR}/.gitignore")
|
||||||
|
set(_pmn_gitignore_new_block "${_pmn_gitignore_begin}\n${_pmn_gitignore_block_content}${_pmn_gitignore_end}")
|
||||||
|
|
||||||
|
if(EXISTS "${_pmn_gitignore_path}")
|
||||||
|
file(READ "${_pmn_gitignore_path}" _pmn_gitignore_content)
|
||||||
|
else()
|
||||||
|
set(_pmn_gitignore_content "")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
string(FIND "${_pmn_gitignore_content}" "${_pmn_gitignore_begin}" _pmn_gitignore_begin_pos)
|
||||||
|
string(FIND "${_pmn_gitignore_content}" "${_pmn_gitignore_end}" _pmn_gitignore_end_pos)
|
||||||
|
|
||||||
|
if(_pmn_gitignore_begin_pos GREATER -1 AND _pmn_gitignore_end_pos GREATER -1
|
||||||
|
AND _pmn_gitignore_end_pos GREATER _pmn_gitignore_begin_pos)
|
||||||
|
# Replace only the content between the existing markers.
|
||||||
|
string(SUBSTRING "${_pmn_gitignore_content}" 0 ${_pmn_gitignore_begin_pos} _pmn_gitignore_before)
|
||||||
|
string(LENGTH "${_pmn_gitignore_end}" _pmn_gitignore_end_marker_len)
|
||||||
|
math(EXPR _pmn_gitignore_after_start "${_pmn_gitignore_end_pos} + ${_pmn_gitignore_end_marker_len}")
|
||||||
|
string(SUBSTRING "${_pmn_gitignore_content}" ${_pmn_gitignore_after_start} -1 _pmn_gitignore_after)
|
||||||
|
set(_pmn_gitignore_final "${_pmn_gitignore_before}${_pmn_gitignore_new_block}${_pmn_gitignore_after}")
|
||||||
|
else()
|
||||||
|
# No markers yet -- append (trimming trailing blank lines first so we
|
||||||
|
# don't accumulate extra blank lines across repeated appends).
|
||||||
|
string(REGEX REPLACE "\n+$" "" _pmn_gitignore_trimmed "${_pmn_gitignore_content}")
|
||||||
|
if(_pmn_gitignore_trimmed STREQUAL "")
|
||||||
|
set(_pmn_gitignore_final "${_pmn_gitignore_new_block}\n")
|
||||||
|
else()
|
||||||
|
set(_pmn_gitignore_final "${_pmn_gitignore_trimmed}\n\n${_pmn_gitignore_new_block}\n")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT _pmn_gitignore_final STREQUAL _pmn_gitignore_content)
|
||||||
|
file(WRITE "${_pmn_gitignore_path}" "${_pmn_gitignore_final}")
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
# CMake module-system demo
|
||||||
|
|
||||||
|
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 via a
|
||||||
|
pinned Doxygen that's entirely opt-in (never required just to build),
|
||||||
|
Ninja itself is pinned and fetched the same way with a graceful fallback
|
||||||
|
if it's unavailable, and every output path is configured in one place.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
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 per-module Doxygen integration -- only ever LOOKS for a pinned Doxygen
|
||||||
|
cmake/DoxygenPin.cmake shared version/URL/hash parameters for the pinned Doxygen
|
||||||
|
cmake/FetchDoxygen.cmake standalone downloader (run via `cmake -P`, not during normal configure)
|
||||||
|
cmake/NinjaPin.cmake shared version/URL/hash parameters for the pinned Ninja
|
||||||
|
cmake/FetchNinja.cmake standalone downloader (run via `cmake -P`, not during normal configure)
|
||||||
|
cmake/HotReload.cmake hot_reload_enable_patchable_functions() -- see source/HotReload/README.md
|
||||||
|
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
|
||||||
|
source/HotReload/CMakeLists.txt library module "hotreload_lib" (STATIC) -- see source/HotReload/README.md
|
||||||
|
source/examples/HotReloadDemo/ worked example: a host + two builds of one hot-reloadable plugin
|
||||||
|
source/examples/HotReloadLiveDemo/ interactive: a persistent host + one plugin file you edit yourself
|
||||||
|
vendor/tiny_ansi/ header-only vendored dependency (no build of its own)
|
||||||
|
vendor/tiny_case/ vendored dependency with its own CMakeLists.txt
|
||||||
|
scripts/setup.sh, setup.bat one-time: fetch pinned Ninja + Doxygen, check tooling, activate git hook
|
||||||
|
scripts/build.sh, build.bat configure + build -- prefers a pinned Ninja, falls back gracefully
|
||||||
|
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) -- needs setup.sh first
|
||||||
|
scripts/refactor.sh, refactor.bat auto-fix formatting/naming across this project's own code
|
||||||
|
.clang-format, .clang-tidy C++ style guide tooling (see "Code style & tooling" below)
|
||||||
|
.editorconfig, .gitattributes -- same
|
||||||
|
.gitignore Visual Studio template + a block auto-synced from Configuration.cmake
|
||||||
|
c-api/.clang-tidy naming override template for a C API subtree -- now also applied for
|
||||||
|
real at source/HotReload/src/c-api/ and source/HotReload/include/hot_reload/
|
||||||
|
.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 Ninja and Doxygen) are cached -- kept outside `OUT_DIR` so `rm -rf out/` doesn't force a re-download |
|
||||||
|
| `COMPILE_COMMANDS_DESTINATION` | `compile_commands.json` | Where `compile_commands.json` is copied to after every build (see "compile_commands.json / clang tooling" below) |
|
||||||
|
| `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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.gitignore` stays in sync automatically
|
||||||
|
|
||||||
|
`Configuration.cmake` is the single source of truth for every path above
|
||||||
|
-- `.gitignore` doesn't hardcode its own copy of them. Every configure,
|
||||||
|
a marked block near the bottom of `.gitignore` is regenerated from the
|
||||||
|
*current* value of each path variable:
|
||||||
|
|
||||||
|
```
|
||||||
|
# >>> Configuration.cmake generated paths -- BEGIN (do not edit by hand; edit Configuration.cmake and reconfigure) >>>
|
||||||
|
.cache/tools/
|
||||||
|
compile_commands.json
|
||||||
|
out/
|
||||||
|
out/dist/
|
||||||
|
out/docs/
|
||||||
|
out/install/
|
||||||
|
# <<< Configuration.cmake generated paths -- END <<<
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `OUT_DIR` (or any of the others, permanently in `Configuration.cmake`
|
||||||
|
or per-configure with `-D...=...`) and this block updates to match on the
|
||||||
|
next configure -- nothing to keep in sync by hand. Everything outside the
|
||||||
|
block is untouched: `.gitignore` ships based on GitHub's
|
||||||
|
[`VisualStudio.gitignore`](https://github.com/github/gitignore/blob/main/VisualStudio.gitignore)
|
||||||
|
template, and that content is never rewritten. A path is only added if it
|
||||||
|
resolves *inside* the repo -- pointing `TOOLCACHE_DIR` somewhere else
|
||||||
|
entirely just omits it, since `.gitignore` has nothing useful to say about
|
||||||
|
a location outside the repository it lives in. The file is only rewritten
|
||||||
|
when the computed block actually differs from what's already there, so a
|
||||||
|
normal reconfigure doesn't spuriously touch its contents or mtime.
|
||||||
|
|
||||||
|
## 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, entirely opt-in)
|
||||||
|
|
||||||
|
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 for free -- nothing
|
||||||
|
to add to a module's own `CMakeLists.txt`. This project uses a pinned
|
||||||
|
Doxygen (currently **1.16.1**, see `DOXYGEN_PINNED_VERSION` in
|
||||||
|
`cmake/DoxygenPin.cmake`) -- 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`.
|
||||||
|
|
||||||
|
**Building the project never needs Doxygen.** `cmake/Documentation.cmake`
|
||||||
|
only ever *looks* for an already-fetched pinned Doxygen under
|
||||||
|
`.cache/tools/` (`TOOLCACHE_DIR`) -- it never downloads anything itself
|
||||||
|
during a normal configure, so `scripts/build.sh` has no Doxygen/network
|
||||||
|
dependency at all. Fetching it is a separate, explicit, one-time step:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/setup.sh # see "Setup" below -- fetches the pinned Doxygen once
|
||||||
|
```
|
||||||
|
|
||||||
|
Until you've run that, `scripts/docs.sh` prints a clear message instead of
|
||||||
|
building anything -- everything else (the actual C++ project) builds
|
||||||
|
completely normally either way:
|
||||||
|
|
||||||
|
```
|
||||||
|
Documentation isn't set up yet (or ENABLE_DOXYGEN=OFF). Run scripts/setup.sh (or setup.bat) once to fetch the pinned Doxygen, then reconfigure.
|
||||||
|
```
|
||||||
|
|
||||||
|
Once set up:
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
**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 (docs targets print a message instead of building):
|
||||||
|
cmake -S . -B out/build -DENABLE_DOXYGEN=OFF
|
||||||
|
|
||||||
|
# Pin a different Doxygen version (also update the two SHA256 hashes in
|
||||||
|
# cmake/DoxygenPin.cmake to match that release's assets -- a stale hash
|
||||||
|
# fails scripts/setup.sh loudly, on purpose, rather than silently
|
||||||
|
# accepting an unexpected file -- then re-run scripts/setup.sh):
|
||||||
|
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 |
|
||||||
|
|
||||||
|
**Fixing violations automatically**, instead of hunting them down by hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/refactor.sh # every file under source/ and c-api/ by default
|
||||||
|
scripts/refactor.sh source/Core # just one file or directory
|
||||||
|
```
|
||||||
|
```bat
|
||||||
|
scripts\refactor.bat
|
||||||
|
scripts\refactor.bat source\Core
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs `clang-format -i` (always) and `clang-tidy --fix` (if
|
||||||
|
`compile_commands.json` exists -- build once first if it doesn't) across
|
||||||
|
the given scope. The pre-commit hook only *checks*; this is the "just fix
|
||||||
|
it" counterpart -- review the result with `git diff` afterward, same as
|
||||||
|
any auto-formatter. This is exactly how this project's own code
|
||||||
|
(`source/`) was brought into compliance when the toolkit was integrated --
|
||||||
|
see the renames noted further down.
|
||||||
|
|
||||||
|
With no argument, only `source/` and `c-api/` are ever scanned -- `out/`,
|
||||||
|
`.cache/`, `vendor/`, and `.git/` are never walked, so this can't
|
||||||
|
accidentally spend minutes reformatting/analyzing a fetched dependency's
|
||||||
|
entire source tree (verified against `out/build/_deps/fmt-src/`
|
||||||
|
specifically, since that's what triggered this exact problem before the
|
||||||
|
fix). Passing an explicit scope takes it as given: `refactor.sh` still
|
||||||
|
excludes `vendor`/`out`/`.cache`/`.git` defensively if your scope happens
|
||||||
|
to overlap with them; `refactor.bat` does not (its Windows batch
|
||||||
|
equivalent of that filter turned out to be unreliable to verify, so it
|
||||||
|
was removed rather than shipped uncertain -- pick a scope that doesn't
|
||||||
|
overlap with those directories, which is the normal case anyway, e.g.
|
||||||
|
`source\Core`).
|
||||||
|
|
||||||
|
**One-time setup per clone** to activate the hook -- `scripts/setup.sh` /
|
||||||
|
`setup.bat` does this for you (see "Setup" below) along with fetching the
|
||||||
|
pinned Doxygen and checking for clang-format/clang-tidy; the two commands
|
||||||
|
it runs for the hook specifically are:
|
||||||
|
|
||||||
|
```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, 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 (for `fmt`, cached afterward
|
||||||
|
under `out/build/_deps`). Notably **not** in this list: Ninja and Doxygen
|
||||||
|
are both pinned and fetched automatically (see "Setup" below), not system
|
||||||
|
dependencies, and `scripts/build.sh` warns rather than fails if Ninja
|
||||||
|
isn't available at all (see "Build" below).
|
||||||
|
[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 and `scripts/refactor.sh` (see "Code style & tooling"
|
||||||
|
above) -- both are detected, not pinned, since they're editor/workflow
|
||||||
|
tools rather than part of the build itself. `scripts/setup.sh` (below)
|
||||||
|
checks for both and prints install guidance if either is missing.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Optional, and separate from building (see "Requirements", "Build", and
|
||||||
|
"Documentation" above) -- run once per clone, or any time you want to
|
||||||
|
pick up a newer pinned version after bumping `NINJA_PINNED_VERSION` or
|
||||||
|
`DOXYGEN_PINNED_VERSION`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/setup.sh
|
||||||
|
```
|
||||||
|
```bat
|
||||||
|
scripts\setup.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Does four things, each independent of the others:
|
||||||
|
|
||||||
|
1. Fetches the pinned Ninja (`cmake/FetchNinja.cmake`) -- used by
|
||||||
|
`scripts/build.sh`/`build.bat` if present (see "Build" below); not
|
||||||
|
required, a system Ninja works fine too.
|
||||||
|
2. Fetches the pinned Doxygen (`cmake/FetchDoxygen.cmake`) -- needed for
|
||||||
|
`scripts/docs.sh`, not for building the project.
|
||||||
|
3. Checks for `clang-format`/`clang-tidy` on `PATH` and prints
|
||||||
|
OS-appropriate install guidance if either is missing (never
|
||||||
|
auto-installs -- that would need assumptions about your package
|
||||||
|
manager and elevated privileges this script shouldn't assume it has).
|
||||||
|
4. Activates the pre-commit hook for this clone, if it's a git repository
|
||||||
|
(`git config core.hooksPath .githooks`).
|
||||||
|
|
||||||
|
Safe to re-run any time -- each step is a no-op if there's nothing to do
|
||||||
|
(e.g. the pinned Ninja/Doxygen are already cached).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/build.sh # Release (default)
|
||||||
|
scripts/build.sh Debug # or Debug / RelWithDebInfo / MinSizeRel
|
||||||
|
```
|
||||||
|
```bat
|
||||||
|
scripts\build.bat
|
||||||
|
scripts\build.bat Debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Picks a generator in this order, each falling back to the next:
|
||||||
|
|
||||||
|
1. **A pinned Ninja**, if `scripts/setup.sh`/`setup.bat` has fetched one --
|
||||||
|
passed to CMake explicitly via `-DCMAKE_MAKE_PROGRAM=...` so it's used
|
||||||
|
even if a different Ninja also happens to be on `PATH` (same "always
|
||||||
|
the pinned copy" preference as Doxygen -- see "Documentation" above).
|
||||||
|
2. **A system Ninja**, if one is on `PATH` and no pinned copy was found.
|
||||||
|
3. **CMake's own default generator** for the platform, if neither Ninja is
|
||||||
|
available anywhere -- `scripts/build.sh`/`build.bat` print a warning
|
||||||
|
and continue rather than fail. (`CMakeLists.txt` separately warns too,
|
||||||
|
once CMake reports which generator it actually picked -- this is the
|
||||||
|
pre-existing "This project targets Ninja" message, unrelated to this
|
||||||
|
script.)
|
||||||
|
|
||||||
|
Verified concretely, not assumed: with neither a pinned nor a system Ninja
|
||||||
|
present anywhere, `scripts/build.sh` still successfully configures, builds,
|
||||||
|
and produces working binaries -- it just uses whatever CMake's default
|
||||||
|
generator is for the platform (Unix Makefiles on Linux, typically NMake or
|
||||||
|
a Visual Studio generator on Windows) instead.
|
||||||
|
|
||||||
|
## 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/`.
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
# 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. Also partially supports [Class Member Organization](cpp-style-guide.md#class-member-organization): `AllowShortFunctionsOnASingleLine: InlineOnly` lets the trivial-one-liner exception stay a compact one-liner in the class body while still fully Allman-expanding anything defined out-of-class — it doesn't enforce the rest of that section (member order, where an inline template member sits within its section).
|
||||||
|
|
||||||
|
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.
|
||||||
|
- `readability-redundant-inline-specifier` → [Class Member Organization](cpp-style-guide.md#class-member-organization): flags an explicit `inline` keyword that's already redundant (e.g. on a function defined in the class body). A narrow, complementary check — it doesn't catch an inline body that omits the keyword, which is the common case that rule is actually about.
|
||||||
|
|
||||||
|
## `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.
|
||||||
|
- **Doxygen comment syntax over plain `//`** in [§9](cpp-style-guide.md#9-comments) — `clang-format` doesn't rewrite comment styles, and no standard `clang-tidy` check flags a plain `//` comment sitting above a declaration. Doxygen itself (this project's `scripts/docs.sh`) will happily generate a page for an undocumented symbol; it doesn't know or care what style a comment above it uses.
|
||||||
|
- **Blank line after an early exit** (`return`/`break`/`continue`) in [§12](cpp-style-guide.md#12-miscellaneous) — neither tool has a check tied to specific statement kinds like this.
|
||||||
|
- **Member order (`public`/`protected`/`private`) and inline-template-member placement** in [Class Member Organization](cpp-style-guide.md#class-member-organization) — no standard check inspects access-specifier ordering or where a member is positioned within a section.
|
||||||
|
- **Avoiding inline member function definitions** in [Class Member Organization](cpp-style-guide.md#class-member-organization) — `readability-redundant-inline-specifier` (`.clang-tidy`) catches one narrow related mistake, a redundant explicit `inline` keyword, but not an inline body that omits the keyword, which is the common case.
|
||||||
@@ -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_
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 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 ...)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Documentation.cmake
|
||||||
|
#
|
||||||
|
# Automatic, per-module Doxygen documentation, using a pinned Doxygen
|
||||||
|
# version -- never your system's doxygen, so every machine gets identical
|
||||||
|
# output. Zero setup required at the CMakeLists.txt level: 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.
|
||||||
|
#
|
||||||
|
# IMPORTANT: this file only ever LOOKS for an already-downloaded pinned
|
||||||
|
# Doxygen (see cmake/DoxygenPin.cmake for where) -- it never downloads
|
||||||
|
# anything itself, so a plain `scripts/build.sh` never touches Doxygen or
|
||||||
|
# needs network access. Run `scripts/setup.sh` / `setup.bat` once (which
|
||||||
|
# runs cmake/FetchDoxygen.cmake) to actually fetch it; until then,
|
||||||
|
# documentation targets print a message pointing at that script instead
|
||||||
|
# of building, and everything else builds completely normally.
|
||||||
|
#
|
||||||
|
# What gets generated, per module <target>, once set up:
|
||||||
|
# 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 even the presence check)
|
||||||
|
# -DDOXYGEN_PINNED_VERSION=1.17.0 use a different pinned version (also
|
||||||
|
# update the two SHA256 hashes in cmake/DoxygenPin.cmake to match
|
||||||
|
# that release's assets, and re-run scripts/setup.sh)
|
||||||
|
# -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)
|
||||||
|
|
||||||
|
option(ENABLE_DOXYGEN "Generate per-module Doxygen documentation using a pinned, project-managed Doxygen (run scripts/setup.sh once to fetch it)" ON)
|
||||||
|
|
||||||
|
if(ENABLE_DOXYGEN)
|
||||||
|
include("${CMAKE_CURRENT_LIST_DIR}/DoxygenPin.cmake")
|
||||||
|
|
||||||
|
if(NOT DOXYGEN_PIN_PLATFORM_SUPPORTED)
|
||||||
|
message(WARNING "No pinned Doxygen ${DOXYGEN_PINNED_VERSION} binary is configured for this "
|
||||||
|
"host platform. Install Doxygen ${DOXYGEN_PINNED_VERSION} manually, make sure "
|
||||||
|
"it's on PATH or pass -DDOXYGEN_EXECUTABLE=/path/to/doxygen, or extend "
|
||||||
|
"cmake/DoxygenPin.cmake with a download URL + hash for this platform.")
|
||||||
|
set(ENABLE_DOXYGEN OFF)
|
||||||
|
elseif(NOT EXISTS "${DOXYGEN_PIN_EXECUTABLE}")
|
||||||
|
# Not fetched yet -- see the file header. Deliberately not a
|
||||||
|
# message() here: this is the normal, expected state before
|
||||||
|
# scripts/setup.sh has ever run, not something to warn about.
|
||||||
|
set(ENABLE_DOXYGEN OFF)
|
||||||
|
else()
|
||||||
|
# Pre-seed DOXYGEN_EXECUTABLE with our pinned path *before*
|
||||||
|
# find_package(Doxygen) runs, rather than overriding it
|
||||||
|
# afterward. find_program() (which find_package uses internally)
|
||||||
|
# does not re-search when the cache variable already holds a
|
||||||
|
# valid path -- so this makes find_package() treat our pinned
|
||||||
|
# copy as the one it found, and run its own normal success path
|
||||||
|
# using it: creating the Doxygen::doxygen imported target,
|
||||||
|
# determining DOXYGEN_VERSION, and generating the Doxyfile
|
||||||
|
# template doxygen_add_docs() needs. All of that only happens as
|
||||||
|
# a side effect of find_package() succeeding -- overriding
|
||||||
|
# DOXYGEN_EXECUTABLE *after* calling it (the previous approach
|
||||||
|
# here) leaves those steps never having run at all, which
|
||||||
|
# doxygen_add_docs() then fails on. This way we don't have to
|
||||||
|
# reimplement any of find_package's own (undocumented, and
|
||||||
|
# confirmed version-dependent -- CMake 3.28 and 4.3 both hit
|
||||||
|
# this differently) internal machinery ourselves.
|
||||||
|
set(DOXYGEN_EXECUTABLE "${DOXYGEN_PIN_EXECUTABLE}" CACHE FILEPATH "Pinned Doxygen executable (never the system's)" FORCE)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(ENABLE_DOXYGEN)
|
||||||
|
# Also searches for `dot` (Graphviz) for call graphs -- an optional,
|
||||||
|
# system-detected enhancement, unrelated to the doxygen executable
|
||||||
|
# itself, which is pre-seeded above to always resolve to our pinned
|
||||||
|
# copy rather than a system one.
|
||||||
|
find_package(Doxygen QUIET OPTIONAL_COMPONENTS dot)
|
||||||
|
|
||||||
|
if(NOT DOXYGEN_FOUND)
|
||||||
|
message(WARNING "Pre-seeding DOXYGEN_EXECUTABLE with the pinned Doxygen at "
|
||||||
|
"${DOXYGEN_PIN_EXECUTABLE} did not result in find_package(Doxygen) "
|
||||||
|
"succeeding. Documentation will be disabled for this configure.")
|
||||||
|
set(ENABLE_DOXYGEN OFF)
|
||||||
|
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 isn't set up yet (or ENABLE_DOXYGEN=OFF). Run scripts/setup.sh (or setup.bat) once to fetch the pinned Doxygen, then reconfigure."
|
||||||
|
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()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# DoxygenPin.cmake
|
||||||
|
#
|
||||||
|
# Pure computation of "where does the pinned Doxygen live, and where would
|
||||||
|
# it be downloaded from" -- no network calls, no download, no extraction.
|
||||||
|
# Shared by two very different callers, which is why this is its own file:
|
||||||
|
#
|
||||||
|
# cmake/Documentation.cmake include()s this during the normal CMake
|
||||||
|
# configure to find out where to LOOK for an
|
||||||
|
# already-downloaded pinned Doxygen. Never
|
||||||
|
# downloads anything itself -- see that file.
|
||||||
|
#
|
||||||
|
# cmake/FetchDoxygen.cmake include()s this when run standalone via
|
||||||
|
# `cmake -P cmake/FetchDoxygen.cmake` (what
|
||||||
|
# scripts/setup.sh / setup.bat call) to know
|
||||||
|
# what to actually download.
|
||||||
|
#
|
||||||
|
# Needs TOOLCACHE_DIR to already be set -- Documentation.cmake gets it from
|
||||||
|
# Configuration.cmake (included earlier by CMakeLists.txt); FetchDoxygen.cmake
|
||||||
|
# sets it manually since script mode (`cmake -P`) never runs CMakeLists.txt.
|
||||||
|
#
|
||||||
|
# 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, on purpose, in
|
||||||
|
# FetchDoxygen.cmake rather than silently accepting an unexpected file).
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
include_guard(GLOBAL)
|
||||||
|
|
||||||
|
if(NOT DEFINED DOXYGEN_PINNED_VERSION)
|
||||||
|
set(DOXYGEN_PINNED_VERSION "1.16.1")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
string(REPLACE "." "_" _pmn_doxygen_tag_version "${DOXYGEN_PINNED_VERSION}")
|
||||||
|
set(DOXYGEN_PIN_TAG "Release_${_pmn_doxygen_tag_version}")
|
||||||
|
set(DOXYGEN_PIN_ROOT "${TOOLCACHE_DIR}/doxygen-${DOXYGEN_PINNED_VERSION}")
|
||||||
|
|
||||||
|
if(CMAKE_HOST_WIN32)
|
||||||
|
set(DOXYGEN_PIN_URL "https://github.com/doxygen/doxygen/releases/download/${DOXYGEN_PIN_TAG}/doxygen-${DOXYGEN_PINNED_VERSION}.windows.x64.bin.zip")
|
||||||
|
set(DOXYGEN_PIN_HASH "ddfa59a4ae9549651330471c2e387ec7a9891080543faed541c859e5ce448653")
|
||||||
|
set(DOXYGEN_PIN_EXECUTABLE "${DOXYGEN_PIN_ROOT}/doxygen.exe")
|
||||||
|
set(DOXYGEN_PIN_PLATFORM_SUPPORTED TRUE)
|
||||||
|
elseif(CMAKE_HOST_UNIX AND NOT CMAKE_HOST_APPLE)
|
||||||
|
set(DOXYGEN_PIN_URL "https://github.com/doxygen/doxygen/releases/download/${DOXYGEN_PIN_TAG}/doxygen-${DOXYGEN_PINNED_VERSION}.linux.bin.tar.gz")
|
||||||
|
set(DOXYGEN_PIN_HASH "a56f885d37e3aae08a99f638d17bbb381224c03a878d9e2dda4f9fa4baf1d8bd")
|
||||||
|
set(DOXYGEN_PIN_EXECUTABLE "${DOXYGEN_PIN_ROOT}/doxygen-${DOXYGEN_PINNED_VERSION}/bin/doxygen")
|
||||||
|
set(DOXYGEN_PIN_PLATFORM_SUPPORTED TRUE)
|
||||||
|
else()
|
||||||
|
set(DOXYGEN_PIN_URL "")
|
||||||
|
set(DOXYGEN_PIN_HASH "")
|
||||||
|
set(DOXYGEN_PIN_EXECUTABLE "")
|
||||||
|
set(DOXYGEN_PIN_PLATFORM_SUPPORTED FALSE)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# FetchDoxygen.cmake
|
||||||
|
#
|
||||||
|
# Downloads, verifies, and extracts the pinned Doxygen (see DoxygenPin.cmake
|
||||||
|
# for the version/URL/hash) into TOOLCACHE_DIR. Run standalone, NOT included
|
||||||
|
# by the normal CMake configure:
|
||||||
|
#
|
||||||
|
# cmake -P cmake/FetchDoxygen.cmake
|
||||||
|
#
|
||||||
|
# This is what scripts/setup.sh / setup.bat call. It's deliberately not part
|
||||||
|
# of the main `cmake -S . -B out/build` configure -- see cmake/Documentation.cmake,
|
||||||
|
# which only ever LOOKS for the result of this script and never downloads
|
||||||
|
# anything itself, so a plain build never depends on Doxygen or on network
|
||||||
|
# access. Safe to re-run any time; a no-op if the pinned version is already
|
||||||
|
# present.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# TOOLCACHE_DIR normally comes from Configuration.cmake, which isn't loaded
|
||||||
|
# in this standalone script-mode invocation -- match its default here. If
|
||||||
|
# you've overridden TOOLCACHE_DIR via -DTOOLCACHE_DIR=... at configure time,
|
||||||
|
# pass the same value here: cmake -DTOOLCACHE_DIR=... -P cmake/FetchDoxygen.cmake
|
||||||
|
if(NOT DEFINED TOOLCACHE_DIR)
|
||||||
|
get_filename_component(_pmn_project_root "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
|
||||||
|
set(TOOLCACHE_DIR "${_pmn_project_root}/.cache/tools")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("${CMAKE_CURRENT_LIST_DIR}/DoxygenPin.cmake")
|
||||||
|
|
||||||
|
if(NOT DOXYGEN_PIN_PLATFORM_SUPPORTED)
|
||||||
|
message(FATAL_ERROR "No pinned Doxygen ${DOXYGEN_PINNED_VERSION} binary is configured for this host "
|
||||||
|
"platform. Install Doxygen ${DOXYGEN_PINNED_VERSION} manually, make sure it's on "
|
||||||
|
"PATH or pass -DDOXYGEN_EXECUTABLE=/path/to/doxygen at project-configure time, or "
|
||||||
|
"extend cmake/DoxygenPin.cmake with a download URL + hash for this platform.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(EXISTS "${DOXYGEN_PIN_EXECUTABLE}")
|
||||||
|
message(STATUS "Doxygen ${DOXYGEN_PINNED_VERSION} already present: ${DOXYGEN_PIN_EXECUTABLE}")
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
message(STATUS "Downloading pinned Doxygen ${DOXYGEN_PINNED_VERSION} (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 turn a "you're
|
||||||
|
# offline" hiccup into a worse error than it needs to be. Downloading
|
||||||
|
# first and verifying the hash ourselves afterward lets us give a clearer
|
||||||
|
# message for a network failure, while still treating a genuine hash
|
||||||
|
# mismatch (see below) as fully fatal.
|
||||||
|
file(DOWNLOAD "${DOXYGEN_PIN_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(FATAL_ERROR "Failed to download pinned Doxygen ${DOXYGEN_PINNED_VERSION}: ${_pmn_doxygen_dl_msg}. "
|
||||||
|
"Check your network connection and try again -- your build doesn't need this to "
|
||||||
|
"succeed, only `scripts/docs.sh` / `docs.bat` does.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Verify integrity, 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 in DoxygenPin.cmake without updating the
|
||||||
|
# matching hash there): a supply-chain-relevant integrity failure, not
|
||||||
|
# something to silently continue past.
|
||||||
|
file(SHA256 "${_pmn_doxygen_archive}" _pmn_doxygen_actual_hash)
|
||||||
|
string(TOLOWER "${_pmn_doxygen_actual_hash}" _pmn_doxygen_actual_hash)
|
||||||
|
string(TOLOWER "${DOXYGEN_PIN_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 in cmake/DoxygenPin.cmake "
|
||||||
|
"without updating the matching hash there, or the download was corrupted or "
|
||||||
|
"tampered with in transit.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
file(ARCHIVE_EXTRACT INPUT "${_pmn_doxygen_archive}" DESTINATION "${DOXYGEN_PIN_ROOT}")
|
||||||
|
file(REMOVE "${_pmn_doxygen_archive}")
|
||||||
|
|
||||||
|
if(NOT CMAKE_HOST_WIN32)
|
||||||
|
file(CHMOD "${DOXYGEN_PIN_EXECUTABLE}" PERMISSIONS
|
||||||
|
OWNER_READ OWNER_WRITE OWNER_EXECUTE
|
||||||
|
GROUP_READ GROUP_EXECUTE
|
||||||
|
WORLD_READ WORLD_EXECUTE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(EXISTS "${DOXYGEN_PIN_EXECUTABLE}")
|
||||||
|
message(STATUS "Doxygen ${DOXYGEN_PINNED_VERSION} ready: ${DOXYGEN_PIN_EXECUTABLE}")
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "Download/extract completed but the expected executable is missing: "
|
||||||
|
"${DOXYGEN_PIN_EXECUTABLE}")
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# FetchNinja.cmake
|
||||||
|
#
|
||||||
|
# Downloads, verifies, and extracts the pinned ninja (see NinjaPin.cmake for
|
||||||
|
# the version/URL/hash) into TOOLCACHE_DIR. Run standalone, never included by
|
||||||
|
# the normal CMake configure -- ninja is needed as the generator itself
|
||||||
|
# (`cmake -G Ninja`), so it has to be resolved *before* configure runs, unlike
|
||||||
|
# Doxygen (see FetchDoxygen.cmake), which is only needed for a downstream
|
||||||
|
# custom target:
|
||||||
|
#
|
||||||
|
# cmake -P cmake/FetchNinja.cmake
|
||||||
|
#
|
||||||
|
# This is what scripts/setup.sh / setup.bat call, and also what scripts/
|
||||||
|
# build.sh / build.bat call on demand if neither a pinned nor a system ninja
|
||||||
|
# is found (see the comment there). Safe to re-run any time; a no-op if the
|
||||||
|
# pinned version is already present.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# TOOLCACHE_DIR normally comes from Configuration.cmake, which isn't loaded
|
||||||
|
# in this standalone script-mode invocation -- match its default here. If
|
||||||
|
# you've overridden TOOLCACHE_DIR via -DTOOLCACHE_DIR=... at configure time,
|
||||||
|
# pass the same value here: cmake -DTOOLCACHE_DIR=... -P cmake/FetchNinja.cmake
|
||||||
|
if(NOT DEFINED TOOLCACHE_DIR)
|
||||||
|
get_filename_component(_pmn_project_root "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
|
||||||
|
set(TOOLCACHE_DIR "${_pmn_project_root}/.cache/tools")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("${CMAKE_CURRENT_LIST_DIR}/NinjaPin.cmake")
|
||||||
|
|
||||||
|
if(NOT NINJA_PIN_PLATFORM_SUPPORTED)
|
||||||
|
message(FATAL_ERROR "No pinned ninja ${NINJA_PINNED_VERSION} binary is configured for this host "
|
||||||
|
"platform. Install ninja manually and make sure it's on PATH, or extend "
|
||||||
|
"cmake/NinjaPin.cmake with a download URL + hash for this platform.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(EXISTS "${NINJA_PIN_EXECUTABLE}")
|
||||||
|
message(STATUS "ninja ${NINJA_PINNED_VERSION} already present: ${NINJA_PIN_EXECUTABLE}")
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
message(STATUS "Downloading pinned ninja ${NINJA_PINNED_VERSION} (cached under ${TOOLCACHE_DIR})")
|
||||||
|
set(_pmn_ninja_archive "${TOOLCACHE_DIR}/_ninja_download.archive")
|
||||||
|
|
||||||
|
# No EXPECTED_HASH here on purpose -- see the identical comment in
|
||||||
|
# FetchDoxygen.cmake: file(DOWNLOAD ... EXPECTED_HASH ...) raises a hard,
|
||||||
|
# unrecoverable CMake Error on ANY failure, including plain network
|
||||||
|
# failures. Downloading first and verifying the hash ourselves afterward
|
||||||
|
# lets a network failure and a genuine integrity failure be told apart and
|
||||||
|
# handled differently (see below).
|
||||||
|
file(DOWNLOAD "${NINJA_PIN_URL}" "${_pmn_ninja_archive}"
|
||||||
|
STATUS _pmn_ninja_dl_status
|
||||||
|
SHOW_PROGRESS
|
||||||
|
)
|
||||||
|
list(GET _pmn_ninja_dl_status 0 _pmn_ninja_dl_code)
|
||||||
|
if(NOT _pmn_ninja_dl_code EQUAL 0)
|
||||||
|
list(GET _pmn_ninja_dl_status 1 _pmn_ninja_dl_msg)
|
||||||
|
file(REMOVE "${_pmn_ninja_archive}")
|
||||||
|
message(FATAL_ERROR "Failed to download pinned ninja ${NINJA_PINNED_VERSION}: ${_pmn_ninja_dl_msg}. "
|
||||||
|
"Check your network connection and try again, or install ninja yourself and "
|
||||||
|
"make sure it's on PATH -- scripts/build.sh / build.bat will use a system ninja "
|
||||||
|
"if no pinned one is found.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Verify integrity, deliberately as a hard failure -- see the identical
|
||||||
|
# comment in FetchDoxygen.cmake: a mismatch here means corruption/tampering
|
||||||
|
# in transit, or a stale pinned hash after bumping NINJA_PINNED_VERSION in
|
||||||
|
# NinjaPin.cmake without updating the matching hash there.
|
||||||
|
file(SHA256 "${_pmn_ninja_archive}" _pmn_ninja_actual_hash)
|
||||||
|
string(TOLOWER "${_pmn_ninja_actual_hash}" _pmn_ninja_actual_hash)
|
||||||
|
string(TOLOWER "${NINJA_PIN_HASH}" _pmn_ninja_expected_hash_lc)
|
||||||
|
if(NOT _pmn_ninja_actual_hash STREQUAL _pmn_ninja_expected_hash_lc)
|
||||||
|
file(REMOVE "${_pmn_ninja_archive}")
|
||||||
|
message(FATAL_ERROR "Pinned ninja ${NINJA_PINNED_VERSION} download hash mismatch -- "
|
||||||
|
"expected ${_pmn_ninja_expected_hash_lc}, got ${_pmn_ninja_actual_hash}. "
|
||||||
|
"This usually means NINJA_PINNED_VERSION was bumped in cmake/NinjaPin.cmake "
|
||||||
|
"without updating the matching hash there, or the download was corrupted or "
|
||||||
|
"tampered with in transit.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
file(ARCHIVE_EXTRACT INPUT "${_pmn_ninja_archive}" DESTINATION "${NINJA_PIN_ROOT}")
|
||||||
|
file(REMOVE "${_pmn_ninja_archive}")
|
||||||
|
|
||||||
|
if(NOT CMAKE_HOST_WIN32)
|
||||||
|
file(CHMOD "${NINJA_PIN_EXECUTABLE}" PERMISSIONS
|
||||||
|
OWNER_READ OWNER_WRITE OWNER_EXECUTE
|
||||||
|
GROUP_READ GROUP_EXECUTE
|
||||||
|
WORLD_READ WORLD_EXECUTE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(EXISTS "${NINJA_PIN_EXECUTABLE}")
|
||||||
|
message(STATUS "ninja ${NINJA_PINNED_VERSION} ready: ${NINJA_PIN_EXECUTABLE}")
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "Download/extract completed but the expected executable is missing: "
|
||||||
|
"${NINJA_PIN_EXECUTABLE}")
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# HotReload.cmake
|
||||||
|
#
|
||||||
|
# One function: hot_reload_enable_patchable_functions(<target>).
|
||||||
|
# Call it on any SHARED library target whose exported functions you
|
||||||
|
# intend to hot-reload with source/HotReload. It applies whichever
|
||||||
|
# compiler flag guarantees enough safely-overwritable space at each
|
||||||
|
# function's entry point for this library's jump-patch backends
|
||||||
|
# (source/HotReload/src/Architecture.cpp) to write into -- without it,
|
||||||
|
# HotReloadContext::reload() falls back to the compiled function's
|
||||||
|
# symbol size (ELF only -- see SymbolTable.cpp) and silently skips
|
||||||
|
# (HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL) anything too small, rather than
|
||||||
|
# corrupting whatever comes after it in memory, but a target built
|
||||||
|
# with this flag applied should never actually hit that fallback.
|
||||||
|
#
|
||||||
|
# 32 bytes covers every backend with margin: ARM64's worst case is a
|
||||||
|
# 4-byte BTI landing pad (only present if the target enables ARM
|
||||||
|
# branch protection) plus a 16-byte jump sequence = 20 bytes; x86-64's
|
||||||
|
# is a 4-byte ENDBR64 landing pad (on by default on some toolchains --
|
||||||
|
# see Architecture.cpp's file comment) plus its 14-byte jump = 18;
|
||||||
|
# plain x86 needs at most 4 + 5 = 9. 32 leaves headroom over the
|
||||||
|
# largest of those without meaningfully affecting code size -- this is
|
||||||
|
# NOP padding, skipped in a few cycles by the branch predictor on
|
||||||
|
# every call until a function is actually reloaded.
|
||||||
|
function(hot_reload_enable_patchable_functions TARGET_NAME)
|
||||||
|
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||||
|
# M (the "before entry" count) is deliberately 0: all 32 NOPs
|
||||||
|
# land AT and after the function's address, which is exactly
|
||||||
|
# where source/HotReload writes into. Confirmed against
|
||||||
|
# `objdump` output while building this library -- see the
|
||||||
|
# design note this function's file comment links back to.
|
||||||
|
target_compile_options(${TARGET_NAME} PRIVATE -fpatchable-function-entry=32,0)
|
||||||
|
elseif(MSVC)
|
||||||
|
# MSVC's own equivalent: reserves a 2-byte pad at every
|
||||||
|
# function's entry plus additional padding immediately before
|
||||||
|
# it, specifically so an external tool can overwrite the entry
|
||||||
|
# with a short jump. See
|
||||||
|
# https://learn.microsoft.com/en-us/cpp/build/reference/hotpatch
|
||||||
|
# This does not take a byte count the way -fpatchable-function-
|
||||||
|
# entry does; the PE export table also gives this library no
|
||||||
|
# per-symbol size the way ELF's symbol table does (see
|
||||||
|
# SymbolTable.cpp's file comment), so on MSVC this flag is not
|
||||||
|
# just recommended but the primary safety mechanism -- there is
|
||||||
|
# no runtime fallback check backing it up here the way there is
|
||||||
|
# on GCC/Clang/ELF.
|
||||||
|
target_compile_options(${TARGET_NAME} PRIVATE /hotpatch)
|
||||||
|
else()
|
||||||
|
message(WARNING
|
||||||
|
"hot_reload_enable_patchable_functions(${TARGET_NAME}): "
|
||||||
|
"unrecognized compiler '${CMAKE_CXX_COMPILER_ID}', no patchable-entry "
|
||||||
|
"flag applied. Hot-reloading ${TARGET_NAME} will still work for any "
|
||||||
|
"function whose compiled body happens to be large enough on its own; "
|
||||||
|
"see HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL in hot_reload.h.")
|
||||||
|
endif()
|
||||||
|
endfunction()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# NinjaPin.cmake
|
||||||
|
#
|
||||||
|
# Pure computation of "where does the pinned ninja live, and where would it
|
||||||
|
# be downloaded from" -- no network calls, no download, no extraction.
|
||||||
|
# Mirrors DoxygenPin.cmake exactly; see that file for the full rationale.
|
||||||
|
# Shared by two very different callers:
|
||||||
|
#
|
||||||
|
# scripts/build.sh / build.bat include() this (via `cmake -P`, same
|
||||||
|
# mechanism as FetchNinja.cmake below) to
|
||||||
|
# find out where to LOOK for an
|
||||||
|
# already-downloaded pinned ninja, before
|
||||||
|
# CMake configure even runs -- ninja is
|
||||||
|
# needed as the generator itself, unlike
|
||||||
|
# Doxygen, which is only ever needed for a
|
||||||
|
# downstream custom target. Never
|
||||||
|
# downloads anything itself.
|
||||||
|
#
|
||||||
|
# cmake/FetchNinja.cmake include()s this when run standalone via
|
||||||
|
# `cmake -P cmake/FetchNinja.cmake` (what
|
||||||
|
# scripts/setup.sh / setup.bat call) to
|
||||||
|
# know what to actually download.
|
||||||
|
#
|
||||||
|
# Needs TOOLCACHE_DIR to already be set. Both callers above set it manually
|
||||||
|
# (matching Configuration.cmake's default) since neither runs through
|
||||||
|
# CMakeLists.txt.
|
||||||
|
#
|
||||||
|
# Bump NINJA_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, on purpose, in
|
||||||
|
# FetchNinja.cmake rather than silently accepting an unexpected file).
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
include_guard(GLOBAL)
|
||||||
|
|
||||||
|
if(NOT DEFINED NINJA_PINNED_VERSION)
|
||||||
|
set(NINJA_PINNED_VERSION "1.13.2")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(NINJA_PIN_TAG "v${NINJA_PINNED_VERSION}")
|
||||||
|
set(NINJA_PIN_ROOT "${TOOLCACHE_DIR}/ninja-${NINJA_PINNED_VERSION}")
|
||||||
|
|
||||||
|
if(CMAKE_HOST_WIN32)
|
||||||
|
set(NINJA_PIN_URL "https://github.com/ninja-build/ninja/releases/download/${NINJA_PIN_TAG}/ninja-win.zip")
|
||||||
|
set(NINJA_PIN_HASH "07fc8261b42b20e71d1720b39068c2e14ffcee6396b76fb7a795fb460b78dc65")
|
||||||
|
set(NINJA_PIN_EXECUTABLE "${NINJA_PIN_ROOT}/ninja.exe")
|
||||||
|
set(NINJA_PIN_PLATFORM_SUPPORTED TRUE)
|
||||||
|
elseif(CMAKE_HOST_UNIX AND NOT CMAKE_HOST_APPLE)
|
||||||
|
set(NINJA_PIN_URL "https://github.com/ninja-build/ninja/releases/download/${NINJA_PIN_TAG}/ninja-linux.zip")
|
||||||
|
set(NINJA_PIN_HASH "5749cbc4e668273514150a80e387a957f933c6ed3f5f11e03fb30955e2bbead6")
|
||||||
|
set(NINJA_PIN_EXECUTABLE "${NINJA_PIN_ROOT}/ninja")
|
||||||
|
set(NINJA_PIN_PLATFORM_SUPPORTED TRUE)
|
||||||
|
else()
|
||||||
|
set(NINJA_PIN_URL "")
|
||||||
|
set(NINJA_PIN_HASH "")
|
||||||
|
set(NINJA_PIN_EXECUTABLE "")
|
||||||
|
set(NINJA_PIN_PLATFORM_SUPPORTED FALSE)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
# 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)
|
||||||
|
- [Class Member Organization](#class-member-organization)
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Class Member Organization
|
||||||
|
|
||||||
|
- **Member order: `public`, then `protected`, then `private`.** Group each access level together, in that order. The widest audience — everyone who includes the header — reads what's relevant to them (the public interface) first, without wading through implementation details to find it. Don't reopen an access specifier further down "for organization"; interleaving defeats the ordering rule's purpose.
|
||||||
|
- **Avoid inline member function definitions in the class body.** Declare the member in the class; define its body separately — out-of-line in the matching `.cpp` for an ordinary class (see the `NetworkManager` example in [Header Files](#6-header-files) above), or out-of-line further down the same header for a template member (see [Implementing Templates](#implementing-templates-project-standard) above). This keeps the class body itself a clean, scannable interface instead of a mix of signatures and implementation.
|
||||||
|
- **Exception:** a genuinely trivial one-line body (a plain getter, a one-expression forwarding call) may stay inline in the class — the same exception already noted for templates above.
|
||||||
|
- **Inline template members go at the bottom of their access section.** When a class mixes ordinary members with template members that use the trivial-one-liner exception just above, keep the plain declarations together at the top of each `public`/`protected`/`private` block, and place the inline template members after them, at the bottom of that same block — not interleaved. A reader scanning the interface sees the plain signatures first; the inline implementation detail comes last, where it doesn't interrupt that scan.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class NetworkManager
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
NetworkManager();
|
||||||
|
~NetworkManager();
|
||||||
|
|
||||||
|
void connectToServer();
|
||||||
|
bool isConnected() const;
|
||||||
|
|
||||||
|
// Inline template member (trivial-one-liner exception, see Templates
|
||||||
|
// above) -- kept at the bottom of this access section rather than
|
||||||
|
// interleaved with the plain declarations above.
|
||||||
|
template <typename T> T configValue() const { return static_cast<T>(m_connectionTimeout); }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual void onDisconnected();
|
||||||
|
|
||||||
|
private:
|
||||||
|
int m_connectionTimeout;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
None of this is mechanically enforced — `readability-redundant-inline-specifier` (see `.clang-tidy`) catches one narrow related mistake (a redundant explicit `inline` keyword) but not the member-order or inline-body rules themselves. See TOOLING.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **Do not wrap parameters onto their own line.** Keep a function's parameter list together — on the declaration line if it fits, otherwise packed as tightly as the line allows on a continuation line. Never lay parameters out one per line; that turns a short signature into a tall list a reader has to scroll past to see the return type and name.
|
||||||
|
- `clang-format` enforces the "never one-per-line" part. It can't force a genuinely long signature to fit within 100 characters without wrapping at all — that would need line length limits disabled project-wide, which would also stop ordinary long expressions and comments from ever wrapping, so it isn't done. If a signature is long enough that even packing parameters together still overflows, let it overflow rather than reach for one-per-line as the fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Comments
|
||||||
|
|
||||||
|
- **Document symbols with Doxygen-style comments, never a plain `//`.** Any comment documenting a function, class, struct, enum, member variable, or macro — explaining what it *is* or *does*, as opposed to annotating a line of implementation — uses `///` or `/** ... */`. This is what makes the project's generated documentation (see the Doxygen integration) actually useful instead of a bare list of undocumented signatures.
|
||||||
|
- **`///` for most documentation; `/** ... */` for longer, multi-paragraph explanations.** Both are valid Doxygen syntax — pick based on length, not per-file preference.
|
||||||
|
- **Plain `//` is still correct for everything that isn't documenting a symbol** — a note on a tricky line inside a function body, a `// TODO`, a block disabled during debugging. The rule above is about comments attached to a declaration, not every comment everywhere.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// Establishes a connection to the configured server.
|
||||||
|
/// @return true if the connection succeeded.
|
||||||
|
bool connectToServer();
|
||||||
|
```
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/**
|
||||||
|
* Parses and validates a server configuration blob.
|
||||||
|
*
|
||||||
|
* Accepts either JSON or the legacy INI format; the format is
|
||||||
|
* auto-detected from the first non-whitespace byte.
|
||||||
|
*
|
||||||
|
* @param raw the raw configuration bytes, in either supported format.
|
||||||
|
* @return a parsed Config, or std::nullopt if raw is malformed.
|
||||||
|
*/
|
||||||
|
std::optional<Config> parseConfig(std::string_view raw);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **Leave a blank line after an early exit** — a `return`, `break`, or `continue` statement, or a block whose last statement is one of these — whenever more code follows at that same level. It visually marks where the early-exit path ends and the normal-case code begins. Skip the blank line if the statement (or its block) is the last thing in its own enclosing function or loop; there's nothing after it to separate from.
|
||||||
|
- Not enforced by `clang-format` or `clang-tidy` — neither has a check for this. It's a code-review convention.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void process(const std::vector<int>& values)
|
||||||
|
{
|
||||||
|
if (values.empty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int value : values)
|
||||||
|
{
|
||||||
|
accumulate(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
@echo off
|
||||||
|
REM Configure (if needed) and build the project, preferably 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 enabledelayedexpansion
|
||||||
|
|
||||||
|
cd /d "%~dp0.."
|
||||||
|
|
||||||
|
if "%BUILD_DIR%"=="" set "BUILD_DIR=out\build"
|
||||||
|
set "BUILD_TYPE=%~1"
|
||||||
|
if "%BUILD_TYPE%"=="" set "BUILD_TYPE=Release"
|
||||||
|
if "%TOOLCACHE_DIR%"=="" set "TOOLCACHE_DIR=.cache\tools"
|
||||||
|
|
||||||
|
where cmake >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo error: cmake not found on PATH.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Prefer a pinned ninja (fetched by scripts\setup.bat -- see
|
||||||
|
REM cmake\FetchNinja.cmake) over a system one, same "always the pinned
|
||||||
|
REM copy" preference as Doxygen. Deliberately goto-based, and deliberately
|
||||||
|
REM `pushd` + a bare wildcard rather than `for /d %%D in ("path\*")` --
|
||||||
|
REM both tested directly; a path-prefixed wildcard didn't match here,
|
||||||
|
REM this form does, and it also sidesteps nesting a FOR loop inside an
|
||||||
|
REM if/else block, which separately corrupts cmd.exe's paren-matching for
|
||||||
|
REM the enclosing block (see scripts\refactor.bat for that one).
|
||||||
|
set "NINJA_BIN="
|
||||||
|
if not exist "%TOOLCACHE_DIR%" goto check_system_ninja
|
||||||
|
|
||||||
|
pushd "%TOOLCACHE_DIR%"
|
||||||
|
for /d %%D in (ninja-*) do if exist "%%D\ninja.exe" if not defined NINJA_BIN set "NINJA_BIN=%%~fD\ninja.exe"
|
||||||
|
popd
|
||||||
|
|
||||||
|
if defined NINJA_BIN goto have_pinned_ninja
|
||||||
|
|
||||||
|
:check_system_ninja
|
||||||
|
where ninja >nul 2>nul
|
||||||
|
if errorlevel 1 goto no_ninja
|
||||||
|
echo ==^> Using system ninja
|
||||||
|
set "GENERATOR_ARGS=-G Ninja"
|
||||||
|
goto configure
|
||||||
|
|
||||||
|
:have_pinned_ninja
|
||||||
|
echo ==^> Using pinned ninja: %NINJA_BIN%
|
||||||
|
set "GENERATOR_ARGS=-G Ninja -DCMAKE_MAKE_PROGRAM=%NINJA_BIN%"
|
||||||
|
goto configure
|
||||||
|
|
||||||
|
:no_ninja
|
||||||
|
echo warning: ninja not found ^(no pinned copy, none on PATH^) -- falling back to
|
||||||
|
echo CMake's default generator for this platform. Run scripts\setup.bat
|
||||||
|
echo to fetch a pinned ninja, or install ninja yourself, for a faster
|
||||||
|
echo and more consistent build. Continuing without it.
|
||||||
|
set "GENERATOR_ARGS="
|
||||||
|
|
||||||
|
:configure
|
||||||
|
echo ==^> Configuring (%BUILD_TYPE%) into %BUILD_DIR%\
|
||||||
|
cmake %GENERATOR_ARGS% -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
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Configure (if needed) and build the project, preferably 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
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
# Prefer a pinned ninja (fetched by scripts/setup.sh -- see
|
||||||
|
# cmake/FetchNinja.cmake) over a system one, same "always the pinned copy"
|
||||||
|
# preference as Doxygen. TOOLCACHE_DIR default here matches
|
||||||
|
# Configuration.cmake's; if you've overridden TOOLCACHE_DIR at configure
|
||||||
|
# time, also set it here as an env var so this still finds it.
|
||||||
|
TOOLCACHE_DIR="${TOOLCACHE_DIR:-.cache/tools}"
|
||||||
|
NINJA_BIN=""
|
||||||
|
for _candidate in "${TOOLCACHE_DIR}"/ninja-*/ninja; do
|
||||||
|
if [ -x "$_candidate" ]; then
|
||||||
|
# Must be absolute: CMake invokes CMAKE_MAKE_PROGRAM from several
|
||||||
|
# different working directories during its own internal
|
||||||
|
# try-compile steps, where a relative path doesn't resolve.
|
||||||
|
NINJA_BIN="$(cd "$(dirname "$_candidate")" && pwd)/$(basename "$_candidate")"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
GENERATOR_ARGS=()
|
||||||
|
if [ -n "$NINJA_BIN" ]; then
|
||||||
|
echo "==> Using pinned ninja: ${NINJA_BIN}"
|
||||||
|
GENERATOR_ARGS=(-G Ninja "-DCMAKE_MAKE_PROGRAM=${NINJA_BIN}")
|
||||||
|
elif command -v ninja >/dev/null 2>&1; then
|
||||||
|
echo "==> Using system ninja: $(command -v ninja)"
|
||||||
|
GENERATOR_ARGS=(-G Ninja)
|
||||||
|
else
|
||||||
|
echo "warning: ninja not found (no pinned copy, none on PATH) -- falling back to" >&2
|
||||||
|
echo " CMake's default generator for this platform. Run scripts/setup.sh" >&2
|
||||||
|
echo " to fetch a pinned ninja, or install ninja yourself, for a faster" >&2
|
||||||
|
echo " and more consistent build. Continuing without it." >&2
|
||||||
|
GENERATOR_ARGS=()
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Configuring (${BUILD_TYPE}) into ${BUILD_DIR}/"
|
||||||
|
cmake "${GENERATOR_ARGS[@]}" -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>"
|
||||||
@@ -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%
|
||||||
@@ -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
|
||||||
@@ -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%
|
||||||
@@ -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}"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@echo off
|
||||||
|
REM Applies clang-format and clang-tidy's --fix across this project's own
|
||||||
|
REM C/C++ code. The pre-commit hook only CHECKS; this is the "just fix it"
|
||||||
|
REM counterpart -- review the result with `git diff` afterward, same as any
|
||||||
|
REM auto-formatter.
|
||||||
|
REM
|
||||||
|
REM Defaults to source\ and c-api\, this template's own-code roots -- NEVER
|
||||||
|
REM walks out\, .cache\, vendor\, or .git\ by default, so it can't
|
||||||
|
REM accidentally spend minutes reformatting/analyzing a fetched dependency's
|
||||||
|
REM entire source tree the way scanning the whole repo from "." would.
|
||||||
|
REM
|
||||||
|
REM Usage, from the project root: scripts\refactor.bat [optional path]
|
||||||
|
REM scripts\refactor.bat every file under source\ and c-api\
|
||||||
|
REM scripts\refactor.bat source\Core just one file or directory -- taken
|
||||||
|
REM as given, with no exclusion filter,
|
||||||
|
REM so pick a path that doesn't overlap
|
||||||
|
REM with out\/.cache\/vendor\/.git\
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
cd /d "%~dp0.."
|
||||||
|
|
||||||
|
where clang-format >nul 2>nul
|
||||||
|
set "HAVE_FORMAT=0"
|
||||||
|
if not errorlevel 1 set "HAVE_FORMAT=1"
|
||||||
|
where clang-tidy >nul 2>nul
|
||||||
|
set "HAVE_TIDY=0"
|
||||||
|
if not errorlevel 1 set "HAVE_TIDY=1"
|
||||||
|
|
||||||
|
if "%HAVE_FORMAT%"=="1" goto have_a_tool
|
||||||
|
if "%HAVE_TIDY%"=="1" goto have_a_tool
|
||||||
|
echo error: neither clang-format nor clang-tidy found on PATH. Run scripts\setup.bat for install guidance.
|
||||||
|
exit /b 1
|
||||||
|
:have_a_tool
|
||||||
|
|
||||||
|
set "FILELIST=%TEMP%\refactor_files_%RANDOM%.txt"
|
||||||
|
if exist "%FILELIST%" del "%FILELIST%"
|
||||||
|
|
||||||
|
REM Collect the file list. Deliberately NOT using an if/else block here --
|
||||||
|
REM a for /f or for /r loop nested inside a parenthesized if(...) else(...)
|
||||||
|
REM block confuses cmd.exe's paren-matching for the enclosing block (this
|
||||||
|
REM was tested and confirmed, not a guess); goto-based branching sidesteps
|
||||||
|
REM the problem entirely by never nesting a FOR loop inside another block.
|
||||||
|
if not "%~1"=="" goto collect_custom
|
||||||
|
|
||||||
|
set "SCOPE_DESC=source and c-api"
|
||||||
|
if exist "source" for /r "source" %%F in (*.c *.h *.cpp *.hpp *.cc *.cxx *.tpp) do echo %%F>>"%FILELIST%"
|
||||||
|
if exist "c-api" for /r "c-api" %%F in (*.c *.h *.cpp *.hpp *.cc *.cxx *.tpp) do echo %%F>>"%FILELIST%"
|
||||||
|
goto collected
|
||||||
|
|
||||||
|
:collect_custom
|
||||||
|
set "SCOPE_DESC=%~1"
|
||||||
|
if not exist "%~1" goto collected
|
||||||
|
for /r "%~1" %%F in (*.c *.h *.cpp *.hpp *.cc *.cxx *.tpp) do echo %%F>>"%FILELIST%"
|
||||||
|
|
||||||
|
:collected
|
||||||
|
if exist "%FILELIST%" goto have_files
|
||||||
|
echo No C/C++ files found under: %SCOPE_DESC%
|
||||||
|
exit /b 0
|
||||||
|
:have_files
|
||||||
|
|
||||||
|
echo ==^> Files in scope: %SCOPE_DESC%
|
||||||
|
|
||||||
|
if "%HAVE_FORMAT%"=="1" goto do_format
|
||||||
|
echo ==^> Skipping clang-format ^(not found on PATH -- see scripts\setup.bat^)
|
||||||
|
goto after_format
|
||||||
|
:do_format
|
||||||
|
echo ==^> Running clang-format -i
|
||||||
|
for /f "usebackq delims=" %%F in ("%FILELIST%") do clang-format -i "%%F"
|
||||||
|
:after_format
|
||||||
|
|
||||||
|
if "%HAVE_TIDY%"=="1" goto do_tidy
|
||||||
|
echo ==^> Skipping clang-tidy ^(not found on PATH -- see scripts\setup.bat^)
|
||||||
|
goto after_tidy
|
||||||
|
:do_tidy
|
||||||
|
if exist compile_commands.json goto run_tidy
|
||||||
|
echo ==^> Skipping clang-tidy ^(no compile_commands.json -- run scripts\build.bat first^)
|
||||||
|
goto after_tidy
|
||||||
|
:run_tidy
|
||||||
|
echo ==^> Running clang-tidy --fix ^(auto-fixable issues only; build once first if this is stale^)
|
||||||
|
for /f "usebackq delims=" %%F in ("%FILELIST%") do clang-tidy --quiet --fix "%%F"
|
||||||
|
:after_tidy
|
||||||
|
|
||||||
|
del "%FILELIST%" 2>nul
|
||||||
|
echo ==^> Done. Review the changes: git diff
|
||||||
|
exit /b 0
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Applies clang-format and clang-tidy's --fix across this project's own
|
||||||
|
# C/C++ code. The pre-commit hook only CHECKS; this is the "just fix it"
|
||||||
|
# counterpart -- review the result with `git diff` afterward, same as any
|
||||||
|
# auto-formatter.
|
||||||
|
#
|
||||||
|
# Defaults to source/ and c-api/ (this template's own-code roots) --
|
||||||
|
# NEVER walks out/, .cache/, vendor/, or .git/ by default, so it can't
|
||||||
|
# accidentally spend minutes reformatting/analyzing a fetched dependency's
|
||||||
|
# entire source tree (e.g. out/build/_deps/*-src/) the way scanning the
|
||||||
|
# whole repo from "." would. Passing an explicit scope still excludes
|
||||||
|
# those four directories defensively, in case the scope you give overlaps
|
||||||
|
# with them.
|
||||||
|
#
|
||||||
|
# Usage (from the project root):
|
||||||
|
# scripts/refactor.sh # every file under source/ and c-api/
|
||||||
|
# scripts/refactor.sh source/Core # just one file or directory
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
if [ $# -ge 1 ]; then
|
||||||
|
scopes=("$1")
|
||||||
|
else
|
||||||
|
scopes=()
|
||||||
|
[ -d source ] && scopes+=(source)
|
||||||
|
[ -d c-api ] && scopes+=(c-api)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#scopes[@]}" -eq 0 ]; then
|
||||||
|
echo "No default scope found (expected a source/ and/or c-api/ directory here)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v clang-format >/dev/null 2>&1 && ! command -v clang-tidy >/dev/null 2>&1; then
|
||||||
|
echo "error: neither clang-format nor clang-tidy found on PATH. Run scripts/setup.sh for install guidance." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mapfile -t files < <(
|
||||||
|
find "${scopes[@]}" \
|
||||||
|
\( -path './vendor' -o -path './out' -o -path './.cache' -o -path './.git' \) -prune -o \
|
||||||
|
-type f \( -name '*.c' -o -name '*.h' -o -name '*.cpp' -o -name '*.hpp' -o -name '*.cc' -o -name '*.cxx' -o -name '*.tpp' \) -print \
|
||||||
|
| sed 's|^\./||' | sort
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
|
echo "No C/C++ files found under: ${scopes[*]}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> ${#files[@]} file(s) in scope: ${scopes[*]}"
|
||||||
|
|
||||||
|
if command -v clang-format >/dev/null 2>&1; then
|
||||||
|
echo "==> Running clang-format -i"
|
||||||
|
clang-format -i "${files[@]}"
|
||||||
|
else
|
||||||
|
echo "==> Skipping clang-format (not found on PATH -- see scripts/setup.sh)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v clang-tidy >/dev/null 2>&1; then
|
||||||
|
if [ ! -f compile_commands.json ]; then
|
||||||
|
echo "==> Skipping clang-tidy (no compile_commands.json -- run scripts/build.sh first)"
|
||||||
|
else
|
||||||
|
echo "==> Running clang-tidy --fix (auto-fixable issues only; build once first if this is stale)"
|
||||||
|
for f in "${files[@]}"; do
|
||||||
|
clang-tidy --quiet --fix "$f" || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "==> Skipping clang-tidy (not found on PATH -- see scripts/setup.sh)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Done. Review the changes: git diff"
|
||||||
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
@echo off
|
||||||
|
REM One-time (or re-run any time) environment setup for this template:
|
||||||
|
REM
|
||||||
|
REM 1. Fetches the pinned ninja (cmake\FetchNinja.cmake) -- used by
|
||||||
|
REM scripts\build.bat if found (see there); a system ninja still
|
||||||
|
REM works fine too, this just avoids needing one installed at all.
|
||||||
|
REM 2. Fetches the pinned Doxygen (cmake\FetchDoxygen.cmake) -- NOT
|
||||||
|
REM required to build the project itself (see cmake\Documentation.cmake),
|
||||||
|
REM only to run scripts\docs.bat afterward.
|
||||||
|
REM 3. Checks for clang-format/clang-tidy (used by scripts\refactor.bat and
|
||||||
|
REM .githooks\pre-commit) and prints install guidance if either is
|
||||||
|
REM missing -- these aren't auto-installed, since doing so would need
|
||||||
|
REM assumptions about your package manager/installer this script
|
||||||
|
REM shouldn't assume it has.
|
||||||
|
REM 4. Activates the pre-commit hook (.githooks\pre-commit) for this
|
||||||
|
REM clone, if it's a git repository.
|
||||||
|
REM
|
||||||
|
REM Usage (from the project root):
|
||||||
|
REM scripts\setup.bat
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
cd /d "%~dp0.."
|
||||||
|
|
||||||
|
where cmake >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo error: cmake not found on PATH.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo ==^> Fetching pinned ninja (cmake\FetchNinja.cmake)
|
||||||
|
cmake -P cmake\FetchNinja.cmake
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ==^> Fetching pinned Doxygen (cmake\FetchDoxygen.cmake)
|
||||||
|
cmake -P cmake\FetchDoxygen.cmake
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ==^> Checking for clang-format / clang-tidy
|
||||||
|
set "_missing=0"
|
||||||
|
|
||||||
|
where clang-format >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo clang-format: not found
|
||||||
|
set "_missing=1"
|
||||||
|
) else (
|
||||||
|
for /f "delims=" %%V in ('where clang-format') do echo clang-format: %%V
|
||||||
|
)
|
||||||
|
|
||||||
|
where clang-tidy >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo clang-tidy: not found
|
||||||
|
set "_missing=1"
|
||||||
|
) else (
|
||||||
|
for /f "delims=" %%V in ('where clang-tidy') do echo clang-tidy: %%V
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%_missing%"=="1" (
|
||||||
|
echo.
|
||||||
|
echo Install the missing tool(s^) with one of:
|
||||||
|
echo winget install LLVM.LLVM
|
||||||
|
echo choco install llvm
|
||||||
|
echo or the installer from https://releases.llvm.org/
|
||||||
|
echo Not required to build this project -- only for scripts\refactor.bat
|
||||||
|
echo and the pre-commit hook.
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
git rev-parse --is-inside-work-tree >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ==^> Skipping pre-commit hook activation ^(not a git repository yet^)
|
||||||
|
echo Once you've run "git init", re-run this script or:
|
||||||
|
echo git config core.hooksPath .githooks
|
||||||
|
) else (
|
||||||
|
echo ==^> Activating pre-commit hook
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
echo core.hooksPath = .githooks
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ==^> Setup complete. scripts\build.bat and scripts\docs.bat are ready to use.
|
||||||
|
exit /b 0
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-time (or re-run any time) environment setup for this template:
|
||||||
|
#
|
||||||
|
# 1. Fetches the pinned ninja (cmake/FetchNinja.cmake) -- used by
|
||||||
|
# scripts/build.sh / build.bat if found (see there); a system ninja
|
||||||
|
# still works fine too, this just avoids needing one installed at all.
|
||||||
|
# 2. Fetches the pinned Doxygen (cmake/FetchDoxygen.cmake) -- NOT
|
||||||
|
# required to build the project itself (see cmake/Documentation.cmake),
|
||||||
|
# only to run scripts/docs.sh / docs.bat afterward.
|
||||||
|
# 3. Checks for clang-format/clang-tidy (used by scripts/refactor.sh and
|
||||||
|
# .githooks/pre-commit) and prints install guidance if either is
|
||||||
|
# missing -- these aren't auto-installed, since doing so would need
|
||||||
|
# assumptions about your package manager and elevated privileges
|
||||||
|
# this script shouldn't assume it has.
|
||||||
|
# 4. Activates the pre-commit hook (.githooks/pre-commit) for this
|
||||||
|
# clone, if it's a git repository.
|
||||||
|
#
|
||||||
|
# Usage (from the project root):
|
||||||
|
# scripts/setup.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
command -v cmake >/dev/null 2>&1 || { echo "error: cmake not found on PATH." >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "==> Fetching pinned ninja (cmake/FetchNinja.cmake)"
|
||||||
|
cmake -P cmake/FetchNinja.cmake
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> Fetching pinned Doxygen (cmake/FetchDoxygen.cmake)"
|
||||||
|
cmake -P cmake/FetchDoxygen.cmake
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> Checking for clang-format / clang-tidy"
|
||||||
|
_missing=0
|
||||||
|
if ! command -v clang-format >/dev/null 2>&1; then
|
||||||
|
echo " clang-format: not found"
|
||||||
|
_missing=1
|
||||||
|
else
|
||||||
|
echo " clang-format: $(command -v clang-format) ($(clang-format --version))"
|
||||||
|
fi
|
||||||
|
if ! command -v clang-tidy >/dev/null 2>&1; then
|
||||||
|
echo " clang-tidy: not found"
|
||||||
|
_missing=1
|
||||||
|
else
|
||||||
|
echo " clang-tidy: $(command -v clang-tidy) ($(clang-tidy --version | head -n1))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_missing" -eq 1 ]; then
|
||||||
|
echo
|
||||||
|
echo " Install the missing tool(s) with:"
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin)
|
||||||
|
echo " brew install clang-format llvm"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo " sudo apt-get install clang-format clang-tidy # Debian/Ubuntu"
|
||||||
|
echo " (or your distro's equivalent package)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo " Not required to build this project -- only for scripts/refactor.sh"
|
||||||
|
echo " and the pre-commit hook."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
echo "==> Activating pre-commit hook"
|
||||||
|
chmod +x .githooks/pre-commit
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
echo " core.hooksPath = .githooks"
|
||||||
|
else
|
||||||
|
echo "==> Skipping pre-commit hook activation (not a git repository yet)"
|
||||||
|
echo " Once you've run 'git init', re-run this script or:"
|
||||||
|
echo " chmod +x .githooks/pre-commit && git config core.hooksPath .githooks"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> Setup complete. scripts/build.sh and scripts/docs.sh are ready to use."
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# The hot-reload runtime is infrastructure the host application links
|
||||||
|
# against directly. Unlike the plugins it loads at runtime (see
|
||||||
|
# source/examples/HotReloadDemo), this library is never itself meant
|
||||||
|
# to be hot-reloaded, so TYPE STATIC is a deliberate choice here,
|
||||||
|
# independent of the project-wide BUILD_SHARED_LIBS default.
|
||||||
|
add_lib_module(hotreload_lib TYPE STATIC OUTPUT_NAME "hotreload")
|
||||||
|
|
||||||
|
if(NOT WIN32)
|
||||||
|
# dlopen/dlclose/dlinfo (ModuleHandle.cpp). PUBLIC, not PRIVATE:
|
||||||
|
# for a STATIC library this needs to reach whatever executable
|
||||||
|
# ultimately links hotreload_lib, not just hotreload_lib's own
|
||||||
|
# (nonexistent, for a static lib) link step.
|
||||||
|
target_link_libraries(hotreload_lib PUBLIC ${CMAKE_DL_LIBS})
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
# Hot Reload
|
||||||
|
|
||||||
|
Patches a running process's own code in place so that a new build of a
|
||||||
|
function takes effect immediately -- no restart, and every existing
|
||||||
|
caller (including a raw C function pointer someone cached ten minutes
|
||||||
|
ago) picks up the change automatically. `hot_reload.h` is the API;
|
||||||
|
`source/examples/HotReloadDemo` is a full worked example; this file is
|
||||||
|
the overview `hot_reload.h`'s own doc comments don't have room for,
|
||||||
|
especially the parts you should know are *not* guaranteed.
|
||||||
|
|
||||||
|
If you just want to see it run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake -S . -B out -DBUILD_EXAMPLES=ON
|
||||||
|
cmake --build out
|
||||||
|
./out/bin/examples/hotreload_demo_host
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `source/examples/HotReloadDemo/PluginV1/src/Plugin.cpp` and
|
||||||
|
`PluginV2/src/Plugin.cpp` side by side against the output -- they're
|
||||||
|
written to stand in for "the file before your edit" and "after". The
|
||||||
|
same two directories' `CppWidget.cpp` do the same for a real C++
|
||||||
|
class with no `extern "C"` anywhere -- see "Hot reloading C++ symbols"
|
||||||
|
below.
|
||||||
|
|
||||||
|
Both of those exist to prove things deterministically, though -- for
|
||||||
|
actually playing with this yourself, see
|
||||||
|
`source/examples/HotReloadLiveDemo/README.md` instead: a host that
|
||||||
|
stays running, and one plugin file you edit and rebuild as many times
|
||||||
|
as you want while it's live.
|
||||||
|
|
||||||
|
## How it works, in one paragraph
|
||||||
|
|
||||||
|
`hot_reload_load()` loads a shared library and reads its exported
|
||||||
|
symbol table. `hot_reload_reload()` loads a *new* build of that same
|
||||||
|
library, reads its symbol table too, and for every name both builds
|
||||||
|
export as a function, overwrites the first few bytes of the *old*
|
||||||
|
function with an unconditional jump to the *new* one's address. Every
|
||||||
|
existing call site -- direct calls, calls through the PLT/GOT, raw
|
||||||
|
function pointers already sitting in some struct -- reaches the old
|
||||||
|
address and immediately redirects, because that's now all that's
|
||||||
|
there. Full byte-level detail, verified against a real assembler
|
||||||
|
rather than transcribed from memory, is in
|
||||||
|
`src/Architecture.cpp`'s file comment.
|
||||||
|
|
||||||
|
## The two guarantees this library asked to make
|
||||||
|
|
||||||
|
**"Only executable code changes, never data."** This isn't a naming
|
||||||
|
convention you have to follow -- it's mechanical. `src/SymbolTable.cpp`
|
||||||
|
classifies every exported symbol using the loader's own metadata (an
|
||||||
|
ELF symbol's type + its section's executable flag; a PE export's
|
||||||
|
containing section's characteristics), and `HotReloadContext` refuses
|
||||||
|
to patch anything not classified as a function, on *either* side of a
|
||||||
|
reload. `source/examples/HotReloadDemo` proves this concretely:
|
||||||
|
`plugin_build_marker` is an exported `int` sitting right next to real
|
||||||
|
functions, changes value between V1 and V2 exactly like a function's
|
||||||
|
behavior would, and the demo reads its original address again *after*
|
||||||
|
reloading to show it's untouched.
|
||||||
|
|
||||||
|
**"Handle deletion gracefully."** A function missing from the new
|
||||||
|
build is reported through `HotReloadOptions::on_symbol_event`
|
||||||
|
(`HOT_RELOAD_SYMBOL_REMOVED`) and, by default, its old implementation
|
||||||
|
is simply left running -- nothing is ever patched to a null or
|
||||||
|
garbage address. If you want stale calls to be loud instead,
|
||||||
|
`on_deleted_symbol` lets you redirect it to a stub of your own. Both
|
||||||
|
policies are exercised in this repo:
|
||||||
|
`source/examples/HotReloadDemo/Host` supplies a stub;
|
||||||
|
`source/tests/HotReloadTests` covers the default.
|
||||||
|
|
||||||
|
Neither guarantee depends on anything the hot-reloadable module's
|
||||||
|
author has to remember to do correctly -- see "What this cannot
|
||||||
|
detect" below for the one important thing that's true in reverse.
|
||||||
|
|
||||||
|
## Hot reloading C++ symbols (classes, virtual methods, operators)
|
||||||
|
|
||||||
|
Everything above works for a real, unmodified C++ mangled name with
|
||||||
|
**no changes to this library at all** -- classification only ever
|
||||||
|
looks at a symbol's type and section (see `src/SymbolTable.cpp`),
|
||||||
|
never at what its name looks like, so a class's ordinary method, a
|
||||||
|
virtual method, and an overloaded operator are, mechanically, no
|
||||||
|
different from an `extern "C"` function. This isn't a theoretical
|
||||||
|
claim: `source/examples/HotReloadDemo/Shared/CppWidgetApi.h` declares
|
||||||
|
a class with all three, exported with no `extern "C"` anywhere, living
|
||||||
|
in the *same* plugin build as the plain-C demo, and
|
||||||
|
`source/examples/HotReloadDemo/Host/Main.cpp` hot-reloads it -- run
|
||||||
|
the demo (see the top of this file) and watch the `CppWidget::` lines
|
||||||
|
in the output.
|
||||||
|
|
||||||
|
**Virtual methods work automatically, including through a real
|
||||||
|
vtable.** A virtual call is dispatched by loading a function pointer
|
||||||
|
out of the object's vtable and calling through it -- and that pointer
|
||||||
|
holds exactly the same address a direct call would, since the plugin's
|
||||||
|
own code populated the vtable with it. Patch that address, and *every*
|
||||||
|
path that reaches it redirects: a raw function pointer, a direct call,
|
||||||
|
and a vtable slot are all just different ways of holding the same
|
||||||
|
address. The demo proves this directly: it calls `widget->virtualTick(x)`
|
||||||
|
through ordinary polymorphic dispatch, once before `hot_reload_reload()`
|
||||||
|
and once after, on the *same* pre-existing object, and the second call
|
||||||
|
runs the new build's code.
|
||||||
|
|
||||||
|
That said, a vtable is data, and changing its *layout* -- adding,
|
||||||
|
removing, or reordering virtual functions, or changing base classes --
|
||||||
|
is exactly the data-layout hazard "What this cannot detect" describes
|
||||||
|
below, just in a C++-specific shape: a pre-existing object's vptr
|
||||||
|
still points at the *old* build's vtable, with the old slot count and
|
||||||
|
order, so new code that assumes the new layout while running against
|
||||||
|
an old object's vtable will read the wrong slot. A newly-constructed
|
||||||
|
object (created after the reload) gets the new vtable and is fine.
|
||||||
|
This library does not check for this -- it isn't something a symbol
|
||||||
|
table carries any information about -- so keep a hot-reloadable
|
||||||
|
class's virtual function set unchanged across a reload, the same
|
||||||
|
discipline as keeping a free function's signature unchanged.
|
||||||
|
|
||||||
|
**Operators are just functions with unusual mangled names** and need
|
||||||
|
nothing special either -- `CppWidgetApi.h`'s `operator+` patches
|
||||||
|
exactly like `tick()` below.
|
||||||
|
|
||||||
|
**The one real requirement, restated for C++:** a function only exists
|
||||||
|
to be patched at all if the compiler emitted an actual out-of-line
|
||||||
|
body for it somewhere addressable. This was already true for
|
||||||
|
`extern "C"` functions (see "Using it in your own module" below), but
|
||||||
|
C++ makes it much easier to violate by accident:
|
||||||
|
|
||||||
|
- A method defined *inside* a class body (`struct S { int f() { ...
|
||||||
|
} };`) is implicitly `inline`, and a caller that includes the header
|
||||||
|
will very often get the body inlined directly into its own compiled
|
||||||
|
code, with no separate symbol left anywhere to patch. This is not
|
||||||
|
hypothetical -- built and inspected while writing this feature:
|
||||||
|
```cpp
|
||||||
|
struct InlineWidget { int tick(int x) { return x * 2; } };
|
||||||
|
int caller(int x) { InlineWidget w; return w.tick(x); }
|
||||||
|
```
|
||||||
|
compiled with optimization on, `nm -D` on the resulting library shows
|
||||||
|
**no `InlineWidget::tick` symbol at all** -- the compiler deleted the
|
||||||
|
separate function entirely, so there is nothing any binary-patching
|
||||||
|
tool, this one included, could ever redirect. `caller`'s own compiled
|
||||||
|
code has to be replaced by reloading `caller` itself.
|
||||||
|
- Templates are instantiated per translation unit and, unless
|
||||||
|
explicitly instantiated once elsewhere, typically get inlined at
|
||||||
|
each call site for the same reason.
|
||||||
|
- The fix, where it applies, is the same one C already needed: declare
|
||||||
|
in a header, define exactly once out-of-line in a `.cpp` compiled
|
||||||
|
into the reloadable module, and don't let a caller outside that
|
||||||
|
module see the definition (so the compiler never has the option to
|
||||||
|
inline it away). `CppWidgetApi.h`'s methods are declared in the
|
||||||
|
header and defined in `PluginV1/src/CppWidget.cpp` /
|
||||||
|
`PluginV2/src/CppWidget.cpp` for exactly this reason.
|
||||||
|
|
||||||
|
**Mangled names are only stable within one ABI family.** GCC and
|
||||||
|
Clang implement the same (Itanium) C++ ABI and mangle the demo's
|
||||||
|
`CppWidget` identically -- confirmed directly: the demo was built and
|
||||||
|
run with both compilers, and separately cross-compiled with MinGW and
|
||||||
|
run under Wine, all using the exact same hardcoded mangled-name string
|
||||||
|
in `Host/Main.cpp`. MSVC uses its own, unrelated scheme. This isn't a
|
||||||
|
new limitation this library introduces -- you were always going to
|
||||||
|
reload with the same toolchain you started a session with -- but it's
|
||||||
|
worth stating plainly for mangled names specifically, since unlike an
|
||||||
|
`extern "C"` name, a mangled one simply won't match at all across the
|
||||||
|
two families.
|
||||||
|
|
||||||
|
**Looking up a mangled name is genuinely more annoying than an
|
||||||
|
`extern "C"` one**, which is the practical reason to prefer the
|
||||||
|
latter for anything the *host* needs to call by a human-typed string.
|
||||||
|
There's no portable way to ask the compiler for another declaration's
|
||||||
|
mangled name at compile time; the demo gets it the same way you would
|
||||||
|
in practice, with `nm -D --defined-only <library> | c++filt`. Two ways
|
||||||
|
around this, both shown in the demo:
|
||||||
|
1. Keep one `extern "C"` factory/entry function (`make_cpp_widget()`
|
||||||
|
in the demo) and reach everything else through it -- a returned
|
||||||
|
object's *virtual* methods then need no lookup at all (see above).
|
||||||
|
2. For a method that isn't virtual, call `hot_reload_demangle()` on
|
||||||
|
whatever name you *do* have (e.g. for logging -- see
|
||||||
|
`HotReloadOptions::on_symbol_event`'s output in the demo, where
|
||||||
|
`_ZN9CppWidget4tickEi` prints as `CppWidget::tick(int)`), or accept
|
||||||
|
the manual `nm | c++filt` lookup as a one-time cost, same as the
|
||||||
|
demo does for `CppWidget::tick`.
|
||||||
|
|
||||||
|
`hot_reload_demangle()` renders a real mangled name for display (via
|
||||||
|
`__cxa_demangle` on GCC/Clang/MinGW, `UnDecorateSymbolName` on MSVC)
|
||||||
|
and returns anything else -- including a plain `extern "C"` name --
|
||||||
|
completely unchanged, so it's safe to call on every
|
||||||
|
`HotReloadSymbolEventInfo::name` unconditionally, as the demo does.
|
||||||
|
It never changes what a name means for lookup purposes; it exists
|
||||||
|
purely so a log is readable.
|
||||||
|
|
||||||
|
**One general C++-plugin caveat, not specific to hot-reloading:**
|
||||||
|
exceptions and RTTI (`dynamic_cast`, `typeid`) generally should not
|
||||||
|
cross the boundary into or out of a dynamically-loaded module at
|
||||||
|
all -- this is already true of any `dlopen()`/`LoadLibrary()`-based
|
||||||
|
plugin architecture, for the same reasons cpp-style-guide.md section
|
||||||
|
11 gives for the plain C boundary, and hot-reloading a module doesn't
|
||||||
|
change it either way. The demo's `delete widget` relies on `new` and
|
||||||
|
`delete` resolving to the same allocator across the boundary, which
|
||||||
|
holds on Linux/macOS as tested here; it's worth double-checking on
|
||||||
|
Windows if you link the CRT in an unusual way (e.g. mismatched
|
||||||
|
static/dynamic CRT between the host and a plugin).
|
||||||
|
|
||||||
|
## Scope: what's actually implemented and checked
|
||||||
|
|
||||||
|
| | x86 | x86-64 | ARM64 | ARM32 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Jump-patch backend | yes | yes | yes | not implemented |
|
||||||
|
| Landing pad handled | ENDBR32 | ENDBR64 | BTI | -- |
|
||||||
|
|
||||||
|
*"Any architecture"* was the ask; ARM32 is the one mainstream target
|
||||||
|
deliberately left out, because it's the one case this project's
|
||||||
|
testing couldn't back up with confidence -- see below.
|
||||||
|
|
||||||
|
**Compilers.** GCC and Clang share one backend (including MinGW,
|
||||||
|
which is one or the other targeting Windows); MSVC has its own
|
||||||
|
`/hotpatch`-based path in `cmake/HotReload.cmake` and its own PE
|
||||||
|
export-table reader in `SymbolTable.cpp`.
|
||||||
|
|
||||||
|
**What was actually run, not just compiled**, while building this:
|
||||||
|
- Native GCC and Clang on Linux/x86-64: the full demo, both compilers,
|
||||||
|
including a real ENDBR64 landing pad this toolchain turned out to
|
||||||
|
emit by default -- see `src/Architecture.cpp`'s file comment.
|
||||||
|
- Cross-compiled to ARM64 and run under `qemu-aarch64`: the full demo,
|
||||||
|
twice -- once plain, once with `-mbranch-protection=bti` -- to
|
||||||
|
exercise the BTI-landing-pad path on real (emulated) ARM64
|
||||||
|
semantics, not just have it compile.
|
||||||
|
- Cross-compiled with MinGW and run under Wine: the full demo,
|
||||||
|
exercising the real Win32 `LoadLibrary`/`VirtualProtect`/
|
||||||
|
`FlushInstructionCache` calls and the PE export-table parser. This
|
||||||
|
run is also *why* the demo plugins now set
|
||||||
|
`WINDOWS_EXPORT_ALL_SYMBOLS ON` -- the first attempt silently
|
||||||
|
exported nothing under an MSVC-style build until that was added.
|
||||||
|
Re-run after adding `CppWidgetApi.h`'s C++ class: confirmed the PE
|
||||||
|
reader also correctly classifies a vtable and typeinfo as data (not
|
||||||
|
code) on Windows, not just on ELF, and that GCC and Clang produce
|
||||||
|
identical mangled names for the same class (the demo's hardcoded
|
||||||
|
`_ZN9CppWidget4tickEi` lookup string was derived from a native GCC
|
||||||
|
build but also worked unmodified against this MinGW/Wine build and
|
||||||
|
the separate native Clang build).
|
||||||
|
- **MSVC itself was not run anywhere in this process** -- there is no
|
||||||
|
Windows machine or MSVC install available in the environment this
|
||||||
|
was built in. The MSVC-specific code (`/hotpatch`,
|
||||||
|
`IMAGE_EXPORT_DIRECTORY` parsing, `VirtualProtect`,
|
||||||
|
`FlushInstructionCache`) is written directly against Microsoft's
|
||||||
|
documented structures and behavior, and the equivalent PE-reading
|
||||||
|
code was compiled clean against real `<windows.h>` headers via
|
||||||
|
MinGW, but treat the MSVC path as unverified until you've built it
|
||||||
|
on an actual Windows box. If it doesn't work, `SymbolTable.cpp`'s
|
||||||
|
PE branch and `cmake/HotReload.cmake`'s `/hotpatch` branch are the
|
||||||
|
two places to look first.
|
||||||
|
- **32-bit ARM was not implemented at all**, deliberately: it's the
|
||||||
|
one architecture in this list where getting it wrong is easy and
|
||||||
|
getting it verified is hard. Its instructions are either 4 bytes
|
||||||
|
(ARM mode) or a 2/4-byte mix (Thumb mode), and a function pointer's
|
||||||
|
low bit is the only thing that says which -- get the interworking
|
||||||
|
wrong and you don't get a clean crash, you get a CPU that's decoding
|
||||||
|
the wrong instruction stream. Nothing in this environment could
|
||||||
|
exercise that on real or emulated ARM32 hardware, so rather than
|
||||||
|
ship an ARM32 backend on the strength of a manual reading of the
|
||||||
|
ISA reference alone, it's left out. Adding it is a matter of
|
||||||
|
implementing one more branch of `src/Architecture.cpp`'s interface;
|
||||||
|
test it for real before trusting it.
|
||||||
|
- **macOS (Mach-O) was not implemented.** Nothing in the request named
|
||||||
|
it, and it would need a third format reader alongside
|
||||||
|
`SymbolTable.cpp`'s ELF and PE branches. `readExportedSymbols()` is
|
||||||
|
the one function that would need it.
|
||||||
|
|
||||||
|
## What this cannot detect
|
||||||
|
|
||||||
|
**A changed function signature.** Patching redirects control flow at a
|
||||||
|
fixed address; it has no way to know from a symbol table alone that
|
||||||
|
`void tick(int)` became `void tick(int, int)`. If a function's
|
||||||
|
signature changes, give it a new exported name instead of reusing the
|
||||||
|
old one -- the old name is then correctly reported as
|
||||||
|
`HOT_RELOAD_SYMBOL_REMOVED` and the new one as `HOT_RELOAD_SYMBOL_ADDED`,
|
||||||
|
rather than being patched into a mismatched calling convention.
|
||||||
|
|
||||||
|
**A data layout change reached only through code that still gets
|
||||||
|
patched.** The library guarantees it never *touches* a data symbol
|
||||||
|
directly. It cannot guarantee that a patched function is safe to run
|
||||||
|
against a `struct`/class instance that already exists in memory with
|
||||||
|
the *old* layout -- e.g. a game object allocated before the reload,
|
||||||
|
if the new code adds a field to its type. This is a correctness
|
||||||
|
property of your own code across the reload boundary, not something a
|
||||||
|
symbol table (which carries no field-offset information) can check.
|
||||||
|
If you need this checked, the extension point is the same
|
||||||
|
place: `HotReloadContext::reload()` -- diffing the two builds' debug
|
||||||
|
info (DWARF or PDB) for layout changes in types touched by patched
|
||||||
|
functions is real work, genuinely useful, and out of scope for what's
|
||||||
|
here now.
|
||||||
|
|
||||||
|
**Being called concurrently with the code it's patching.** The
|
||||||
|
intended use is the same as every other tool in this space (Unreal's
|
||||||
|
Live Coding, Handmade Hero's hot reload, etc.): call
|
||||||
|
`hot_reload_reload()` from a quiescent point -- your main thread
|
||||||
|
between frames or ticks -- where nothing else is actively calling into
|
||||||
|
the module you're reloading. The write order in
|
||||||
|
`src/Architecture.cpp` (payload bytes before the opcode that arms
|
||||||
|
them, with a release fence between) protects the realistic case where
|
||||||
|
a call arrives right at that boundary. It does not protect a thread
|
||||||
|
that is genuinely, actively executing through the exact bytes being
|
||||||
|
patched at that moment -- doing that in general on a variable-length
|
||||||
|
ISA needs a signal-handler-based retry protocol, a substantially
|
||||||
|
bigger mechanism than a dev-loop tool like this one calls for. See
|
||||||
|
that file's comment for the full reasoning.
|
||||||
|
|
||||||
|
## Known, accepted gaps
|
||||||
|
|
||||||
|
- **A `.so` with its section header table deliberately stripped**
|
||||||
|
reads back as exporting no functions at all (empty, not a crash) --
|
||||||
|
`SymbolTable.cpp`'s ELF reader uses section headers to find
|
||||||
|
`.dynsym`/`.dynstr`, which is simpler and easier to get right than
|
||||||
|
walking `.dynamic`'s `DT_*` tags the way the dynamic linker itself
|
||||||
|
does, at the cost of this one edge case. Stripping section headers
|
||||||
|
from a `.so` you are actively hot-reloading is an unusual, separate
|
||||||
|
step from an ordinary `strip` and essentially never happens by
|
||||||
|
accident.
|
||||||
|
- **PE exports have no per-symbol size.** Unlike ELF's `st_size`,
|
||||||
|
which backs a real safety check, the Windows path estimates size
|
||||||
|
from the gap to the next exported function and can only ever prove
|
||||||
|
"not enough room," never prove "enough" -- see `SymbolTable.cpp`'s
|
||||||
|
file comment. `/hotpatch` (applied automatically by
|
||||||
|
`hot_reload_enable_patchable_functions()`) is the real safety
|
||||||
|
mechanism on Windows; treat the size estimate as a bonus, not a
|
||||||
|
guarantee.
|
||||||
|
- **A forwarder PE export** (one that re-exports another DLL's
|
||||||
|
function) is classified as non-function and left alone, not
|
||||||
|
followed.
|
||||||
|
|
||||||
|
## Diagnosing a failed load
|
||||||
|
|
||||||
|
`HOT_RELOAD_ERROR_LOAD_FAILED` alone doesn't say *why* -- a missing
|
||||||
|
file, a permissions problem, a corrupt or wrong-architecture image,
|
||||||
|
and a genuinely undefined symbol all look the same from the status
|
||||||
|
code alone. Call `hot_reload_last_error(ctx)` right after a failing
|
||||||
|
`hot_reload_load()`/`hot_reload_reload()` for the platform's own
|
||||||
|
description (`dlerror()` on POSIX, a formatted `GetLastError()` on
|
||||||
|
Windows).
|
||||||
|
|
||||||
|
The specific case that motivated adding this, reproduced directly
|
||||||
|
while writing it: a **non-inline `static` class member that is
|
||||||
|
declared but never given an out-of-class definition**.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class Randomizer
|
||||||
|
{
|
||||||
|
static std::mt19937& gen() { return m_gen; } // ODR-uses m_gen
|
||||||
|
static std::mt19937 m_gen; // declared here -- never defined anywhere
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This compiles cleanly and links cleanly into a shared library --
|
||||||
|
unlike an executable, a shared library is allowed to keep undefined
|
||||||
|
symbols by default, on the assumption that whatever eventually loads
|
||||||
|
it will supply them. It only fails once `dlopen(..., RTLD_NOW)` (this
|
||||||
|
library always uses `RTLD_NOW`, deliberately -- see the comment in
|
||||||
|
`ModuleHandle.cpp` on why a clean, catchable failure right here beats
|
||||||
|
a deferred, uncatchable one under `RTLD_LAZY`) tries to eagerly
|
||||||
|
resolve every symbol and can't find this one anywhere in the process.
|
||||||
|
Confirmed directly: building the same class with the code path that
|
||||||
|
uses it left uncalled produces a `.so` with no trace of the missing
|
||||||
|
symbol at all (the compiler drops the unused, never-instantiated
|
||||||
|
inline method entirely); the moment something calls it,
|
||||||
|
`hot_reload_last_error()` reports exactly
|
||||||
|
`undefined symbol: _ZN10Randomizer4m_genE`. The fix is the one C++17
|
||||||
|
already offers: mark it `inline static` (with its initializer, if it
|
||||||
|
has one) instead of a bare `static` declaration, the same way you'd
|
||||||
|
fix this in any shared library, hot-reloaded or not.
|
||||||
|
|
||||||
|
## Using it in your own module
|
||||||
|
|
||||||
|
1. Every function you want reloadable needs C linkage --
|
||||||
|
`extern "C"` -- for the same reason any `dlopen()`'d interface
|
||||||
|
needs it (see `hot_reload.h`'s file comment and style guide §11).
|
||||||
|
2. Build it as its own `SHARED` library target and call
|
||||||
|
`hot_reload_enable_patchable_functions(<target>)`
|
||||||
|
(`cmake/HotReload.cmake`) on it, so every exported function has
|
||||||
|
guaranteed room for a jump. Without this, reload still works for
|
||||||
|
any function whose compiled body happens to be large enough on its
|
||||||
|
own -- functions that aren't get skipped
|
||||||
|
(`HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL`), not corrupted.
|
||||||
|
3. On Windows, also set `WINDOWS_EXPORT_ALL_SYMBOLS ON` on that
|
||||||
|
target, or add explicit `__declspec(dllexport)` to each function
|
||||||
|
yourself -- MSVC exports nothing by default. (Prefer explicit
|
||||||
|
per-symbol export in a real project, even though the demo uses the
|
||||||
|
blanket property to match this repo's existing `add_lib_module()`
|
||||||
|
convention: exporting everything means anything your build happens
|
||||||
|
to pull in -- including, as this project's own Windows testing
|
||||||
|
turned up, incidental runtime-library internals -- becomes part of
|
||||||
|
your reloadable surface too, redirected right along with the
|
||||||
|
functions you actually meant.)
|
||||||
|
4. Have your host `hot_reload_load()` it once at startup and
|
||||||
|
`hot_reload_reload()` it after every rebuild, from a point where
|
||||||
|
nothing else is calling into it (see "What this cannot detect"
|
||||||
|
above).
|
||||||
|
5. Rebuilding to the *same output path* every time is fine --
|
||||||
|
`HotReloadContext` stages every load through a fresh, uniquely-named
|
||||||
|
copy internally specifically so your build system's exact
|
||||||
|
write pattern can't cause a stale reload (a known gotcha for this
|
||||||
|
style of tool; see `HotReloadContext.cpp`'s
|
||||||
|
`stagePathForLoad()`).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
# source/HotReload/include/hot_reload/.clang-tidy
|
||||||
|
#
|
||||||
|
# hot_reload.h itself is Part II (C API) style -- snake_case, per style
|
||||||
|
# guide §13. Copied from the repo-root c-api/.clang-tidy template (see
|
||||||
|
# that file's own header comment for how clang-tidy's directory-scoped
|
||||||
|
# config resolution makes this work); see also
|
||||||
|
# source/HotReload/src/c-api/.clang-tidy for this same override applied
|
||||||
|
# to the C API's .cpp implementation, one directory tree over.
|
||||||
|
InheritParentConfig: true
|
||||||
|
|
||||||
|
Checks: >
|
||||||
|
readability-identifier-naming,
|
||||||
|
bugprone-exception-escape
|
||||||
|
|
||||||
|
CheckOptions:
|
||||||
|
- 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
|
||||||
|
- key: readability-identifier-naming.EnumConstantCase
|
||||||
|
value: UPPER_CASE
|
||||||
|
|
||||||
|
- key: readability-identifier-naming.GlobalConstantCase
|
||||||
|
value: UPPER_CASE
|
||||||
|
- key: readability-identifier-naming.MacroDefinitionCase
|
||||||
|
value: UPPER_CASE
|
||||||
|
|
||||||
|
- 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_
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/* hot_reload.h
|
||||||
|
*
|
||||||
|
* Public C API for the hot-reload library -- see cpp-style-guide.md
|
||||||
|
* Part II (C API Style) for the naming/formatting rules this header
|
||||||
|
* follows, and section 11 ("Runtime Loading") for why this boundary is
|
||||||
|
* plain C in the first place: anything crossing a dlopen()/LoadLibrary()
|
||||||
|
* boundary must have C linkage, so this is exactly the kind of surface
|
||||||
|
* that section calls out.
|
||||||
|
*
|
||||||
|
* WHAT THIS LIBRARY DOES
|
||||||
|
* -----------------------
|
||||||
|
* hot_reload_load() loads a shared library (.so/.dll) under a logical
|
||||||
|
* name. hot_reload_reload() loads a NEW build of that same shared
|
||||||
|
* library and, for every function the two builds have in common,
|
||||||
|
* rewrites the FIRST FEW BYTES of the old function in memory into a
|
||||||
|
* jump to the new function -- so every existing call site (anyone who
|
||||||
|
* already resolved the old function's address, including via a plain
|
||||||
|
* C function pointer) transparently starts running the new code,
|
||||||
|
* without restarting the process.
|
||||||
|
*
|
||||||
|
* WHAT THIS LIBRARY DELIBERATELY DOES NOT DO
|
||||||
|
* --------------------------------------------
|
||||||
|
* It never patches anything that isn't executable code. A symbol is
|
||||||
|
* only ever a patch candidate if the loader's own symbol table marks
|
||||||
|
* it as a function (ELF STT_FUNC / a PE export whose address falls
|
||||||
|
* inside an executable section) -- see the design note in
|
||||||
|
* SymbolTable.hpp for exactly how that's determined. Global variables,
|
||||||
|
* class/struct instances, vtables, and anything else classified as
|
||||||
|
* data are always left alone, on both the old and the new module --
|
||||||
|
* there is no code path in this library that ever writes to a data
|
||||||
|
* symbol. This is a mechanical guarantee from how symbols are
|
||||||
|
* classified, not a naming convention you opt into.
|
||||||
|
*
|
||||||
|
* It also cannot verify that a hot-reloadable function's SIGNATURE
|
||||||
|
* hasn't changed. Patching only ever redirects control flow at a fixed
|
||||||
|
* entry point; it has no way to know, from a symbol table alone, that
|
||||||
|
* `void tick(int)` became `void tick(int, int)` between builds. If a
|
||||||
|
* function's signature changes, give it a new exported name (so the
|
||||||
|
* old symbol is treated as removed and the new one as added -- see
|
||||||
|
* HOT_RELOAD_SYMBOL_REMOVED below) rather than reusing the old name.
|
||||||
|
* See the top-level README for the full list of hazards this library
|
||||||
|
* cannot detect for you.
|
||||||
|
*
|
||||||
|
* REQUIREMENTS ON A HOT-RELOADABLE MODULE
|
||||||
|
* ------------------------------------------
|
||||||
|
* Matching an old build's function up with the new build's requires a
|
||||||
|
* name that is stable across recompiles. extern "C" linkage -- see
|
||||||
|
* cpp-style-guide.md section 11 -- is the simplest way to get one: no
|
||||||
|
* mangling at all, so the name never depends on the compiler, its
|
||||||
|
* version, or the function's signature.
|
||||||
|
*
|
||||||
|
* A real, unmodified C++ mangled name works too, and needs no special
|
||||||
|
* handling by this library -- classification only ever looks at a
|
||||||
|
* symbol's type and section, never at what its name looks like, so an
|
||||||
|
* ordinary out-of-line method, a virtual method (redirected wherever
|
||||||
|
* it's reached from -- directly or through a vtable slot, since both
|
||||||
|
* hold the same address), and an operator overload all patch exactly
|
||||||
|
* like any extern "C" function. What extern "C" actually buys you is
|
||||||
|
* a name you can type by hand into hot_reload_get_symbol() and a
|
||||||
|
* guarantee that it won't change if you rebuild with a different
|
||||||
|
* compiler; a mangled name gives up both of those, only stays stable
|
||||||
|
* within one ABI family (GCC and Clang share the Itanium C++ ABI;
|
||||||
|
* MSVC does not), and -- the one hazard that has nothing to do with
|
||||||
|
* this library and everything to do with what "hot-reloadable" can
|
||||||
|
* even mean -- only exists to patch at all if the compiler actually
|
||||||
|
* emitted an out-of-line body for it, which an implicitly-inline
|
||||||
|
* method defined in a class body, or a template instantiated at the
|
||||||
|
* call site, generally will not. See the top-level README's "Hot
|
||||||
|
* reloading C++ symbols" section for the full picture, including how
|
||||||
|
* to get a readable name for logging with hot_reload_demangle().
|
||||||
|
*/
|
||||||
|
#ifndef HOT_RELOAD_H
|
||||||
|
#define HOT_RELOAD_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#ifdef HOT_RELOAD_BUILD_SHARED
|
||||||
|
#define HOT_RELOAD_API __declspec(dllexport)
|
||||||
|
#elif defined(HOT_RELOAD_USE_SHARED)
|
||||||
|
#define HOT_RELOAD_API __declspec(dllimport)
|
||||||
|
#else
|
||||||
|
#define HOT_RELOAD_API
|
||||||
|
#endif
|
||||||
|
#else
|
||||||
|
#if defined(HOT_RELOAD_BUILD_SHARED) || defined(HOT_RELOAD_USE_SHARED)
|
||||||
|
#define HOT_RELOAD_API __attribute__((visibility("default")))
|
||||||
|
#else
|
||||||
|
#define HOT_RELOAD_API
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C"
|
||||||
|
{
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Opaque -- see cpp-style-guide.md section 16 ("Do: use opaque
|
||||||
|
* pointers ... to hide implementation details"). The real definition
|
||||||
|
* is the C++ class hot_reload::HotReloadContext (HotReloadContext.hpp),
|
||||||
|
* never visible from this header. */
|
||||||
|
typedef struct HotReloadContext HotReloadContext;
|
||||||
|
|
||||||
|
/* Every fallible function returns one of these -- see section 16
|
||||||
|
* ("Do: return an integer error code / status enum from every
|
||||||
|
* fallible function"). */
|
||||||
|
typedef enum HotReloadStatus
|
||||||
|
{
|
||||||
|
HOT_RELOAD_OK = 0,
|
||||||
|
|
||||||
|
/* ctx, module_name, or path/new_path was NULL, or module_name
|
||||||
|
* was not found for hot_reload_reload / hot_reload_get_symbol. */
|
||||||
|
HOT_RELOAD_ERROR_INVALID_ARGUMENT,
|
||||||
|
|
||||||
|
/* dlopen()/LoadLibrary() (or the equivalent parse of the file on
|
||||||
|
* disk) failed for the given path. On hot_reload_reload(),
|
||||||
|
* this is non-destructive: the previously loaded version is left
|
||||||
|
* running untouched, exactly as if reload had not been called --
|
||||||
|
* see the file comment above HotReloadContext::reload(). Call
|
||||||
|
* hot_reload_last_error() for the platform's own description of
|
||||||
|
* why -- this one status code covers everything from a missing
|
||||||
|
* file to a genuinely corrupt image to (a real case, found while
|
||||||
|
* building this feature) a shared library with an unresolved
|
||||||
|
* symbol reference, and those need very different fixes. */
|
||||||
|
HOT_RELOAD_ERROR_LOAD_FAILED,
|
||||||
|
|
||||||
|
/* hot_reload_reload() was called for a module_name that was never
|
||||||
|
* hot_reload_load()'ed first, so there is no "old" version to
|
||||||
|
* diff against. Call hot_reload_load() once, up front. */
|
||||||
|
HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION,
|
||||||
|
|
||||||
|
/* This process's CPU architecture has no patch backend compiled
|
||||||
|
* in (see Architecture.hpp -- currently x86, x86-64, and ARM64).
|
||||||
|
* Nothing was patched; hot_reload_load() still succeeded, you
|
||||||
|
* just cannot hot_reload_reload() on this target. */
|
||||||
|
HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE
|
||||||
|
} HotReloadStatus;
|
||||||
|
|
||||||
|
/* Reported once per matched/unmatched symbol during hot_reload_reload()
|
||||||
|
* via HotReloadOptions::on_symbol_event, so the host application can
|
||||||
|
* log (or assert on, in a test) exactly what happened -- nothing here
|
||||||
|
* is silent. */
|
||||||
|
typedef enum HotReloadSymbolEvent
|
||||||
|
{
|
||||||
|
/* Existed in both builds and was classified as code in both --
|
||||||
|
* the old function's entry now jumps to the new one. */
|
||||||
|
HOT_RELOAD_SYMBOL_PATCHED,
|
||||||
|
|
||||||
|
/* Exists in the new build only. Nothing to patch -- it becomes
|
||||||
|
* reachable the normal way, e.g. via hot_reload_get_symbol(). */
|
||||||
|
HOT_RELOAD_SYMBOL_ADDED,
|
||||||
|
|
||||||
|
/* Existed in the old build, missing from the new one. Per
|
||||||
|
* cpp-style-guide.md's spirit of never leaving a caller with an
|
||||||
|
* ambiguous, half-updated state: the old implementation is left
|
||||||
|
* running as-is unless HotReloadOptions::on_deleted_symbol
|
||||||
|
* supplies a stub to redirect to instead -- see that field. */
|
||||||
|
HOT_RELOAD_SYMBOL_REMOVED,
|
||||||
|
|
||||||
|
/* Present in both builds by name, but the symbol table classifies
|
||||||
|
* it as data (or something else non-function) in either build --
|
||||||
|
* e.g. an accidentally-exported global. Never touched, in either
|
||||||
|
* direction. This is the mechanical half of the "only executable
|
||||||
|
* code changes" guarantee described at the top of this file. */
|
||||||
|
HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE,
|
||||||
|
|
||||||
|
/* Classified as a function in both builds, but the OLD function's
|
||||||
|
* compiled size is smaller than the number of bytes this
|
||||||
|
* platform's jump sequence needs to write -- patching it would
|
||||||
|
* overwrite whatever comes after it in memory. Skipped rather
|
||||||
|
* than guessed; see hot_reload_enable_patchable_functions() in
|
||||||
|
* cmake/HotReload.cmake for how to stop this from happening. */
|
||||||
|
HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL,
|
||||||
|
|
||||||
|
/* Classified as a function in both builds, correctly sized, but
|
||||||
|
* the OS refused to make its page temporarily writable (mprotect /
|
||||||
|
* VirtualProtect failed -- e.g. a hardened environment that denies
|
||||||
|
* W^X transitions). Rare in a normal developer setup; reported
|
||||||
|
* rather than silently ignored either way. */
|
||||||
|
HOT_RELOAD_SYMBOL_SKIPPED_UNWRITABLE
|
||||||
|
} HotReloadSymbolEvent;
|
||||||
|
|
||||||
|
/* One event report, passed to HotReloadOptions::on_symbol_event.
|
||||||
|
* old_address/new_address are NULL when not applicable (e.g.
|
||||||
|
* old_address is NULL for HOT_RELOAD_SYMBOL_ADDED). */
|
||||||
|
typedef struct HotReloadSymbolEventInfo
|
||||||
|
{
|
||||||
|
const char* name;
|
||||||
|
HotReloadSymbolEvent kind;
|
||||||
|
void* old_address;
|
||||||
|
void* new_address;
|
||||||
|
} HotReloadSymbolEventInfo;
|
||||||
|
|
||||||
|
typedef void (*HotReloadSymbolEventFn)(const HotReloadSymbolEventInfo* event, void* user_data);
|
||||||
|
|
||||||
|
/* Called instead of patching, once per HOT_RELOAD_SYMBOL_REMOVED
|
||||||
|
* event, so the host can catch stale references during development
|
||||||
|
* instead of them silently continuing to run old code forever. Return
|
||||||
|
* the address of a replacement to redirect the old function to (its
|
||||||
|
* only safe use is a fixed-signature stub -- e.g. `void stub(void)`--
|
||||||
|
* that logs/asserts; this library has no way to know the deleted
|
||||||
|
* function's real signature, so redirecting to anything that reads
|
||||||
|
* arguments the caller didn't pass is undefined behavior, exactly
|
||||||
|
* like calling any function through the wrong function-pointer type).
|
||||||
|
* Return NULL (or pass a NULL callback in HotReloadOptions to begin
|
||||||
|
* with) to leave the old implementation running untouched -- the
|
||||||
|
* default, and the safe choice unless you specifically want deleted
|
||||||
|
* functions to be loud. */
|
||||||
|
typedef void* (*HotReloadDeletedSymbolFn)(const char* symbol_name, void* user_data);
|
||||||
|
|
||||||
|
/* Passed to hot_reload_context_create(); NULL is a valid options
|
||||||
|
* pointer meaning "no callbacks, default policy for everything". */
|
||||||
|
typedef struct HotReloadOptions
|
||||||
|
{
|
||||||
|
HotReloadSymbolEventFn on_symbol_event; /* nullable */
|
||||||
|
HotReloadDeletedSymbolFn on_deleted_symbol; /* nullable */
|
||||||
|
void* user_data; /* passed back to both callbacks, untouched */
|
||||||
|
} HotReloadOptions;
|
||||||
|
|
||||||
|
/* Caller owns the returned pointer and must call
|
||||||
|
* hot_reload_context_destroy() -- see cpp-style-guide.md section 16
|
||||||
|
* ("Document ownership transfer explicitly"). options may be NULL. */
|
||||||
|
HOT_RELOAD_API HotReloadContext* hot_reload_context_create(const HotReloadOptions* options);
|
||||||
|
|
||||||
|
HOT_RELOAD_API void hot_reload_context_destroy(HotReloadContext* ctx);
|
||||||
|
|
||||||
|
/* Loads path for the first time under module_name -- an arbitrary
|
||||||
|
* caller-chosen key used to look the module back up in later
|
||||||
|
* hot_reload_reload()/hot_reload_get_symbol() calls; it does not need
|
||||||
|
* to match anything on disk. Returns HOT_RELOAD_ERROR_INVALID_ARGUMENT
|
||||||
|
* if module_name was already loaded -- call hot_reload_reload()
|
||||||
|
* instead of hot_reload_load() again for the same name. */
|
||||||
|
HOT_RELOAD_API HotReloadStatus hot_reload_load(HotReloadContext* ctx, const char* module_name, const char* path);
|
||||||
|
|
||||||
|
/* Loads a NEW build of the module previously registered under
|
||||||
|
* module_name, diffs its exported functions against the previous
|
||||||
|
* version, and patches every one that is safe to patch in place --
|
||||||
|
* see HotReloadSymbolEvent above for exactly what happens to each
|
||||||
|
* kind of change. On any HOT_RELOAD_ERROR_* return, nothing was
|
||||||
|
* changed: the previous version keeps running exactly as it was. */
|
||||||
|
HOT_RELOAD_API HotReloadStatus hot_reload_reload(HotReloadContext* ctx, const char* module_name, const char* new_path);
|
||||||
|
|
||||||
|
/* Resolves symbol_name in module_name's CURRENT build (i.e. always
|
||||||
|
* the freshest one loaded, patched or not) -- for calling a function
|
||||||
|
* that did not exist yet when the host first started, such as one
|
||||||
|
* reported via HOT_RELOAD_SYMBOL_ADDED. Returns NULL if module_name
|
||||||
|
* is unknown or symbol_name is not an exported function in it. */
|
||||||
|
HOT_RELOAD_API void* hot_reload_get_symbol(HotReloadContext* ctx, const char* module_name, const char* symbol_name);
|
||||||
|
|
||||||
|
/* Only meaningful immediately after hot_reload_load() or
|
||||||
|
* hot_reload_reload() on this SAME ctx returned something other than
|
||||||
|
* HOT_RELOAD_OK: the underlying platform's own description of why --
|
||||||
|
* dlerror() on POSIX, the formatted message for GetLastError() on
|
||||||
|
* Windows -- e.g. "undefined symbol: _ZN6Widget4tickEi" for a shared
|
||||||
|
* library with an unresolved reference (a real, confusing-until-you-
|
||||||
|
* see-this-message case: a non-inline static class member that is
|
||||||
|
* declared but never given an out-of-class definition compiles fine
|
||||||
|
* and links fine into a shared library -- the linker allows a shared
|
||||||
|
* library to keep undefined symbols by default -- and only fails,
|
||||||
|
* right here, once something ODR-uses it and the loader tries to
|
||||||
|
* resolve it for real). Overwritten by the next
|
||||||
|
* hot_reload_load()/hot_reload_reload() call on this ctx; copy it out
|
||||||
|
* immediately if you need to keep it past that point (e.g. for a log
|
||||||
|
* line). Empty if the platform gave no further detail, or if the
|
||||||
|
* failure never reached the platform loader at all (e.g. a NULL
|
||||||
|
* argument, which is HOT_RELOAD_ERROR_INVALID_ARGUMENT before
|
||||||
|
* anything platform-specific happens). A static string internally --
|
||||||
|
* never free() this. */
|
||||||
|
HOT_RELOAD_API const char* hot_reload_last_error(HotReloadContext* ctx);
|
||||||
|
|
||||||
|
/* A static string literal -- never free() this. */
|
||||||
|
HOT_RELOAD_API const char* hot_reload_status_string(HotReloadStatus status);
|
||||||
|
|
||||||
|
/* Renders name for a human to read: a real C++ mangled name (Itanium
|
||||||
|
* on GCC/Clang/MinGW, MSVC's own scheme under MSVC -- whichever this
|
||||||
|
* library itself was built with) becomes something like
|
||||||
|
* "Widget::tick(int)"; anything else, including an ordinary extern "C"
|
||||||
|
* name, comes back unchanged. Safe to call unconditionally on any
|
||||||
|
* HotReloadSymbolEventInfo::name or hot_reload_get_symbol() key --
|
||||||
|
* this never changes what name means for lookup purposes, it is only
|
||||||
|
* ever applied when you ask for it, purely for display. Returns NULL
|
||||||
|
* only if name itself is NULL or allocation fails; caller must free
|
||||||
|
* a non-NULL result with hot_reload_free_string(). */
|
||||||
|
HOT_RELOAD_API char* hot_reload_demangle(const char* name);
|
||||||
|
|
||||||
|
HOT_RELOAD_API void hot_reload_free_string(char* str);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* HOT_RELOAD_H */
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
#include "Architecture.hpp"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
/* WHY THERE CAN BE A "LANDING PAD" IN FRONT OF A FUNCTION
|
||||||
|
* -----------------------------------------------------------
|
||||||
|
* Modern toolchains can emit a marker instruction as the very first
|
||||||
|
* instruction of a function, required by a CPU-level indirect-branch
|
||||||
|
* protection feature: x86 CET ("Control-flow Enforcement Technology",
|
||||||
|
* ENDBR64/ENDBR32) and ARM64 BTI ("Branch Target Identification").
|
||||||
|
* When the CPU enforces either, an indirect call/jump (which is
|
||||||
|
* exactly how a call through a function pointer, or a call across a
|
||||||
|
* shared-library boundary via the PLT/GOT, actually reaches a
|
||||||
|
* function) is only allowed to land on one of these marker
|
||||||
|
* instructions -- landing anywhere else raises a fault.
|
||||||
|
*
|
||||||
|
* This matters here because it is genuinely on by default in
|
||||||
|
* mainstream environments, not a rare opt-in: this exact toolchain
|
||||||
|
* (Ubuntu 24.04's system GCC 13, x86-64) emits ENDBR64 at every
|
||||||
|
* function's entry with no extra flags beyond -fpatchable-function-entry,
|
||||||
|
* confirmed empirically while building this library --
|
||||||
|
* `gcc -O2 -fpatchable-function-entry=16,0` alone was enough to produce
|
||||||
|
* an `endbr64` as the very first instruction, ahead of the requested
|
||||||
|
* NOP padding. Overwriting it unconditionally would silently work on
|
||||||
|
* a CPU/kernel combination that does not enforce IBT and silently
|
||||||
|
* crash on one that does -- exactly the kind of environment-dependent
|
||||||
|
* failure this library exists to avoid.
|
||||||
|
*
|
||||||
|
* So every backend below first checks the live bytes at the function's
|
||||||
|
* entry for its architecture's landing-pad encoding and, if present,
|
||||||
|
* treats it as a fixed, immovable prefix: the jump is written
|
||||||
|
* immediately after it rather than over it. Since a landing pad is
|
||||||
|
* defined to otherwise behave as a NOP, falling through it into our
|
||||||
|
* jump is always correct whether or not the CPU actually enforces the
|
||||||
|
* protection it marks.
|
||||||
|
*
|
||||||
|
* WRITE ORDERING (AND WHAT IT DOES / DOES NOT PROTECT AGAINST)
|
||||||
|
* -----------------------------------------------------------
|
||||||
|
* Every backend below writes the target-address bytes first and the
|
||||||
|
* leading opcode/instruction word that "arms" the jump last, with a
|
||||||
|
* release fence in between. This protects the realistic case this
|
||||||
|
* library is designed for: hot_reload_reload() runs at a quiescent
|
||||||
|
* point (e.g. your main thread between frames) where nothing is
|
||||||
|
* actively calling INTO the function while it is being patched, and
|
||||||
|
* this ordering guarantees that any call arriving right at that
|
||||||
|
* boundary sees either the fully-old or the fully-new jump, never a
|
||||||
|
* target address that is half-old-half-new. It does NOT make the
|
||||||
|
* write safe against a thread that is concurrently, actively
|
||||||
|
* executing through the exact bytes being patched -- doing that in
|
||||||
|
* general on a variable-length ISA needs a signal-handler-based retry
|
||||||
|
* protocol (a much bigger mechanism, well beyond a dev-loop tool's
|
||||||
|
* requirements). See the README for this precondition stated plainly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace hot_reload::detail
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
#if defined(__x86_64__) || defined(_M_X64)
|
||||||
|
|
||||||
|
constexpr std::size_t kLandingPadBytes = 4;
|
||||||
|
constexpr unsigned char kEndbr64[kLandingPadBytes] = {0xF3, 0x0F, 0x1E, 0xFA};
|
||||||
|
// jmp qword ptr [rip+0]; <8-byte absolute address> -- reaches
|
||||||
|
// any 64-bit address regardless of how far apart the old and
|
||||||
|
// new modules were loaded, unlike a 5-byte relative jump
|
||||||
|
// (E9 rel32) which only reaches +/-2GB. Verified byte-for-byte
|
||||||
|
// against `as`/objdump output while building this library.
|
||||||
|
constexpr std::size_t kJumpBytes = 14;
|
||||||
|
|
||||||
|
bool hasLandingPad(const unsigned char* entry)
|
||||||
|
{
|
||||||
|
return std::memcmp(entry, kEndbr64, kLandingPadBytes) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeJumpAt(unsigned char* site, const void* target)
|
||||||
|
{
|
||||||
|
std::uint64_t targetValue = 0;
|
||||||
|
std::memcpy(&targetValue, &target, sizeof(target));
|
||||||
|
|
||||||
|
// Payload first: the absolute address (site+6) and the
|
||||||
|
// constant zero rip-displacement (site+2) are only ever
|
||||||
|
// consumed as DATA once the opcode below is in place, so
|
||||||
|
// writing them early cannot be misinterpreted as a
|
||||||
|
// different, fresh instruction stream by anything that
|
||||||
|
// might fall through this region in its old, all-NOP state.
|
||||||
|
std::memcpy(site + 6, &targetValue, sizeof(targetValue));
|
||||||
|
std::memcpy(site + 2, "\x00\x00\x00\x00", 4);
|
||||||
|
std::atomic_thread_fence(std::memory_order_release);
|
||||||
|
|
||||||
|
// Arm last: until these 2 bytes land, site+0 still reads as
|
||||||
|
// whatever it was (a NOP, on first patch; a previous FF 25
|
||||||
|
// on a later reload -- either way harmless/inert).
|
||||||
|
unsigned char opcode[2] = {0xFF, 0x25};
|
||||||
|
std::memcpy(site, opcode, sizeof(opcode));
|
||||||
|
std::atomic_thread_fence(std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
#elif defined(__i386__) || defined(_M_IX86)
|
||||||
|
|
||||||
|
constexpr std::size_t kLandingPadBytes = 4;
|
||||||
|
constexpr unsigned char kEndbr32[kLandingPadBytes] = {0xF3, 0x0F, 0x1E, 0xFB};
|
||||||
|
// e9 rel32 -- a plain relative near jump. On 32-bit x86 the
|
||||||
|
// entire address space fits in the signed 32-bit displacement
|
||||||
|
// (mod 2^32 wraparound gives the right answer regardless of
|
||||||
|
// "distance"), so unlike x86-64 there is no need for an
|
||||||
|
// absolute/indirect form here.
|
||||||
|
constexpr std::size_t kJumpBytes = 5;
|
||||||
|
|
||||||
|
bool hasLandingPad(const unsigned char* entry)
|
||||||
|
{
|
||||||
|
return std::memcmp(entry, kEndbr32, kLandingPadBytes) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeJumpAt(unsigned char* site, const void* target)
|
||||||
|
{
|
||||||
|
std::int32_t rel32 = static_cast<std::int32_t>(
|
||||||
|
reinterpret_cast<std::intptr_t>(target) - reinterpret_cast<std::intptr_t>(site) - 5);
|
||||||
|
|
||||||
|
// Payload (the displacement) first, opcode byte last --
|
||||||
|
// same reasoning as the x86-64 backend above.
|
||||||
|
std::memcpy(site + 1, &rel32, sizeof(rel32));
|
||||||
|
std::atomic_thread_fence(std::memory_order_release);
|
||||||
|
site[0] = 0xE9;
|
||||||
|
std::atomic_thread_fence(std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
#elif defined(__aarch64__) || defined(_M_ARM64)
|
||||||
|
|
||||||
|
constexpr std::size_t kLandingPadBytes = 4;
|
||||||
|
|
||||||
|
// Matches any BTI variant (plain/C/J/JC): bits [31:6] are fixed
|
||||||
|
// at 0b010101000000001001001, differing only in bits [6:5]
|
||||||
|
// (the qualifier) -- equivalently, the low byte is one of
|
||||||
|
// 0x1F/0x5F/0x9F/0xDF and the next 3 bytes are always
|
||||||
|
// 24 03 D5. Verified with `as`/objdump for all 4 variants
|
||||||
|
// while building this library.
|
||||||
|
bool isBti(const unsigned char* entry)
|
||||||
|
{
|
||||||
|
return (entry[0] & 0x3Fu) == 0x1Fu && entry[1] == 0x24 && entry[2] == 0x03 && entry[3] == 0xD5;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasLandingPad(const unsigned char* entry)
|
||||||
|
{
|
||||||
|
return isBti(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ldr x17, #8 ; br x17 ; <8-byte absolute address>. x16/x17
|
||||||
|
// are AArch64's designated "intra-procedure-call" scratch
|
||||||
|
// registers (IP0/IP1) -- the AAPCS64 explicitly reserves them
|
||||||
|
// for exactly this kind of linker/veneer-style long branch, so
|
||||||
|
// clobbering them at a function's entry point, before any of
|
||||||
|
// its real prologue has executed, is always safe. Byte values
|
||||||
|
// verified with `as`/objdump while building this library.
|
||||||
|
constexpr std::size_t kJumpBytes = 16;
|
||||||
|
constexpr unsigned char kLdrX17Pc8[4] = {0x51, 0x00, 0x00, 0x58};
|
||||||
|
constexpr unsigned char kBrX17[4] = {0x20, 0x02, 0x1F, 0xD6};
|
||||||
|
|
||||||
|
void writeJumpAt(unsigned char* site, const void* target)
|
||||||
|
{
|
||||||
|
std::uint64_t targetValue = 0;
|
||||||
|
std::memcpy(&targetValue, &target, sizeof(target));
|
||||||
|
|
||||||
|
// Payload (the literal address) first. ARM64 instructions
|
||||||
|
// are fixed-width and naturally aligned, so -- unlike the
|
||||||
|
// variable-length x86 case -- each 4-byte slot here is
|
||||||
|
// unambiguous: it is either the old NOP, the old
|
||||||
|
// instruction, or the new one, never a byte-misaligned
|
||||||
|
// mix. Writing high-to-low keeps the two real instructions
|
||||||
|
// (which is what any in-flight fall-through NOP execution
|
||||||
|
// could actually reach) inert for as long as possible.
|
||||||
|
std::memcpy(site + 8, &targetValue, sizeof(targetValue));
|
||||||
|
std::atomic_thread_fence(std::memory_order_release);
|
||||||
|
std::memcpy(site + 4, kBrX17, sizeof(kBrX17));
|
||||||
|
std::atomic_thread_fence(std::memory_order_release);
|
||||||
|
|
||||||
|
// Arm last: until this word lands, site+0 still reads as a
|
||||||
|
// NOP (or a previous ldr, on a later reload) and execution
|
||||||
|
// never reaches the br at site+4 via fall-through.
|
||||||
|
std::memcpy(site, kLdrX17Pc8, sizeof(kLdrX17Pc8));
|
||||||
|
std::atomic_thread_fence(std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isArchitectureSupported()
|
||||||
|
{
|
||||||
|
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
|
||||||
|
defined(_M_ARM64)
|
||||||
|
return true;
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
PatchLayout planPatch(const void* entryAddress)
|
||||||
|
{
|
||||||
|
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
|
||||||
|
defined(_M_ARM64)
|
||||||
|
const unsigned char* entry = static_cast<const unsigned char*>(entryAddress);
|
||||||
|
PatchLayout layout;
|
||||||
|
layout.patchOffset = hasLandingPad(entry) ? kLandingPadBytes : 0;
|
||||||
|
layout.totalBytes = layout.patchOffset + kJumpBytes;
|
||||||
|
return layout;
|
||||||
|
#else
|
||||||
|
(void) entryAddress;
|
||||||
|
return PatchLayout{};
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool writeJump(void* entryAddress, const void* targetAddress)
|
||||||
|
{
|
||||||
|
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) || defined(__aarch64__) || \
|
||||||
|
defined(_M_ARM64)
|
||||||
|
PatchLayout layout = planPatch(entryAddress);
|
||||||
|
unsigned char* site = static_cast<unsigned char*>(entryAddress) + layout.patchOffset;
|
||||||
|
writeJumpAt(site, targetAddress);
|
||||||
|
return true;
|
||||||
|
#else
|
||||||
|
(void) entryAddress;
|
||||||
|
(void) targetAddress;
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace hot_reload::detail
|
||||||
|
{
|
||||||
|
|
||||||
|
/// What planPatch() found at one specific function's entry point --
|
||||||
|
/// how many bytes from the entry address must be safe to overwrite,
|
||||||
|
/// and how many of those (if any) are a landing pad that must be
|
||||||
|
/// preserved rather than overwritten. See Architecture.cpp's file
|
||||||
|
/// comment for why a landing pad can be present at all.
|
||||||
|
struct PatchLayout
|
||||||
|
{
|
||||||
|
/// Total bytes from the function's entry address that this
|
||||||
|
/// patch touches, landing pad included -- compare this against
|
||||||
|
/// the symbol's compiled size before writing anything.
|
||||||
|
std::size_t totalBytes = 0;
|
||||||
|
|
||||||
|
/// How many leading bytes are a landing pad and must be left
|
||||||
|
/// untouched; the jump itself is written starting at
|
||||||
|
/// entryAddress + patchOffset.
|
||||||
|
std::size_t patchOffset = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// True if this build has a jump-patch backend for the CPU
|
||||||
|
/// architecture it was compiled for (decided at compile time --
|
||||||
|
/// see the top of Architecture.cpp). x86, x86-64, and ARM64 are
|
||||||
|
/// supported; anything else (including 32-bit ARM, whose ARM/Thumb
|
||||||
|
/// interworking this library does not attempt -- see the README)
|
||||||
|
/// returns false here, and HotReloadContext surfaces that as
|
||||||
|
/// HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE.
|
||||||
|
bool isArchitectureSupported();
|
||||||
|
|
||||||
|
/// Inspects the bytes already sitting at entryAddress to determine
|
||||||
|
/// the real patch layout for THIS function, accounting for a
|
||||||
|
/// possible leading landing pad. Safe to call even when
|
||||||
|
/// isArchitectureSupported() is false (returns a zeroed PatchLayout).
|
||||||
|
PatchLayout planPatch(const void* entryAddress);
|
||||||
|
|
||||||
|
/// Overwrites entryAddress so that control transfers unconditionally
|
||||||
|
/// to targetAddress, following the layout planPatch() would return
|
||||||
|
/// for this same entryAddress. The caller must already have made
|
||||||
|
/// [entryAddress, entryAddress + planPatch(entryAddress).totalBytes)
|
||||||
|
/// writable (see MemoryProtection.hpp) and must flush the
|
||||||
|
/// instruction cache over that same range afterwards -- this
|
||||||
|
/// function does neither.
|
||||||
|
///
|
||||||
|
/// Only meaningful precondition: the caller has already checked the
|
||||||
|
/// target symbol's compiled size against planPatch(entryAddress)
|
||||||
|
/// .totalBytes; this function does not re-check it and will happily
|
||||||
|
/// overwrite whatever comes next in memory if asked to.
|
||||||
|
///
|
||||||
|
/// Returns false only if isArchitectureSupported() is false.
|
||||||
|
bool writeJump(void* entryAddress, const void* targetAddress);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
#include "HotReloadContext.hpp"
|
||||||
|
|
||||||
|
#include "Architecture.hpp"
|
||||||
|
#include "MemoryProtection.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <fstream>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
|
namespace hot_reload
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
bool copyFile(const std::string& from, const std::string& to)
|
||||||
|
{
|
||||||
|
std::ifstream in(from, std::ios::binary);
|
||||||
|
if (!in)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::ofstream out(to, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!out)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out << in.rdbuf();
|
||||||
|
return static_cast<bool>(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HotReloadContext::HotReloadContext(const HotReloadOptions* options)
|
||||||
|
: m_onSymbolEvent(nullptr)
|
||||||
|
, m_onDeletedSymbol(nullptr)
|
||||||
|
, m_userData(nullptr)
|
||||||
|
{
|
||||||
|
if (options != nullptr)
|
||||||
|
{
|
||||||
|
m_onSymbolEvent = options->on_symbol_event;
|
||||||
|
m_onDeletedSymbol = options->on_deleted_symbol;
|
||||||
|
m_userData = options->user_data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HotReloadContext::emitEvent(HotReloadSymbolEvent kind, const std::string& name, void* oldAddress, void* newAddress)
|
||||||
|
{
|
||||||
|
if (m_onSymbolEvent == nullptr)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HotReloadSymbolEventInfo info;
|
||||||
|
info.name = name.c_str();
|
||||||
|
info.kind = kind;
|
||||||
|
info.old_address = oldAddress;
|
||||||
|
info.new_address = newAddress;
|
||||||
|
m_onSymbolEvent(&info, m_userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string HotReloadContext::stagePathForLoad(
|
||||||
|
LoadedModule& module, const std::string& moduleName, const std::string& sourcePath)
|
||||||
|
{
|
||||||
|
// Every load (first and subsequent) goes through a freshly,
|
||||||
|
// uniquely-named copy rather than dlopen()/LoadLibrary()'ing
|
||||||
|
// sourcePath directly. Without this, a build that overwrites
|
||||||
|
// the SAME path in place for every rebuild (rather than the
|
||||||
|
// more common write-temp-then-rename pattern) can hit
|
||||||
|
// dlopen's device+inode-based caching and hand back the
|
||||||
|
// already-mapped OLD content instead of genuinely reloading --
|
||||||
|
// a well-known gotcha for this style of tool. Copying to a
|
||||||
|
// name guaranteed never reused sidesteps it regardless of how
|
||||||
|
// the build produced sourcePath.
|
||||||
|
++module.reloadCounter;
|
||||||
|
std::string staged = sourcePath + ".hotreload." + moduleName + "." + std::to_string(module.reloadCounter);
|
||||||
|
if (!copyFile(sourcePath, staged))
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return staged;
|
||||||
|
}
|
||||||
|
|
||||||
|
HotReloadStatus HotReloadContext::load(const std::string& moduleName, const std::string& path)
|
||||||
|
{
|
||||||
|
m_lastError.clear();
|
||||||
|
|
||||||
|
if (m_modules.find(moduleName) != m_modules.end())
|
||||||
|
{
|
||||||
|
// Already loaded -- call reload() to load a new build of
|
||||||
|
// an existing name, rather than silently discarding
|
||||||
|
// whatever was already patched into the first one.
|
||||||
|
return HOT_RELOAD_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadedModule module;
|
||||||
|
std::string staged = stagePathForLoad(module, moduleName, path);
|
||||||
|
if (staged.empty())
|
||||||
|
{
|
||||||
|
m_lastError = "could not stage '" + path + "' for loading (source unreadable, or destination path unwritable)";
|
||||||
|
return HOT_RELOAD_ERROR_LOAD_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
detail::ModuleHandle handle = detail::ModuleHandle::load(staged);
|
||||||
|
if (!handle.isValid())
|
||||||
|
{
|
||||||
|
m_lastError = handle.errorMessage();
|
||||||
|
std::remove(staged.c_str());
|
||||||
|
return HOT_RELOAD_ERROR_LOAD_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.currentSymbols = detail::readExportedSymbols(staged, handle.loadedBase());
|
||||||
|
std::remove(staged.c_str()); // best-effort tidy-up; the mapping survives regardless (see stagePathForLoad)
|
||||||
|
module.versions.push_back(std::move(handle));
|
||||||
|
|
||||||
|
m_modules.emplace(moduleName, std::move(module));
|
||||||
|
return HOT_RELOAD_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
HotReloadStatus HotReloadContext::reload(const std::string& moduleName, const std::string& newPath)
|
||||||
|
{
|
||||||
|
m_lastError.clear();
|
||||||
|
|
||||||
|
auto it = m_modules.find(moduleName);
|
||||||
|
if (it == m_modules.end())
|
||||||
|
{
|
||||||
|
return HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION;
|
||||||
|
}
|
||||||
|
if (!detail::isArchitectureSupported())
|
||||||
|
{
|
||||||
|
return HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadedModule& module = it->second;
|
||||||
|
|
||||||
|
std::string staged = stagePathForLoad(module, moduleName, newPath);
|
||||||
|
if (staged.empty())
|
||||||
|
{
|
||||||
|
m_lastError =
|
||||||
|
"could not stage '" + newPath + "' for loading (source unreadable, or destination path unwritable)";
|
||||||
|
return HOT_RELOAD_ERROR_LOAD_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
detail::ModuleHandle newHandle = detail::ModuleHandle::load(staged);
|
||||||
|
if (!newHandle.isValid())
|
||||||
|
{
|
||||||
|
m_lastError = newHandle.errorMessage();
|
||||||
|
std::remove(staged.c_str());
|
||||||
|
return HOT_RELOAD_ERROR_LOAD_FAILED; // nothing touched -- the previous version keeps running
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<detail::ExportedSymbol> newSymbols = detail::readExportedSymbols(staged, newHandle.loadedBase());
|
||||||
|
std::remove(staged.c_str());
|
||||||
|
|
||||||
|
std::unordered_map<std::string, const detail::ExportedSymbol*> newByName;
|
||||||
|
newByName.reserve(newSymbols.size());
|
||||||
|
for (const detail::ExportedSymbol& symbol : newSymbols)
|
||||||
|
{
|
||||||
|
newByName[symbol.name] = &symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unordered_set<std::string> matchedNewNames;
|
||||||
|
for (const detail::ExportedSymbol& oldSymbol : module.currentSymbols)
|
||||||
|
{
|
||||||
|
auto found = newByName.find(oldSymbol.name);
|
||||||
|
if (found == newByName.end())
|
||||||
|
{
|
||||||
|
handleRemoved(oldSymbol);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail::ExportedSymbol& newSymbol = *found->second;
|
||||||
|
matchedNewNames.insert(oldSymbol.name);
|
||||||
|
|
||||||
|
if (!oldSymbol.isFunction || !newSymbol.isFunction)
|
||||||
|
{
|
||||||
|
// Present on both sides, but classified as data (or
|
||||||
|
// something this library does not recognize as code)
|
||||||
|
// on at least one side -- the mechanical half of the
|
||||||
|
// "only executable code changes" guarantee. Never
|
||||||
|
// touched, in either direction.
|
||||||
|
emitEvent(HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE, oldSymbol.name, oldSymbol.address, newSymbol.address);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
patchOne(oldSymbol, newSymbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const detail::ExportedSymbol& newSymbol : newSymbols)
|
||||||
|
{
|
||||||
|
if (matchedNewNames.find(newSymbol.name) == matchedNewNames.end())
|
||||||
|
{
|
||||||
|
emitEvent(HOT_RELOAD_SYMBOL_ADDED, newSymbol.name, nullptr, newSymbol.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.currentSymbols = std::move(newSymbols);
|
||||||
|
module.versions.push_back(std::move(newHandle));
|
||||||
|
return HOT_RELOAD_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void* HotReloadContext::getSymbol(const std::string& moduleName, const std::string& symbolName) const
|
||||||
|
{
|
||||||
|
auto it = m_modules.find(moduleName);
|
||||||
|
if (it == m_modules.end())
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
for (const detail::ExportedSymbol& symbol : it->second.currentSymbols)
|
||||||
|
{
|
||||||
|
if (symbol.name == symbolName)
|
||||||
|
{
|
||||||
|
return symbol.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HotReloadContext::patchOne(const detail::ExportedSymbol& oldSymbol, const detail::ExportedSymbol& newSymbol)
|
||||||
|
{
|
||||||
|
detail::PatchLayout layout = detail::planPatch(oldSymbol.address);
|
||||||
|
if (oldSymbol.size != 0 && oldSymbol.size < layout.totalBytes)
|
||||||
|
{
|
||||||
|
emitEvent(HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL, oldSymbol.name, oldSymbol.address, newSymbol.address);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detail::ScopedCodeWriteAccess writable(oldSymbol.address, layout.totalBytes);
|
||||||
|
if (!writable.isValid())
|
||||||
|
{
|
||||||
|
emitEvent(HOT_RELOAD_SYMBOL_SKIPPED_UNWRITABLE, oldSymbol.name, oldSymbol.address, newSymbol.address);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detail::writeJump(oldSymbol.address, newSymbol.address);
|
||||||
|
writable.finishWrite();
|
||||||
|
emitEvent(HOT_RELOAD_SYMBOL_PATCHED, oldSymbol.name, oldSymbol.address, newSymbol.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HotReloadContext::handleRemoved(const detail::ExportedSymbol& oldSymbol)
|
||||||
|
{
|
||||||
|
// Ask for a stub at most once, and only for an old symbol that
|
||||||
|
// was itself real code -- there is no sensible "redirect" for
|
||||||
|
// a removed data symbol. A returned NULL (including because no
|
||||||
|
// callback was supplied at all) means the default: leave the
|
||||||
|
// old implementation running untouched.
|
||||||
|
void* stub = nullptr;
|
||||||
|
if (oldSymbol.isFunction && m_onDeletedSymbol != nullptr)
|
||||||
|
{
|
||||||
|
stub = m_onDeletedSymbol(oldSymbol.name.c_str(), m_userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stub != nullptr)
|
||||||
|
{
|
||||||
|
detail::PatchLayout layout = detail::planPatch(oldSymbol.address);
|
||||||
|
bool sizeOk = oldSymbol.size == 0 || oldSymbol.size >= layout.totalBytes;
|
||||||
|
if (sizeOk)
|
||||||
|
{
|
||||||
|
detail::ScopedCodeWriteAccess writable(oldSymbol.address, layout.totalBytes);
|
||||||
|
if (writable.isValid())
|
||||||
|
{
|
||||||
|
detail::writeJump(oldSymbol.address, stub);
|
||||||
|
writable.finishWrite();
|
||||||
|
}
|
||||||
|
// If the OS refused write access, or the function was
|
||||||
|
// too small to safely hold the stub redirect, this
|
||||||
|
// silently falls back to leaving the old implementation
|
||||||
|
// running -- the same safe default as not supplying a
|
||||||
|
// stub at all, rather than adding a second kind of
|
||||||
|
// event on top of HOT_RELOAD_SYMBOL_REMOVED below for
|
||||||
|
// what is already a rare, best-effort extra.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitEvent(HOT_RELOAD_SYMBOL_REMOVED, oldSymbol.name, oldSymbol.address, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "ModuleHandle.hpp"
|
||||||
|
#include "SymbolTable.hpp"
|
||||||
|
#include "hot_reload/hot_reload.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace hot_reload
|
||||||
|
{
|
||||||
|
|
||||||
|
/// The real type behind the opaque ::HotReloadContext the public C
|
||||||
|
/// API hands out -- see HotReloadApi.cpp for the extern "C" shim
|
||||||
|
/// that casts between the two, following the same opaque-pointer
|
||||||
|
/// PIMPL pattern as cpp-style-guide.md's own NetManager example.
|
||||||
|
class HotReloadContext
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit HotReloadContext(const HotReloadOptions* options);
|
||||||
|
|
||||||
|
HotReloadContext(const HotReloadContext&) = delete;
|
||||||
|
HotReloadContext& operator=(const HotReloadContext&) = delete;
|
||||||
|
|
||||||
|
/// See hot_reload_load()'s doc comment in hot_reload.h.
|
||||||
|
HotReloadStatus load(const std::string& moduleName, const std::string& path);
|
||||||
|
|
||||||
|
/// See hot_reload_reload()'s doc comment in hot_reload.h.
|
||||||
|
HotReloadStatus reload(const std::string& moduleName, const std::string& newPath);
|
||||||
|
|
||||||
|
/// See hot_reload_get_symbol()'s doc comment in hot_reload.h.
|
||||||
|
void* getSymbol(const std::string& moduleName, const std::string& symbolName) const;
|
||||||
|
|
||||||
|
/// See hot_reload_last_error()'s doc comment in hot_reload.h.
|
||||||
|
const std::string& lastError() const
|
||||||
|
{
|
||||||
|
return m_lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
/// Everything tracked for one logical module name. Has no
|
||||||
|
/// meaning outside HotReloadContext, so it lives here rather
|
||||||
|
/// than in its own header -- see cpp-style-guide.md's carve-out
|
||||||
|
/// for small types with no use outside their owning type.
|
||||||
|
struct LoadedModule
|
||||||
|
{
|
||||||
|
// Every version ever loaded for this name, kept alive for
|
||||||
|
// the rest of the process's lifetime: once a function is
|
||||||
|
// patched, anything that already holds its old address
|
||||||
|
// must stay valid memory forever, even though it now holds
|
||||||
|
// nothing but a jump. versions.back() is the active one.
|
||||||
|
std::vector<detail::ModuleHandle> versions;
|
||||||
|
std::vector<detail::ExportedSymbol> currentSymbols;
|
||||||
|
int reloadCounter = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string stagePathForLoad(LoadedModule& module, const std::string& moduleName, const std::string& sourcePath);
|
||||||
|
void patchOne(const detail::ExportedSymbol& oldSymbol, const detail::ExportedSymbol& newSymbol);
|
||||||
|
void handleRemoved(const detail::ExportedSymbol& oldSymbol);
|
||||||
|
void emitEvent(HotReloadSymbolEvent kind, const std::string& name, void* oldAddress, void* newAddress);
|
||||||
|
|
||||||
|
std::unordered_map<std::string, LoadedModule> m_modules;
|
||||||
|
HotReloadSymbolEventFn m_onSymbolEvent;
|
||||||
|
HotReloadDeletedSymbolFn m_onDeletedSymbol;
|
||||||
|
void* m_userData;
|
||||||
|
std::string m_lastError;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#include "MemoryProtection.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace hot_reload::detail
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
std::size_t systemPageSize()
|
||||||
|
{
|
||||||
|
#if defined(_WIN32)
|
||||||
|
SYSTEM_INFO info;
|
||||||
|
GetSystemInfo(&info);
|
||||||
|
return static_cast<std::size_t>(info.dwPageSize);
|
||||||
|
#else
|
||||||
|
long size = sysconf(_SC_PAGESIZE);
|
||||||
|
return size > 0 ? static_cast<std::size_t>(size) : 4096;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScopedCodeWriteAccess::ScopedCodeWriteAccess(void* address, std::size_t length)
|
||||||
|
: m_pageAddress(nullptr)
|
||||||
|
, m_pageLength(0)
|
||||||
|
, m_rangeAddress(address)
|
||||||
|
, m_rangeLength(length)
|
||||||
|
, m_previousProtection(0)
|
||||||
|
, m_valid(false)
|
||||||
|
{
|
||||||
|
const std::size_t page = systemPageSize();
|
||||||
|
const std::uintptr_t start = reinterpret_cast<std::uintptr_t>(address);
|
||||||
|
const std::uintptr_t alignedStart = start - (start % page);
|
||||||
|
const std::size_t frontSlack = static_cast<std::size_t>(start - alignedStart);
|
||||||
|
const std::size_t alignedLength = ((frontSlack + length + page - 1) / page) * page;
|
||||||
|
|
||||||
|
m_pageAddress = reinterpret_cast<void*>(alignedStart);
|
||||||
|
m_pageLength = alignedLength;
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
DWORD oldProtect = 0;
|
||||||
|
if (VirtualProtect(m_pageAddress, m_pageLength, PAGE_EXECUTE_READWRITE, &oldProtect))
|
||||||
|
{
|
||||||
|
m_previousProtection = oldProtect;
|
||||||
|
m_valid = true;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if (mprotect(m_pageAddress, m_pageLength, PROT_READ | PROT_WRITE | PROT_EXEC) == 0)
|
||||||
|
{
|
||||||
|
m_previousProtection = PROT_READ | PROT_EXEC;
|
||||||
|
m_valid = true;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
ScopedCodeWriteAccess::~ScopedCodeWriteAccess()
|
||||||
|
{
|
||||||
|
if (!m_valid)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
DWORD ignored = 0;
|
||||||
|
VirtualProtect(m_pageAddress, m_pageLength, static_cast<DWORD>(m_previousProtection), &ignored);
|
||||||
|
#else
|
||||||
|
mprotect(m_pageAddress, m_pageLength, static_cast<int>(m_previousProtection));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScopedCodeWriteAccess::finishWrite()
|
||||||
|
{
|
||||||
|
if (!m_valid)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
FlushInstructionCache(GetCurrentProcess(), m_rangeAddress, m_rangeLength);
|
||||||
|
#else
|
||||||
|
char* begin = static_cast<char*>(m_rangeAddress);
|
||||||
|
__builtin___clear_cache(begin, begin + m_rangeLength);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace hot_reload::detail
|
||||||
|
{
|
||||||
|
|
||||||
|
/// RAII guard: makes [address, address + length) writable for its
|
||||||
|
/// lifetime (the OS only grants permissions in whole pages, so the
|
||||||
|
/// guard rounds out to the enclosing page range internally) and
|
||||||
|
/// restores the original protection when it goes out of scope.
|
||||||
|
///
|
||||||
|
/// Assumes the range started out READ+EXECUTE, as every platform's
|
||||||
|
/// loader maps a .text/code section -- restoring to exactly that,
|
||||||
|
/// rather than querying and restoring whatever the previous
|
||||||
|
/// protection actually was, since POSIX mprotect() has no portable
|
||||||
|
/// way to ask for the current protection of a range and code pages
|
||||||
|
/// are never anything else in practice.
|
||||||
|
class ScopedCodeWriteAccess
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ScopedCodeWriteAccess(void* address, std::size_t length);
|
||||||
|
~ScopedCodeWriteAccess();
|
||||||
|
|
||||||
|
ScopedCodeWriteAccess(const ScopedCodeWriteAccess&) = delete;
|
||||||
|
ScopedCodeWriteAccess& operator=(const ScopedCodeWriteAccess&) = delete;
|
||||||
|
|
||||||
|
/// False if the platform call to relax protection failed --
|
||||||
|
/// when false, nothing was changed and the range must not be
|
||||||
|
/// written to (the destructor is still safe to run either way).
|
||||||
|
bool isValid() const
|
||||||
|
{
|
||||||
|
return m_valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call exactly once, after writing the new bytes and before
|
||||||
|
/// this guard is destroyed: flushes the instruction cache over
|
||||||
|
/// the original [address, address + length) range so every
|
||||||
|
/// core sees the new instructions rather than a stale fetch
|
||||||
|
/// (required on ARM64, where instruction and data caches are
|
||||||
|
/// not automatically coherent; effectively a formality on
|
||||||
|
/// x86/x86-64, called anyway for correctness on that
|
||||||
|
/// architecture too).
|
||||||
|
void finishWrite();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void* m_pageAddress;
|
||||||
|
std::size_t m_pageLength;
|
||||||
|
void* m_rangeAddress;
|
||||||
|
std::size_t m_rangeLength;
|
||||||
|
unsigned long m_previousProtection;
|
||||||
|
bool m_valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
#include "ModuleHandle.hpp"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#include <windows.h>
|
||||||
|
#else
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <link.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace hot_reload::detail
|
||||||
|
{
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
/// GetLastError() alone is just a number; this renders it the
|
||||||
|
/// way a person would want to read it, the same information
|
||||||
|
/// dlerror() gives directly on POSIX. Written against
|
||||||
|
/// documented Win32 behavior; not exercised on real Windows --
|
||||||
|
/// see source/HotReload/README.md's verification notes.
|
||||||
|
std::string formatLastWindowsError()
|
||||||
|
{
|
||||||
|
DWORD errorCode = GetLastError();
|
||||||
|
LPSTR buffer = nullptr;
|
||||||
|
DWORD length = FormatMessageA(
|
||||||
|
FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr,
|
||||||
|
errorCode, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), reinterpret_cast<LPSTR>(&buffer), 0, nullptr);
|
||||||
|
|
||||||
|
std::string message;
|
||||||
|
if (length > 0 && buffer != nullptr)
|
||||||
|
{
|
||||||
|
message.assign(buffer, length);
|
||||||
|
while (!message.empty() && (message.back() == '\n' || message.back() == '\r'))
|
||||||
|
{
|
||||||
|
message.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer != nullptr)
|
||||||
|
{
|
||||||
|
LocalFree(buffer);
|
||||||
|
}
|
||||||
|
if (message.empty())
|
||||||
|
{
|
||||||
|
message = "LoadLibrary failed (error code " + std::to_string(errorCode) + ")";
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
ModuleHandle::~ModuleHandle()
|
||||||
|
{
|
||||||
|
if (m_native != nullptr)
|
||||||
|
{
|
||||||
|
#if defined(_WIN32)
|
||||||
|
FreeLibrary(static_cast<HMODULE>(m_native));
|
||||||
|
#else
|
||||||
|
dlclose(m_native);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModuleHandle::ModuleHandle(ModuleHandle&& other) noexcept
|
||||||
|
: m_native(other.m_native)
|
||||||
|
, m_loadedBase(other.m_loadedBase)
|
||||||
|
{
|
||||||
|
other.m_native = nullptr;
|
||||||
|
other.m_loadedBase = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModuleHandle& ModuleHandle::operator=(ModuleHandle&& other) noexcept
|
||||||
|
{
|
||||||
|
if (this != &other)
|
||||||
|
{
|
||||||
|
if (m_native != nullptr)
|
||||||
|
{
|
||||||
|
#if defined(_WIN32)
|
||||||
|
FreeLibrary(static_cast<HMODULE>(m_native));
|
||||||
|
#else
|
||||||
|
dlclose(m_native);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
m_native = other.m_native;
|
||||||
|
m_loadedBase = other.m_loadedBase;
|
||||||
|
other.m_native = nullptr;
|
||||||
|
other.m_loadedBase = nullptr;
|
||||||
|
}
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModuleHandle ModuleHandle::load(const std::string& path)
|
||||||
|
{
|
||||||
|
ModuleHandle handle;
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
HMODULE module = LoadLibraryA(path.c_str());
|
||||||
|
if (module != nullptr)
|
||||||
|
{
|
||||||
|
handle.m_native = module;
|
||||||
|
handle.m_loadedBase = module; // the HMODULE already IS the mapped base on Windows
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
handle.m_errorMessage = formatLastWindowsError();
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
// RTLD_LOCAL: keep each version's symbols out of the global
|
||||||
|
// symbol namespace. Hot reload deliberately resolves symbols
|
||||||
|
// itself (see SymbolTable.cpp) rather than through the dynamic
|
||||||
|
// linker's own global name lookup, and RTLD_GLOBAL would let
|
||||||
|
// an old and a new build of the same plugin -- which export
|
||||||
|
// the very same names on purpose -- collide there.
|
||||||
|
//
|
||||||
|
// RTLD_NOW (eager symbol resolution), not RTLD_LAZY: this
|
||||||
|
// means a plugin with a genuinely undefined symbol (e.g. a
|
||||||
|
// non-inline static class member declared but never defined,
|
||||||
|
// only ODR-used once some previously-dead code path becomes
|
||||||
|
// reachable -- a real case found while building this feature)
|
||||||
|
// fails right here, cleanly and reportably, INSTEAD of
|
||||||
|
// crashing the whole host process the moment that code path
|
||||||
|
// first runs under RTLD_LAZY's deferred binding. For a
|
||||||
|
// long-running host, a clean, catchable load failure is far
|
||||||
|
// preferable to a delayed, uncatchable one.
|
||||||
|
void* module = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
|
||||||
|
if (module == nullptr)
|
||||||
|
{
|
||||||
|
const char* error = dlerror();
|
||||||
|
handle.m_errorMessage = error != nullptr ? error : "dlopen failed with no further detail";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
struct link_map* map = nullptr;
|
||||||
|
if (dlinfo(module, RTLD_DI_LINKMAP, &map) == 0 && map != nullptr)
|
||||||
|
{
|
||||||
|
handle.m_native = module;
|
||||||
|
handle.m_loadedBase = reinterpret_cast<void*>(map->l_addr);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Captured before dlclose() below, which could
|
||||||
|
// otherwise overwrite dlerror()'s state first.
|
||||||
|
const char* error = dlerror();
|
||||||
|
handle.m_errorMessage =
|
||||||
|
error != nullptr ? error : "dlinfo(RTLD_DI_LINKMAP) failed with no further detail";
|
||||||
|
dlclose(module);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace hot_reload::detail
|
||||||
|
{
|
||||||
|
|
||||||
|
/// RAII wrapper around dlopen()/LoadLibrary(). Move-only, closes
|
||||||
|
/// the underlying module on destruction (or on being replaced by a
|
||||||
|
/// move-assignment).
|
||||||
|
class ModuleHandle
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ModuleHandle() = default;
|
||||||
|
~ModuleHandle();
|
||||||
|
|
||||||
|
ModuleHandle(const ModuleHandle&) = delete;
|
||||||
|
ModuleHandle& operator=(const ModuleHandle&) = delete;
|
||||||
|
ModuleHandle(ModuleHandle&& other) noexcept;
|
||||||
|
ModuleHandle& operator=(ModuleHandle&& other) noexcept;
|
||||||
|
|
||||||
|
/// Loads path. On failure the returned handle's isValid() is
|
||||||
|
/// false and loadedBase() is null.
|
||||||
|
static ModuleHandle load(const std::string& path);
|
||||||
|
|
||||||
|
bool isValid() const
|
||||||
|
{
|
||||||
|
return m_native != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address to pass as SymbolTable's loadedBase: on Windows
|
||||||
|
/// this is simply the HMODULE; on POSIX it is the runtime load
|
||||||
|
/// bias (struct link_map::l_addr) that turns an ELF symbol's
|
||||||
|
/// file-relative st_value into a real address.
|
||||||
|
void* loadedBase() const
|
||||||
|
{
|
||||||
|
return m_loadedBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only meaningful when isValid() is false: the underlying
|
||||||
|
/// platform's own description of why loading failed --
|
||||||
|
/// dlerror() on POSIX, the formatted message for
|
||||||
|
/// GetLastError() on Windows. Captured immediately inside
|
||||||
|
/// load()'s failing branch, before any other dl*/Win32 call
|
||||||
|
/// that could otherwise overwrite it first. Empty if load()
|
||||||
|
/// was never attempted, or if the platform gave no message.
|
||||||
|
const std::string& errorMessage() const
|
||||||
|
{
|
||||||
|
return m_errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void* m_native = nullptr;
|
||||||
|
void* m_loadedBase = nullptr;
|
||||||
|
std::string m_errorMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
#include "SymbolTable.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
#include <map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
/* WHY "IS THIS A FUNCTION" IS DECIDED HERE, MECHANICALLY
|
||||||
|
* ---------------------------------------------------------
|
||||||
|
* hot_reload.h promises that data -- globals, class instances, vtables,
|
||||||
|
* anything that isn't executable code -- is never patched. That
|
||||||
|
* guarantee is enforced in exactly one place: here. Every symbol this
|
||||||
|
* file reports carries a real classification pulled from the loader's
|
||||||
|
* own metadata (ELF's symbol type + section flags, or a PE export's
|
||||||
|
* containing section's characteristics), never inferred from the
|
||||||
|
* symbol's name or any convention the hot-reloadable module is
|
||||||
|
* expected to follow. HotReloadContext (the only caller) simply
|
||||||
|
* refuses to patch anything this file marks isFunction = false, on
|
||||||
|
* either side of a reload -- so nothing downstream has to be trusted
|
||||||
|
* to double-check that part.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace hot_reload::detail
|
||||||
|
{
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// PE / Windows: read straight out of the image already mapped at
|
||||||
|
// loadedBase. Unlike ELF (below), the OS loader has already fully
|
||||||
|
// relocated this image and handed us its exact base address, so
|
||||||
|
// there is no separate "load bias" to compute -- every RVA in the
|
||||||
|
// headers just gets added directly to loadedBase.
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct SectionInfo
|
||||||
|
{
|
||||||
|
std::uint32_t rvaBegin;
|
||||||
|
std::uint32_t rvaEnd;
|
||||||
|
bool executable;
|
||||||
|
};
|
||||||
|
|
||||||
|
const char* rvaToPointer(std::uint8_t* base, std::uint32_t rva)
|
||||||
|
{
|
||||||
|
return reinterpret_cast<const char*>(base + rva);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ExportedSymbol> readExportedSymbols(const std::string& /*imagePath*/, void* loadedBase)
|
||||||
|
{
|
||||||
|
std::vector<ExportedSymbol> result;
|
||||||
|
if (loadedBase == nullptr)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint8_t* base = static_cast<std::uint8_t*>(loadedBase);
|
||||||
|
auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(base);
|
||||||
|
if (dos->e_magic != IMAGE_DOS_SIGNATURE)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(base + dos->e_lfanew);
|
||||||
|
if (nt->Signature != IMAGE_NT_SIGNATURE)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section table for the executable-section check, following
|
||||||
|
// the same "code lives in a section marked executable" rule
|
||||||
|
// as the ELF path's SHF_EXECINSTR check below.
|
||||||
|
std::vector<SectionInfo> sections;
|
||||||
|
const auto* sectionHeaders = IMAGE_FIRST_SECTION(nt);
|
||||||
|
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i)
|
||||||
|
{
|
||||||
|
const IMAGE_SECTION_HEADER& section = sectionHeaders[i];
|
||||||
|
SectionInfo info;
|
||||||
|
info.rvaBegin = section.VirtualAddress;
|
||||||
|
info.rvaEnd = section.VirtualAddress + std::max(section.Misc.VirtualSize, section.SizeOfRawData);
|
||||||
|
info.executable = (section.Characteristics & IMAGE_SCN_MEM_EXECUTE) != 0;
|
||||||
|
sections.push_back(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sectionFor = [§ions](std::uint32_t rva) -> const SectionInfo*
|
||||||
|
{
|
||||||
|
for (const SectionInfo& section : sections)
|
||||||
|
{
|
||||||
|
if (rva >= section.rvaBegin && rva < section.rvaEnd)
|
||||||
|
{
|
||||||
|
return §ion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (nt->OptionalHeader.NumberOfRvaAndSizes <= IMAGE_DIRECTORY_ENTRY_EXPORT)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_DATA_DIRECTORY& exportDirEntry = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
|
||||||
|
if (exportDirEntry.VirtualAddress == 0 || exportDirEntry.Size == 0)
|
||||||
|
{
|
||||||
|
return result; // no exports at all
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto* exportDir = reinterpret_cast<const IMAGE_EXPORT_DIRECTORY*>(base + exportDirEntry.VirtualAddress);
|
||||||
|
const auto* nameRvas = reinterpret_cast<const DWORD*>(base + exportDir->AddressOfNames);
|
||||||
|
const auto* nameOrdinals = reinterpret_cast<const WORD*>(base + exportDir->AddressOfNameOrdinals);
|
||||||
|
const auto* functionRvas = reinterpret_cast<const DWORD*>(base + exportDir->AddressOfFunctions);
|
||||||
|
|
||||||
|
const std::uint32_t exportDirBegin = exportDirEntry.VirtualAddress;
|
||||||
|
const std::uint32_t exportDirEnd = exportDirEntry.VirtualAddress + exportDirEntry.Size;
|
||||||
|
|
||||||
|
struct Candidate
|
||||||
|
{
|
||||||
|
std::string name;
|
||||||
|
std::uint32_t rva;
|
||||||
|
bool isFunction;
|
||||||
|
};
|
||||||
|
std::vector<Candidate> candidates;
|
||||||
|
|
||||||
|
for (DWORD i = 0; i < exportDir->NumberOfNames; ++i)
|
||||||
|
{
|
||||||
|
const char* name = rvaToPointer(base, nameRvas[i]);
|
||||||
|
WORD ordinal = nameOrdinals[i];
|
||||||
|
if (ordinal >= exportDir->NumberOfFunctions)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::uint32_t functionRva = functionRvas[ordinal];
|
||||||
|
if (functionRva == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forwarder export's "RVA" actually points at a string
|
||||||
|
// like "OtherDll.OtherFunction" living inside the export
|
||||||
|
// directory itself, not at real code -- never classified
|
||||||
|
// as a patchable function.
|
||||||
|
bool isForwarder = functionRva >= exportDirBegin && functionRva < exportDirEnd;
|
||||||
|
|
||||||
|
const SectionInfo* section = sectionFor(functionRva);
|
||||||
|
bool isFunction = !isForwarder && section != nullptr && section->executable;
|
||||||
|
|
||||||
|
candidates.push_back(Candidate{name, functionRva, isFunction});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort size estimate: distance to the next function
|
||||||
|
// export in the same section. This can OVER-estimate (there
|
||||||
|
// may be non-exported code, or alignment padding, between two
|
||||||
|
// exported functions, so a large gap does not guarantee a
|
||||||
|
// large real function) -- it only ever gives a hard "not
|
||||||
|
// enough room" answer when the gap itself is already small.
|
||||||
|
// PE has no per-export size field the way ELF does (see
|
||||||
|
// below), so /hotpatch (applied automatically by
|
||||||
|
// cmake/HotReload.cmake) is the primary safety mechanism on
|
||||||
|
// Windows; this is a secondary backstop, not a substitute.
|
||||||
|
std::vector<std::uint32_t> functionRvasSorted;
|
||||||
|
for (const Candidate& candidate : candidates)
|
||||||
|
{
|
||||||
|
if (candidate.isFunction)
|
||||||
|
{
|
||||||
|
functionRvasSorted.push_back(candidate.rva);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::sort(functionRvasSorted.begin(), functionRvasSorted.end());
|
||||||
|
|
||||||
|
for (const Candidate& candidate : candidates)
|
||||||
|
{
|
||||||
|
ExportedSymbol symbol;
|
||||||
|
symbol.name = candidate.name;
|
||||||
|
symbol.address = base + candidate.rva;
|
||||||
|
symbol.isFunction = candidate.isFunction;
|
||||||
|
symbol.size = 0;
|
||||||
|
|
||||||
|
if (candidate.isFunction)
|
||||||
|
{
|
||||||
|
const SectionInfo* section = sectionFor(candidate.rva);
|
||||||
|
std::uint32_t upperBound = section != nullptr ? section->rvaEnd : candidate.rva;
|
||||||
|
|
||||||
|
auto it = std::upper_bound(functionRvasSorted.begin(), functionRvasSorted.end(), candidate.rva);
|
||||||
|
if (it != functionRvasSorted.end())
|
||||||
|
{
|
||||||
|
upperBound = std::min(upperBound, *it);
|
||||||
|
}
|
||||||
|
symbol.size = upperBound > candidate.rva ? (upperBound - candidate.rva) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push_back(std::move(symbol));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// ELF / POSIX: read imagePath on disk rather than the mapped
|
||||||
|
// image. Reading from disk means walking the ordinary section
|
||||||
|
// header table (.dynsym / .dynstr, plus every section's sh_flags
|
||||||
|
// for the executable check) instead of re-deriving the same
|
||||||
|
// information from the .dynamic section's DT_* tags the way the
|
||||||
|
// dynamic linker itself does internally -- simpler to get right,
|
||||||
|
// at the cost of one known gap: a shared object with its section
|
||||||
|
// header table deliberately stripped (rare -- an explicit, extra
|
||||||
|
// step beyond an ordinary `strip`, essentially never done to a
|
||||||
|
// library you are actively iterating on) will read back as having
|
||||||
|
// no exported functions at all, handled the same as any other
|
||||||
|
// unparseable image: an empty result, never a crash.
|
||||||
|
//
|
||||||
|
// st_value in the file is relative to the module's own link-time
|
||||||
|
// base, so it is loadedBase (the actual runtime load address --
|
||||||
|
// the "load bias" ModuleHandle reads via dlinfo) that turns it
|
||||||
|
// into a real, callable address.
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#include <elf.h>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
std::vector<char> readWholeFile(const std::string& path)
|
||||||
|
{
|
||||||
|
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
||||||
|
if (!file)
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
std::streamsize length = file.tellg();
|
||||||
|
if (length <= 0)
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
file.seekg(0, std::ios::beg);
|
||||||
|
std::vector<char> buffer(static_cast<std::size_t>(length));
|
||||||
|
if (!file.read(buffer.data(), length))
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses one ELF class (32 or 64-bit); Ehdr/Shdr/Sym is
|
||||||
|
/// Elf32_* or Elf64_* depending on the caller, matched against
|
||||||
|
/// e_ident[EI_CLASS] before this is ever instantiated.
|
||||||
|
template <typename Ehdr, typename Shdr, typename Sym, typename SymType, typename SymBind>
|
||||||
|
std::vector<ExportedSymbol> parseElf(
|
||||||
|
const std::vector<char>& file, void* loadedBase, SymType symType, SymBind symBind)
|
||||||
|
{
|
||||||
|
std::vector<ExportedSymbol> result;
|
||||||
|
if (file.size() < sizeof(Ehdr))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto* ehdr = reinterpret_cast<const Ehdr*>(file.data());
|
||||||
|
if (ehdr->e_shoff == 0 || ehdr->e_shnum == 0)
|
||||||
|
{
|
||||||
|
return result; // no section header table -- see file comment above
|
||||||
|
}
|
||||||
|
if (static_cast<std::uint64_t>(ehdr->e_shoff) + static_cast<std::uint64_t>(ehdr->e_shnum) * sizeof(Shdr) >
|
||||||
|
file.size())
|
||||||
|
{
|
||||||
|
return result; // truncated/corrupt file
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto* sections = reinterpret_cast<const Shdr*>(file.data() + ehdr->e_shoff);
|
||||||
|
|
||||||
|
// Section header string table, to find sections by name.
|
||||||
|
if (ehdr->e_shstrndx >= ehdr->e_shnum)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const char* shstrtab = file.data() + sections[ehdr->e_shstrndx].sh_offset;
|
||||||
|
|
||||||
|
const Shdr* dynsymSection = nullptr;
|
||||||
|
const Shdr* dynstrSection = nullptr;
|
||||||
|
for (std::size_t i = 0; i < ehdr->e_shnum; ++i)
|
||||||
|
{
|
||||||
|
const char* name = shstrtab + sections[i].sh_name;
|
||||||
|
if (std::strcmp(name, ".dynsym") == 0)
|
||||||
|
{
|
||||||
|
dynsymSection = §ions[i];
|
||||||
|
}
|
||||||
|
else if (std::strcmp(name, ".dynstr") == 0)
|
||||||
|
{
|
||||||
|
dynstrSection = §ions[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dynsymSection == nullptr || dynstrSection == nullptr)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* dynstr = file.data() + dynstrSection->sh_offset;
|
||||||
|
std::size_t symbolCount = dynsymSection->sh_size / sizeof(Sym);
|
||||||
|
const auto* symbols = reinterpret_cast<const Sym*>(file.data() + dynsymSection->sh_offset);
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < symbolCount; ++i)
|
||||||
|
{
|
||||||
|
const Sym& sym = symbols[i];
|
||||||
|
if (sym.st_shndx == SHN_UNDEF || sym.st_name == 0)
|
||||||
|
{
|
||||||
|
continue; // imported/unresolved, not defined here
|
||||||
|
}
|
||||||
|
unsigned char bind = symBind(sym.st_info);
|
||||||
|
if (bind != STB_GLOBAL && bind != STB_WEAK)
|
||||||
|
{
|
||||||
|
continue; // not part of this module's visible surface
|
||||||
|
}
|
||||||
|
|
||||||
|
ExportedSymbol exported;
|
||||||
|
exported.name = dynstr + sym.st_name;
|
||||||
|
exported.address = static_cast<char*>(loadedBase) + sym.st_value;
|
||||||
|
exported.size = static_cast<std::size_t>(sym.st_size);
|
||||||
|
|
||||||
|
bool isFuncType = symType(sym.st_info) == STT_FUNC;
|
||||||
|
bool sectionExecutable = false;
|
||||||
|
if (sym.st_shndx != SHN_ABS && sym.st_shndx != SHN_COMMON && sym.st_shndx < ehdr->e_shnum)
|
||||||
|
{
|
||||||
|
sectionExecutable = (sections[sym.st_shndx].sh_flags & SHF_EXECINSTR) != 0;
|
||||||
|
}
|
||||||
|
exported.isFunction = isFuncType && sectionExecutable;
|
||||||
|
|
||||||
|
result.push_back(std::move(exported));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ExportedSymbol> readExportedSymbols(const std::string& imagePath, void* loadedBase)
|
||||||
|
{
|
||||||
|
std::vector<char> file = readWholeFile(imagePath);
|
||||||
|
if (file.size() < EI_NIDENT)
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned char elfClass = static_cast<unsigned char>(file[EI_CLASS]);
|
||||||
|
if (elfClass == ELFCLASS64)
|
||||||
|
{
|
||||||
|
return parseElf<Elf64_Ehdr, Elf64_Shdr, Elf64_Sym>(
|
||||||
|
file, loadedBase, [](unsigned char info) { return ELF64_ST_TYPE(info); },
|
||||||
|
[](unsigned char info) { return ELF64_ST_BIND(info); });
|
||||||
|
}
|
||||||
|
if (elfClass == ELFCLASS32)
|
||||||
|
{
|
||||||
|
return parseElf<Elf32_Ehdr, Elf32_Shdr, Elf32_Sym>(
|
||||||
|
file, loadedBase, [](unsigned char info) { return ELF32_ST_TYPE(info); },
|
||||||
|
[](unsigned char info) { return ELF32_ST_BIND(info); });
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace hot_reload::detail
|
||||||
|
{
|
||||||
|
|
||||||
|
/// One exported symbol found in a loaded module, already classified.
|
||||||
|
struct ExportedSymbol
|
||||||
|
{
|
||||||
|
std::string name;
|
||||||
|
void* address = nullptr;
|
||||||
|
|
||||||
|
/// The single load-bearing safety gate behind the "only
|
||||||
|
/// executable code changes" guarantee in hot_reload.h: false
|
||||||
|
/// for anything not mechanically identified as code (a data
|
||||||
|
/// object, a forwarder export, or a symbol kind this reader
|
||||||
|
/// does not recognize at all). HotReloadContext never patches
|
||||||
|
/// a symbol where this is false, on either side of a reload.
|
||||||
|
bool isFunction = false;
|
||||||
|
|
||||||
|
/// Compiled size in bytes if known, 0 if not. ELF gives this
|
||||||
|
/// exactly (the symbol table's st_size field); PE gives no
|
||||||
|
/// such field, so this holds a conservative estimate derived
|
||||||
|
/// from the distance to the next exported function in the
|
||||||
|
/// same section -- see the file comment in SymbolTable.cpp
|
||||||
|
/// for exactly what that heuristic can and cannot catch.
|
||||||
|
std::size_t size = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Reads every exported, defined symbol belonging to the module
|
||||||
|
/// mapped at loadedBase (the base address dlopen()/LoadLibrary()
|
||||||
|
/// returned for it), classifying each one as code or data.
|
||||||
|
///
|
||||||
|
/// On POSIX this parses imagePath on disk (its ELF .dynsym /
|
||||||
|
/// .dynstr / section-header table) and adds loadedBase as the
|
||||||
|
/// runtime load bias. On Windows this ignores imagePath and reads
|
||||||
|
/// directly from the already-mapped image at loadedBase (its PE
|
||||||
|
/// export directory and section table) -- see the per-platform
|
||||||
|
/// implementation blocks in SymbolTable.cpp for why each platform
|
||||||
|
/// reads from a different place.
|
||||||
|
///
|
||||||
|
/// Returns an empty vector, never throws, if the image could not
|
||||||
|
/// be parsed as this platform's native format (including the rare
|
||||||
|
/// case of a .so with its section header table deliberately
|
||||||
|
/// stripped -- see the file comment in SymbolTable.cpp).
|
||||||
|
std::vector<ExportedSymbol> readExportedSymbols(const std::string& imagePath, void* loadedBase);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
# source/HotReload/src/c-api/.clang-tidy
|
||||||
|
#
|
||||||
|
# hot_reload.h's implementation (HotReloadApi.cpp) is Part II (C API)
|
||||||
|
# style -- snake_case, per style guide §13 -- even though it is a
|
||||||
|
# .cpp file living inside a C++ library. Copied from the repo-root
|
||||||
|
# c-api/.clang-tidy template (see that file's own header comment for
|
||||||
|
# the full explanation of how clang-tidy's directory-scoped config
|
||||||
|
# resolution makes this work); the sibling directory one level up
|
||||||
|
# (source/HotReload/src/) has no override of its own, so its Part I
|
||||||
|
# (C++) files keep inheriting the repo-root .clang-tidy's PascalCase/
|
||||||
|
# camelCase rules exactly as normal.
|
||||||
|
InheritParentConfig: true
|
||||||
|
|
||||||
|
Checks: >
|
||||||
|
readability-identifier-naming,
|
||||||
|
bugprone-exception-escape
|
||||||
|
|
||||||
|
CheckOptions:
|
||||||
|
- 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
|
||||||
|
- key: readability-identifier-naming.EnumConstantCase
|
||||||
|
value: UPPER_CASE
|
||||||
|
|
||||||
|
- key: readability-identifier-naming.GlobalConstantCase
|
||||||
|
value: UPPER_CASE
|
||||||
|
- key: readability-identifier-naming.MacroDefinitionCase
|
||||||
|
value: UPPER_CASE
|
||||||
|
|
||||||
|
- 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_
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/* Demangle.cpp -- hot_reload_demangle() / hot_reload_free_string().
|
||||||
|
*
|
||||||
|
* Pure string utility, no HotReloadContext involved, so it gets its
|
||||||
|
* own file alongside HotReloadApi.cpp rather than crowding that one's
|
||||||
|
* single responsibility (the opaque-pointer shim). Part II (C API)
|
||||||
|
* style, like everything else in this directory -- see .clang-tidy
|
||||||
|
* here and cpp-style-guide.md section 13.
|
||||||
|
*
|
||||||
|
* Every C++ symbol name in this library's events/lookups is always
|
||||||
|
* the REAL symbol-table name (whatever hot_reload_get_symbol() needs
|
||||||
|
* to find it again) -- this function is an opt-in convenience for
|
||||||
|
* DISPLAYING that name, never applied automatically, so there is
|
||||||
|
* never a mismatch between what an event reports and what you'd pass
|
||||||
|
* back in to look the symbol up.
|
||||||
|
*/
|
||||||
|
#include "hot_reload/hot_reload.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#if defined(__GNUC__) || defined(__clang__)
|
||||||
|
// Covers GCC and Clang on every OS this library targets, MinGW
|
||||||
|
// included: what selects the demangling scheme is the C++ ABI the
|
||||||
|
// COMPILER implements (Itanium here), not the operating system --
|
||||||
|
// MinGW is still GCC/Clang generating Itanium-mangled names even
|
||||||
|
// though the OS is Windows. MSVC, on the same OS, uses its own,
|
||||||
|
// unrelated scheme (the #elif below). See README.md's "Hot
|
||||||
|
// reloading C++ symbols" section for why this distinction matters
|
||||||
|
// beyond just this function.
|
||||||
|
#include <cxxabi.h>
|
||||||
|
#elif defined(_MSC_VER)
|
||||||
|
#include <dbghelp.h>
|
||||||
|
#include <windows.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
{
|
||||||
|
|
||||||
|
char* hot_reload_demangle(const char* name)
|
||||||
|
{
|
||||||
|
if (name == nullptr)
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string text = name; // fallback: unchanged, for anything that isn't a mangled C++ name at all
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
#if defined(__GNUC__) || defined(__clang__)
|
||||||
|
int status = 0;
|
||||||
|
char* demangled = abi::__cxa_demangle(name, nullptr, nullptr, &status);
|
||||||
|
if (status == 0 && demangled != nullptr)
|
||||||
|
{
|
||||||
|
text = demangled;
|
||||||
|
}
|
||||||
|
std::free(demangled); // __cxa_demangle always malloc()s or leaves this null either way
|
||||||
|
#elif defined(_MSC_VER)
|
||||||
|
// UnDecorateSymbolName operates on the string alone (no
|
||||||
|
// SymInitialize/session needed, unlike most of
|
||||||
|
// DbgHelp) -- but DbgHelp as a whole is documented as not
|
||||||
|
// thread-safe, so serialize calls to this function
|
||||||
|
// yourself if you call it from more than one thread.
|
||||||
|
// Written against Microsoft's documented behavior; unlike
|
||||||
|
// most of this library, this specific function was not
|
||||||
|
// exercised on real Windows -- see README.md's toolchain
|
||||||
|
// verification notes.
|
||||||
|
char buffer[2048];
|
||||||
|
if (UnDecorateSymbolName(name, buffer, sizeof(buffer), UNDNAME_COMPLETE) != 0)
|
||||||
|
{
|
||||||
|
text = buffer;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
char* result = new char[text.size() + 1];
|
||||||
|
std::memcpy(result, text.c_str(), text.size() + 1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void hot_reload_free_string(char* str)
|
||||||
|
{
|
||||||
|
delete[] str;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#include "hot_reload/hot_reload.h"
|
||||||
|
|
||||||
|
#include "../HotReloadContext.hpp"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
/// The opaque ::HotReloadContext the public header declares is
|
||||||
|
/// never actually instantiated as itself -- every instance really
|
||||||
|
/// is a hot_reload::HotReloadContext, following the same
|
||||||
|
/// opaque-pointer PIMPL pattern cpp-style-guide.md's NetManager
|
||||||
|
/// example uses. This is the one place that cast happens.
|
||||||
|
hot_reload::HotReloadContext* impl(::HotReloadContext* ctx)
|
||||||
|
{
|
||||||
|
return reinterpret_cast<hot_reload::HotReloadContext*>(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per cpp-style-guide.md section 11: a C++ exception must never cross
|
||||||
|
// an extern "C" boundary -- doing so is undefined behavior for any
|
||||||
|
// caller that is not itself compiled to expect one, which a plain C
|
||||||
|
// caller of this API never is. Every function here is a thin,
|
||||||
|
// non-throwing wrapper for exactly that reason: std::string/vector/
|
||||||
|
// unordered_map construction is the only thing in hot_reload::
|
||||||
|
// HotReloadContext that could throw at all (std::bad_alloc under
|
||||||
|
// memory pressure), so catching (...) and mapping it to this API's
|
||||||
|
// existing failure values is sufficient without needing per-exception-
|
||||||
|
// type handling.
|
||||||
|
extern "C"
|
||||||
|
{
|
||||||
|
|
||||||
|
HotReloadContext* hot_reload_context_create(const HotReloadOptions* options)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return reinterpret_cast<::HotReloadContext*>(new hot_reload::HotReloadContext(options));
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void hot_reload_context_destroy(HotReloadContext* ctx)
|
||||||
|
{
|
||||||
|
delete impl(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
HotReloadStatus hot_reload_load(HotReloadContext* ctx, const char* module_name, const char* path)
|
||||||
|
{
|
||||||
|
if (ctx == nullptr || module_name == nullptr || path == nullptr)
|
||||||
|
{
|
||||||
|
return HOT_RELOAD_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return impl(ctx)->load(module_name, path);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HOT_RELOAD_ERROR_LOAD_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HotReloadStatus hot_reload_reload(HotReloadContext* ctx, const char* module_name, const char* new_path)
|
||||||
|
{
|
||||||
|
if (ctx == nullptr || module_name == nullptr || new_path == nullptr)
|
||||||
|
{
|
||||||
|
return HOT_RELOAD_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return impl(ctx)->reload(module_name, new_path);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HOT_RELOAD_ERROR_LOAD_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void* hot_reload_get_symbol(HotReloadContext* ctx, const char* module_name, const char* symbol_name)
|
||||||
|
{
|
||||||
|
if (ctx == nullptr || module_name == nullptr || symbol_name == nullptr)
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return impl(ctx)->getSymbol(module_name, symbol_name);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* hot_reload_last_error(HotReloadContext* ctx)
|
||||||
|
{
|
||||||
|
if (ctx == nullptr)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return impl(ctx)->lastError().c_str();
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* hot_reload_status_string(HotReloadStatus status)
|
||||||
|
{
|
||||||
|
switch (status)
|
||||||
|
{
|
||||||
|
case HOT_RELOAD_OK:
|
||||||
|
return "ok";
|
||||||
|
case HOT_RELOAD_ERROR_INVALID_ARGUMENT:
|
||||||
|
return "invalid argument";
|
||||||
|
case HOT_RELOAD_ERROR_LOAD_FAILED:
|
||||||
|
return "load failed";
|
||||||
|
case HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION:
|
||||||
|
return "no previous version loaded for this module name";
|
||||||
|
case HOT_RELOAD_ERROR_UNSUPPORTED_ARCHITECTURE:
|
||||||
|
return "no patch backend for this CPU architecture";
|
||||||
|
}
|
||||||
|
return "unknown hot reload status";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# _discover_module_subdirectories() (cmake/BuildHelpers.cmake) only
|
||||||
|
# add_subdirectory()'s the IMMEDIATE children of source/examples/ --
|
||||||
|
# this file exists purely so that mechanism finds this folder at all,
|
||||||
|
# then hands off to its three real targets: one host executable and
|
||||||
|
# two builds of the same plugin (see PluginV1/CMakeLists.txt for why
|
||||||
|
# two separate builds rather than one). Shared/ has no CMakeLists.txt
|
||||||
|
# of its own -- it is just PluginApi.h, pulled in by PluginV1/PluginV2
|
||||||
|
# via target_include_directories(), not add_subdirectory().
|
||||||
|
add_subdirectory(PluginV1)
|
||||||
|
add_subdirectory(PluginV2)
|
||||||
|
add_subdirectory(Host)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
add_example_module(hotreload_demo_host OUTPUT_NAME "hotreload_demo_host" DEPENDS hotreload_lib)
|
||||||
|
|
||||||
|
target_include_directories(hotreload_demo_host PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Shared)
|
||||||
|
|
||||||
|
# The Host loads PluginV1/PluginV2 itself, at runtime, through
|
||||||
|
# source/HotReload -- it must NOT link against them directly (that
|
||||||
|
# would defeat the entire point of loading them dynamically via
|
||||||
|
# dlopen()/LoadLibrary()), so this is a build-order-only dependency
|
||||||
|
# plus each one's exact build output path baked in as a default, never
|
||||||
|
# a target_link_libraries().
|
||||||
|
add_dependencies(hotreload_demo_host hotreload_demo_plugin_v1 hotreload_demo_plugin_v2)
|
||||||
|
target_compile_definitions(hotreload_demo_host PRIVATE
|
||||||
|
"PLUGIN_V1_PATH=\"$<TARGET_FILE:hotreload_demo_plugin_v1>\""
|
||||||
|
"PLUGIN_V2_PATH=\"$<TARGET_FILE:hotreload_demo_plugin_v2>\""
|
||||||
|
)
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/* Main.cpp -- loads PluginV1, calls into it, reloads to PluginV2, and
|
||||||
|
* calls the SAME already-resolved function pointers again to show
|
||||||
|
* they now run the new code -- without the Host re-resolving anything.
|
||||||
|
* Along the way this exercises every HotReloadSymbolEvent kind: watch
|
||||||
|
* the printed event log against PluginV1/src/Plugin.cpp and
|
||||||
|
* PluginV2/src/Plugin.cpp side by side. The second half does the same
|
||||||
|
* thing for CppWidgetApi.h's real C++ (non-extern-"C") surface -- see
|
||||||
|
* that header's comments for why its two methods are each reached a
|
||||||
|
* different way below.
|
||||||
|
*/
|
||||||
|
#include "CppWidgetApi.h"
|
||||||
|
#include "hot_reload/hot_reload.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
using PluginFunction = int (*)(int);
|
||||||
|
|
||||||
|
// CppWidget::tick's real ABI: an implicit this-pointer followed by
|
||||||
|
// its one declared parameter, same calling convention the
|
||||||
|
// compiler itself would generate for widget->tick(x) -- this is
|
||||||
|
// just spelling that out explicitly so it can be called through a
|
||||||
|
// manually-resolved address instead.
|
||||||
|
using CppWidgetTickFunction = int (*)(CppWidget*, int);
|
||||||
|
|
||||||
|
int hostSideDeletedFunctionStub(int)
|
||||||
|
{
|
||||||
|
std::printf(" !! called a deleted plugin function -- this is the Host's stub, not a crash !!\n");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Runs once per HOT_RELOAD_SYMBOL_REMOVED, before onSymbolEvent
|
||||||
|
* below sees the same event -- see hot_reload.h's doc comment on
|
||||||
|
* HotReloadDeletedSymbolFn. Redirecting a removed function to a
|
||||||
|
* stub living in the HOST executable (not in either plugin build)
|
||||||
|
* demonstrates that the target of a patch can be anywhere at all,
|
||||||
|
* not just inside the module being reloaded. */
|
||||||
|
void* onDeletedSymbol(const char* symbolName, void* /*userData*/)
|
||||||
|
{
|
||||||
|
std::printf(" [event] REMOVED %-22s -> redirecting old callers to the Host's stub\n", symbolName);
|
||||||
|
return reinterpret_cast<void*>(&hostSideDeletedFunctionStub);
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* eventKindName(HotReloadSymbolEvent kind)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case HOT_RELOAD_SYMBOL_PATCHED:
|
||||||
|
return "PATCHED";
|
||||||
|
case HOT_RELOAD_SYMBOL_ADDED:
|
||||||
|
return "ADDED";
|
||||||
|
case HOT_RELOAD_SYMBOL_REMOVED:
|
||||||
|
return "REMOVED";
|
||||||
|
case HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE:
|
||||||
|
return "SKIPPED (data, not code)";
|
||||||
|
case HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL:
|
||||||
|
return "SKIPPED (too small to patch)";
|
||||||
|
case HOT_RELOAD_SYMBOL_SKIPPED_UNWRITABLE:
|
||||||
|
return "SKIPPED (page not writable)";
|
||||||
|
}
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSymbolEvent(const HotReloadSymbolEventInfo* event, void* /*userData*/)
|
||||||
|
{
|
||||||
|
// REMOVED is already reported, with more context, by
|
||||||
|
// onDeletedSymbol() above -- it fires exactly once per removed
|
||||||
|
// symbol, same as this callback would.
|
||||||
|
if (event->kind == HOT_RELOAD_SYMBOL_REMOVED)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// event->name is always the real symbol-table name (the exact
|
||||||
|
// string hot_reload_get_symbol() would need) -- demangling it
|
||||||
|
// is purely a display choice, made here and only here; a
|
||||||
|
// plain extern "C" name like "plugin_tick" comes back
|
||||||
|
// unchanged, so this is safe to apply unconditionally.
|
||||||
|
char* readableName = hot_reload_demangle(event->name);
|
||||||
|
std::printf(" [event] %-30s %s\n", eventKindName(event->kind), readableName != nullptr ? readableName : event->name);
|
||||||
|
hot_reload_free_string(readableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char** argv)
|
||||||
|
{
|
||||||
|
std::string pluginV1Path = argc > 1 ? argv[1] : PLUGIN_V1_PATH;
|
||||||
|
std::string pluginV2Path = argc > 2 ? argv[2] : PLUGIN_V2_PATH;
|
||||||
|
|
||||||
|
HotReloadOptions options{};
|
||||||
|
options.on_symbol_event = &onSymbolEvent;
|
||||||
|
options.on_deleted_symbol = &onDeletedSymbol;
|
||||||
|
options.user_data = nullptr;
|
||||||
|
|
||||||
|
HotReloadContext* ctx = hot_reload_context_create(&options);
|
||||||
|
if (ctx == nullptr)
|
||||||
|
{
|
||||||
|
std::fprintf(stderr, "hot_reload_context_create failed\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("== loading plugin V1 (%s) ==\n", pluginV1Path.c_str());
|
||||||
|
HotReloadStatus status = hot_reload_load(ctx, "plugin", pluginV1Path.c_str());
|
||||||
|
if (status != HOT_RELOAD_OK)
|
||||||
|
{
|
||||||
|
std::fprintf(stderr, "hot_reload_load failed: %s (%s)\n", hot_reload_status_string(status),
|
||||||
|
hot_reload_last_error(ctx));
|
||||||
|
hot_reload_context_destroy(ctx);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved ONCE, before the reload below -- and never re-resolved.
|
||||||
|
// The point of this demo is that these exact pointers keep working
|
||||||
|
// correctly after the plugin underneath them changes.
|
||||||
|
auto tick = reinterpret_cast<PluginFunction>(hot_reload_get_symbol(ctx, "plugin", "plugin_tick"));
|
||||||
|
auto legacy = reinterpret_cast<PluginFunction>(hot_reload_get_symbol(ctx, "plugin", "plugin_legacy_feature"));
|
||||||
|
auto markerBefore = static_cast<int*>(hot_reload_get_symbol(ctx, "plugin", "plugin_build_marker"));
|
||||||
|
|
||||||
|
std::printf("\n-- calling V1 through freshly-resolved pointers --\n");
|
||||||
|
tick(21);
|
||||||
|
legacy(7);
|
||||||
|
std::printf(" plugin_build_marker = %d\n", markerBefore != nullptr ? *markerBefore : -1);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// The same plugin build ALSO exports a real C++ class
|
||||||
|
// (CppWidgetApi.h) with no extern "C" anywhere -- a virtual
|
||||||
|
// method and an ordinary out-of-line method, both with real
|
||||||
|
// compiler-mangled names. Same "plugin" module name as above,
|
||||||
|
// since it is physically the same shared library on disk.
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
std::printf("\n== the same plugin build's C++ surface (CppWidgetApi.h, no extern \"C\") ==\n");
|
||||||
|
|
||||||
|
auto makeCppWidget = reinterpret_cast<CppWidget* (*) ()>(hot_reload_get_symbol(ctx, "plugin", "make_cpp_widget"));
|
||||||
|
CppWidget* widget = makeCppWidget != nullptr ? makeCppWidget() : nullptr;
|
||||||
|
|
||||||
|
// CppWidget::tick(int)'s real, compiler-mangled name -- see
|
||||||
|
// CppWidgetApi.h's comment on why this one is resolved manually
|
||||||
|
// rather than called as widget->tick(x). Re-derive this yourself
|
||||||
|
// with `nm -D --defined-only <plugin path> | c++filt` if you
|
||||||
|
// change CppWidget's signature; there is no portable way to ask
|
||||||
|
// the compiler for another declaration's mangled name at compile
|
||||||
|
// time, which is exactly the inconvenience extern "C" exists to
|
||||||
|
// avoid for anything you need to look up this way.
|
||||||
|
auto tickByMangledName =
|
||||||
|
reinterpret_cast<CppWidgetTickFunction>(hot_reload_get_symbol(ctx, "plugin", "_ZN9CppWidget4tickEi"));
|
||||||
|
|
||||||
|
std::printf("-- V1: virtual method through REAL polymorphic dispatch (widget->virtualTick) --\n");
|
||||||
|
if (widget != nullptr)
|
||||||
|
{
|
||||||
|
widget->virtualTick(21);
|
||||||
|
}
|
||||||
|
std::printf("-- V1: ordinary method through a manually-resolved mangled-name pointer --\n");
|
||||||
|
if (tickByMangledName != nullptr && widget != nullptr)
|
||||||
|
{
|
||||||
|
tickByMangledName(widget, 21);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("\n== reloading \"plugin\" to V2 (%s) ==\n", pluginV2Path.c_str());
|
||||||
|
status = hot_reload_reload(ctx, "plugin", pluginV2Path.c_str());
|
||||||
|
if (status != HOT_RELOAD_OK)
|
||||||
|
{
|
||||||
|
std::fprintf(stderr, "hot_reload_reload failed: %s (%s)\n", hot_reload_status_string(status),
|
||||||
|
hot_reload_last_error(ctx));
|
||||||
|
delete widget;
|
||||||
|
hot_reload_context_destroy(ctx);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("\n-- calling the exact same pointers from before, un-re-resolved --\n");
|
||||||
|
tick(21); // same function pointer as above -- now runs V2's tripling logic
|
||||||
|
legacy(7); // same function pointer as above -- now runs the Host's stub instead of V1's body
|
||||||
|
|
||||||
|
std::printf("\n-- plugin_new_feature only exists in V2, resolved fresh --\n");
|
||||||
|
auto newFeature = reinterpret_cast<PluginFunction>(hot_reload_get_symbol(ctx, "plugin", "plugin_new_feature"));
|
||||||
|
if (newFeature != nullptr)
|
||||||
|
{
|
||||||
|
newFeature(21);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto markerAfter = static_cast<int*>(hot_reload_get_symbol(ctx, "plugin", "plugin_build_marker"));
|
||||||
|
std::printf("\n-- plugin_build_marker: proof that data is never patched --\n");
|
||||||
|
std::printf(" resolved fresh from V2 right now = %d\n", markerAfter != nullptr ? *markerAfter : -1);
|
||||||
|
std::printf(" V1's ORIGINAL address, read again = %d (still V1's value -- never touched)\n",
|
||||||
|
markerBefore != nullptr ? *markerBefore : -1);
|
||||||
|
|
||||||
|
std::printf("\n-- the SAME widget object, same vtable slot, same manually-resolved pointer --\n");
|
||||||
|
if (widget != nullptr)
|
||||||
|
{
|
||||||
|
widget->virtualTick(21); // same object, same vtable slot -- now V2's tripling logic
|
||||||
|
}
|
||||||
|
if (tickByMangledName != nullptr && widget != nullptr)
|
||||||
|
{
|
||||||
|
tickByMangledName(widget, 21); // same resolved pointer as before -- also now V2's logic
|
||||||
|
}
|
||||||
|
|
||||||
|
delete widget;
|
||||||
|
hot_reload_context_destroy(ctx);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Not built through add_lib_module(): a hot-reloadable plugin is a
|
||||||
|
# different shape from an ordinary library module. PluginV1 and its
|
||||||
|
# sibling PluginV2 are two SEPARATE binaries that deliberately export
|
||||||
|
# the SAME extern "C" symbol names (see ../Shared/PluginApi.h) --
|
||||||
|
# that's exactly what lets HotReloadContext line them up and patch one
|
||||||
|
# into the other at runtime. add_lib_module() assumes one canonical
|
||||||
|
# build per module name and would not fit two builds of the same name
|
||||||
|
# needing to coexist, so this is plain add_library(), reusing the
|
||||||
|
# project's usual output-directory/warnings helpers directly. Gated on
|
||||||
|
# BUILD_EXAMPLES exactly like every other target under source/examples/.
|
||||||
|
|
||||||
|
if(BUILD_EXAMPLES)
|
||||||
|
add_library(hotreload_demo_plugin_v1 SHARED src/Plugin.cpp src/CppWidget.cpp)
|
||||||
|
else()
|
||||||
|
add_library(hotreload_demo_plugin_v1 SHARED EXCLUDE_FROM_ALL src/Plugin.cpp src/CppWidget.cpp)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
target_include_directories(hotreload_demo_plugin_v1 PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Shared)
|
||||||
|
set_target_properties(hotreload_demo_plugin_v1 PROPERTIES
|
||||||
|
OUTPUT_NAME "hotreload_demo_plugin_v1"
|
||||||
|
# Matches add_lib_module()'s own SHARED-library branch
|
||||||
|
# (cmake/BuildHelpers.cmake) exactly, for exactly the same reason:
|
||||||
|
# MinGW's linker exports every global symbol from a DLL by
|
||||||
|
# default, but MSVC exports NONE unless told to -- without this,
|
||||||
|
# PluginApi.h's functions would not be visible in the export table
|
||||||
|
# at all when built with MSVC, silently breaking the whole demo on
|
||||||
|
# that one toolchain.
|
||||||
|
WINDOWS_EXPORT_ALL_SYMBOLS ON)
|
||||||
|
_set_output_dirs(hotreload_demo_plugin_v1
|
||||||
|
"${CMAKE_BINARY_DIR}/${RUNTIME_OUTPUT_SUBDIR}/examples"
|
||||||
|
"${CMAKE_BINARY_DIR}/${LIBRARY_OUTPUT_SUBDIR}/examples")
|
||||||
|
_apply_compile_options(hotreload_demo_plugin_v1)
|
||||||
|
|
||||||
|
# The whole point of this target: give source/HotReload's jump-patch
|
||||||
|
# backends guaranteed room to write into. See cmake/HotReload.cmake.
|
||||||
|
hot_reload_enable_patchable_functions(hotreload_demo_plugin_v1)
|
||||||
|
|
||||||
|
_register_module_target(EXAMPLE hotreload_demo_plugin_v1)
|
||||||
|
add_dependencies(all_examples hotreload_demo_plugin_v1)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* CppWidget V1 -- "before your edit". See PluginV2/src/CppWidget.cpp
|
||||||
|
* for "after": same three exports change behavior together, exactly
|
||||||
|
* like PluginV1/src/Plugin.cpp's plain-C functions do.
|
||||||
|
*/
|
||||||
|
#include "CppWidgetApi.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
int CppWidget::virtualTick(int input)
|
||||||
|
{
|
||||||
|
int result = input * 2;
|
||||||
|
std::printf(" CppWidget::virtualTick(%d) -> %d [V1: input * 2, via vtable]\n", input, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int CppWidget::tick(int input)
|
||||||
|
{
|
||||||
|
int result = input * 2;
|
||||||
|
std::printf(" CppWidget::tick(%d) -> %d [V1: input * 2, via mangled-name lookup]\n", input, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
CppWidget CppWidget::operator+(const CppWidget& /*other*/) const
|
||||||
|
{
|
||||||
|
std::printf(" CppWidget::operator+ [V1]\n");
|
||||||
|
return CppWidget();
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" CppWidget* make_cpp_widget()
|
||||||
|
{
|
||||||
|
return new CppWidget();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* PluginV1 -- "before your edit". See PluginV2/src/Plugin.cpp for
|
||||||
|
* "after"; the two deliberately export the identical set of names
|
||||||
|
* (minus plugin_legacy_feature, removed in V2) so HotReloadContext can
|
||||||
|
* line them up. See ../Shared/PluginApi.h for what each export is
|
||||||
|
* there to demonstrate.
|
||||||
|
*/
|
||||||
|
#include "PluginApi.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
int plugin_tick(int input)
|
||||||
|
{
|
||||||
|
int result = input * 2;
|
||||||
|
std::printf(" plugin_tick(%d) -> %d [V1: input * 2]\n", input, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int plugin_legacy_feature(int input)
|
||||||
|
{
|
||||||
|
std::printf(" plugin_legacy_feature(%d) -> %d [V1, about to be removed in V2]\n", input, input);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(__GNUC__) || defined(__clang__)
|
||||||
|
int plugin_tiny_function(int input)
|
||||||
|
{
|
||||||
|
return input + 1;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
int plugin_build_marker = 1;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# See ../PluginV1/CMakeLists.txt for why this is hand-written instead
|
||||||
|
# of add_lib_module() -- this is that same explanation's other half:
|
||||||
|
# PluginV2 exports the identical extern "C" names as PluginV1 (see
|
||||||
|
# ../Shared/PluginApi.h) with a different implementation standing in
|
||||||
|
# for "the same file, after your edit" in this demo's story.
|
||||||
|
|
||||||
|
if(BUILD_EXAMPLES)
|
||||||
|
add_library(hotreload_demo_plugin_v2 SHARED src/Plugin.cpp src/CppWidget.cpp)
|
||||||
|
else()
|
||||||
|
add_library(hotreload_demo_plugin_v2 SHARED EXCLUDE_FROM_ALL src/Plugin.cpp src/CppWidget.cpp)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
target_include_directories(hotreload_demo_plugin_v2 PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Shared)
|
||||||
|
set_target_properties(hotreload_demo_plugin_v2 PROPERTIES
|
||||||
|
OUTPUT_NAME "hotreload_demo_plugin_v2"
|
||||||
|
WINDOWS_EXPORT_ALL_SYMBOLS ON)
|
||||||
|
_set_output_dirs(hotreload_demo_plugin_v2
|
||||||
|
"${CMAKE_BINARY_DIR}/${RUNTIME_OUTPUT_SUBDIR}/examples"
|
||||||
|
"${CMAKE_BINARY_DIR}/${LIBRARY_OUTPUT_SUBDIR}/examples")
|
||||||
|
_apply_compile_options(hotreload_demo_plugin_v2)
|
||||||
|
hot_reload_enable_patchable_functions(hotreload_demo_plugin_v2)
|
||||||
|
|
||||||
|
_register_module_target(EXAMPLE hotreload_demo_plugin_v2)
|
||||||
|
add_dependencies(all_examples hotreload_demo_plugin_v2)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* CppWidget V2 -- "after your edit". See PluginV1/src/CppWidget.cpp
|
||||||
|
* for "before".
|
||||||
|
*/
|
||||||
|
#include "CppWidgetApi.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
int CppWidget::virtualTick(int input)
|
||||||
|
{
|
||||||
|
int result = input * 3;
|
||||||
|
std::printf(" CppWidget::virtualTick(%d) -> %d [V2: input * 3, via vtable]\n", input, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
int CppWidget::tick(int input)
|
||||||
|
{
|
||||||
|
int result = input * 3;
|
||||||
|
std::printf(" CppWidget::tick(%d) -> %d [V2: input * 3, via mangled-name lookup]\n", input, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
CppWidget CppWidget::operator+(const CppWidget& /*other*/) const
|
||||||
|
{
|
||||||
|
std::printf(" CppWidget::operator+ [V2]\n");
|
||||||
|
return CppWidget();
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" CppWidget* make_cpp_widget()
|
||||||
|
{
|
||||||
|
return new CppWidget();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* PluginV2 -- "after your edit". See PluginV1/src/Plugin.cpp for
|
||||||
|
* "before". plugin_tick's behavior changed, plugin_legacy_feature was
|
||||||
|
* deleted, and plugin_new_feature was added -- exactly the three kinds
|
||||||
|
* of change hot_reload.h's HotReloadSymbolEvent enum distinguishes.
|
||||||
|
*/
|
||||||
|
#include "PluginApi.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
int plugin_tick(int input)
|
||||||
|
{
|
||||||
|
int result = input * 3;
|
||||||
|
std::printf(" plugin_tick(%d) -> %d [V2: input * 3]\n", input, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* plugin_legacy_feature deliberately does not exist in V2 anymore. */
|
||||||
|
|
||||||
|
int plugin_new_feature(int input)
|
||||||
|
{
|
||||||
|
int result = input * 10;
|
||||||
|
std::printf(" plugin_new_feature(%d) -> %d [V2: brand new]\n", input, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(__GNUC__) || defined(__clang__)
|
||||||
|
int plugin_tiny_function(int input)
|
||||||
|
{
|
||||||
|
return input + 2;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
int plugin_build_marker = 2;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/* CppWidgetApi.h -- a C++ (not extern "C") surface living alongside
|
||||||
|
* PluginApi.h's plain C one, in the SAME plugin build. Every member
|
||||||
|
* function below keeps its real, compiler-mangled name; nothing here
|
||||||
|
* needs special handling by source/HotReload, which classifies
|
||||||
|
* symbols by their type and section, never by what their name looks
|
||||||
|
* like -- see hot_reload.h's "REQUIREMENTS ON A HOT-RELOADABLE
|
||||||
|
* MODULE" and README.md's "Hot reloading C++ symbols" for the full
|
||||||
|
* picture.
|
||||||
|
*
|
||||||
|
* The two methods below are reached two different ways from
|
||||||
|
* Host/Main.cpp, deliberately: see that file's comments for why.
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
class CppWidget
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
virtual ~CppWidget() = default;
|
||||||
|
|
||||||
|
/* Virtual: Host/Main.cpp calls this through ordinary C++
|
||||||
|
* polymorphic dispatch (widget->virtualTick(x)) and needs no
|
||||||
|
* manual symbol lookup at all -- the call compiles down to an
|
||||||
|
* indirect call through the object's vtable slot, not a direct
|
||||||
|
* reference to this specific symbol, so it needs no link-time
|
||||||
|
* dependency on whichever plugin build actually defines it. */
|
||||||
|
virtual int virtualTick(int input);
|
||||||
|
|
||||||
|
/* An ordinary out-of-line method. Calling this as widget->tick(x)
|
||||||
|
* would compile to a direct call to CppWidget::tick's own mangled
|
||||||
|
* symbol, which would need the Host to link against a plugin
|
||||||
|
* build at build time -- exactly what hot-reloading a plugin
|
||||||
|
* means avoiding. So Host/Main.cpp instead resolves it manually
|
||||||
|
* by its mangled name via hot_reload_get_symbol(), the same way
|
||||||
|
* it resolves PluginApi.h's plain C functions. */
|
||||||
|
int tick(int input);
|
||||||
|
|
||||||
|
/* An overloaded operator: mechanically just a function with an
|
||||||
|
* unusual mangled name, reached the same manual way as tick()
|
||||||
|
* above. */
|
||||||
|
CppWidget operator+(const CppWidget& other) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The one entry point Host/Main.cpp looks up by a plain, human-typed
|
||||||
|
* name -- extern "C" here for exactly the reason hot_reload.h
|
||||||
|
* recommends it: something to hand a human-readable name to, without
|
||||||
|
* needing to know or hardcode a mangled string. */
|
||||||
|
extern "C" CppWidget* make_cpp_widget();
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/* PluginApi.h -- the contract PluginV1 and PluginV2 both implement,
|
||||||
|
* and the Host loads by name. Every function here follows
|
||||||
|
* cpp-style-guide.md section 11's rule for anything loaded at runtime:
|
||||||
|
* plain C linkage, no classes or exceptions crossing the boundary.
|
||||||
|
*
|
||||||
|
* This header is deliberately identical for both plugin versions --
|
||||||
|
* in a real project this is the file that stays stable while
|
||||||
|
* PluginV1/src/Plugin.cpp and PluginV2/src/Plugin.cpp (standing in for
|
||||||
|
* "the same file, before and after your edit") diverge.
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C"
|
||||||
|
{
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Changes behavior between V1 and V2 -- watch this one get
|
||||||
|
* HOT_RELOAD_SYMBOL_PATCHED. */
|
||||||
|
int plugin_tick(int input);
|
||||||
|
|
||||||
|
/* Only in V1, gone in V2 -- watch this one get
|
||||||
|
* HOT_RELOAD_SYMBOL_REMOVED, and the Host's stub take over for any
|
||||||
|
* call still aimed at it afterwards. */
|
||||||
|
int plugin_legacy_feature(int input);
|
||||||
|
|
||||||
|
/* Only in V2 -- watch this one get HOT_RELOAD_SYMBOL_ADDED, then
|
||||||
|
* get called for the first time right after. */
|
||||||
|
int plugin_new_feature(int input);
|
||||||
|
|
||||||
|
#if defined(__GNUC__) || defined(__clang__)
|
||||||
|
/* Deliberately too small to safely hold a jump on any of this
|
||||||
|
* library's backends (its patchable-function-entry padding is
|
||||||
|
* overridden down to zero) -- watch this one get
|
||||||
|
* HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL rather than corrupt whatever
|
||||||
|
* comes after it. MSVC has no equivalent per-function override
|
||||||
|
* this demo could apply, so this one only exists on GCC/Clang.
|
||||||
|
*/
|
||||||
|
__attribute__((patchable_function_entry(0))) int plugin_tiny_function(int input);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* An exported VARIABLE, not a function, sitting right next to
|
||||||
|
* real functions in the same library and changing value between
|
||||||
|
* V1 and V2 just like plugin_tick's behavior does -- watch this
|
||||||
|
* one get HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE. This is the
|
||||||
|
* mechanical proof behind hot_reload.h's "only executable code
|
||||||
|
* changes" guarantee: nothing here tells the library "this one is
|
||||||
|
* data, don't touch it" -- it discovers that itself from the
|
||||||
|
* symbol table, the same way it discovers plugin_tick is code.
|
||||||
|
*/
|
||||||
|
extern int plugin_build_marker;
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# See source/examples/HotReloadDemo/CMakeLists.txt for why this
|
||||||
|
# grouping file needs to exist at all (one-level-deep discovery in
|
||||||
|
# cmake/BuildHelpers.cmake).
|
||||||
|
add_subdirectory(Plugin)
|
||||||
|
add_subdirectory(Host)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
add_example_module(hotreload_live_demo_host OUTPUT_NAME "hotreload_live_demo_host" DEPENDS hotreload_lib)
|
||||||
|
|
||||||
|
# Build-order-only, exactly like HotReloadDemo/Host -- see that file's
|
||||||
|
# CMakeLists.txt comment. This Host must NOT link against the plugin
|
||||||
|
# directly; it loads it dynamically through source/HotReload instead.
|
||||||
|
add_dependencies(hotreload_live_demo_host hotreload_live_plugin)
|
||||||
|
target_compile_definitions(hotreload_live_demo_host PRIVATE
|
||||||
|
"LIVE_PLUGIN_PATH=\"$<TARGET_FILE:hotreload_live_plugin>\"")
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/* Main.cpp -- a persistent process, unlike source/examples/
|
||||||
|
* HotReloadDemo/Host (which runs a fixed script and exits). This one
|
||||||
|
* loads Plugin/src/LivePlugin.cpp's compiled output once, then polls
|
||||||
|
* that file's last-write time every second: whenever it changes (i.e.
|
||||||
|
* you rebuilt it), it reloads automatically. Run this, then edit and
|
||||||
|
* rebuild the plugin in another terminal -- see LivePlugin.cpp's own
|
||||||
|
* comment for exactly what to try. Stop with Ctrl+C.
|
||||||
|
*/
|
||||||
|
#include "hot_reload/hot_reload.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
std::atomic<bool> g_running{true};
|
||||||
|
|
||||||
|
void handleSigint(int /*signal*/)
|
||||||
|
{
|
||||||
|
g_running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* eventKindName(HotReloadSymbolEvent kind)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case HOT_RELOAD_SYMBOL_PATCHED:
|
||||||
|
return "PATCHED";
|
||||||
|
case HOT_RELOAD_SYMBOL_ADDED:
|
||||||
|
return "ADDED";
|
||||||
|
case HOT_RELOAD_SYMBOL_REMOVED:
|
||||||
|
return "REMOVED";
|
||||||
|
case HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE:
|
||||||
|
return "SKIPPED (data, not code)";
|
||||||
|
case HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL:
|
||||||
|
return "SKIPPED (too small to patch)";
|
||||||
|
case HOT_RELOAD_SYMBOL_SKIPPED_UNWRITABLE:
|
||||||
|
return "SKIPPED (page not writable)";
|
||||||
|
}
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
void onSymbolEvent(const HotReloadSymbolEventInfo* event, void* /*userData*/)
|
||||||
|
{
|
||||||
|
if (event->kind == HOT_RELOAD_SYMBOL_REMOVED)
|
||||||
|
{
|
||||||
|
return; // reported, with more context, by onDeletedSymbol below
|
||||||
|
}
|
||||||
|
std::printf(" [reload] %-24s %s\n", eventKindName(event->kind), event->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
int fallbackForDeletedTick(int frame)
|
||||||
|
{
|
||||||
|
std::printf(
|
||||||
|
" live_tick was deleted -- this is the Host's fallback (frame %d). Bring it back under the same "
|
||||||
|
"name and rebuild to try LivePlugin.cpp's suggestion #2 through to the end.\n",
|
||||||
|
frame);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void* onDeletedSymbol(const char* symbolName, void* /*userData*/)
|
||||||
|
{
|
||||||
|
std::printf(" [reload] REMOVED %s -> falling back to the Host's stub\n", symbolName);
|
||||||
|
return reinterpret_cast<void*>(&fallbackForDeletedTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char** argv)
|
||||||
|
{
|
||||||
|
// Force line buffering regardless of whether stdout is a real
|
||||||
|
// terminal: this is a "watch it happen live" demo, and glibc (and
|
||||||
|
// most libc implementations) fully buffer stdout instead of
|
||||||
|
// line-buffering it the moment it's not connected to a TTY -- a
|
||||||
|
// redirect to a log file, a `| tee`, or some IDEs' embedded
|
||||||
|
// terminals all trigger this. Without this call, output can sit
|
||||||
|
// in the buffer for a long time (observed directly while building
|
||||||
|
// this: zero output appeared for several seconds of real,
|
||||||
|
// correctly-running execution until the buffer was force-flushed).
|
||||||
|
std::setvbuf(stdout, nullptr, _IOLBF, 4096);
|
||||||
|
|
||||||
|
std::string pluginPath = argc > 1 ? argv[1] : LIVE_PLUGIN_PATH;
|
||||||
|
|
||||||
|
std::printf("================================================================\n");
|
||||||
|
std::printf(" Hot Reload -- live demo\n");
|
||||||
|
std::printf("================================================================\n");
|
||||||
|
std::printf(" Watching: %s\n", pluginPath.c_str());
|
||||||
|
std::printf(" Edit: source/examples/HotReloadLiveDemo/Plugin/src/LivePlugin.cpp\n");
|
||||||
|
std::printf(" Rebuild: cmake --build <your build dir> --target hotreload_live_plugin\n");
|
||||||
|
std::printf(" (or run watch.sh, in this example's own directory, in another\n");
|
||||||
|
std::printf(" terminal to rebuild automatically every time you save)\n");
|
||||||
|
std::printf(" Stop: Ctrl+C\n");
|
||||||
|
std::printf("================================================================\n\n");
|
||||||
|
|
||||||
|
std::signal(SIGINT, &handleSigint);
|
||||||
|
|
||||||
|
HotReloadOptions options{};
|
||||||
|
options.on_symbol_event = &onSymbolEvent;
|
||||||
|
options.on_deleted_symbol = &onDeletedSymbol;
|
||||||
|
|
||||||
|
HotReloadContext* ctx = hot_reload_context_create(&options);
|
||||||
|
if (ctx == nullptr)
|
||||||
|
{
|
||||||
|
std::fprintf(stderr, "hot_reload_context_create failed\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hot_reload_load(ctx, "live", pluginPath.c_str()) != HOT_RELOAD_OK)
|
||||||
|
{
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"Could not load '%s': %s\n"
|
||||||
|
"(if the file exists but this still fails, that's usually a genuinely undefined symbol in the "
|
||||||
|
"plugin -- e.g. a non-inline static class member that's declared but never defined, only caught "
|
||||||
|
"once something actually calls into the code path that uses it; the message above names it)\n",
|
||||||
|
pluginPath.c_str(), hot_reload_last_error(ctx));
|
||||||
|
hot_reload_context_destroy(ctx);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved ONCE, here, and deliberately never re-resolved below --
|
||||||
|
// the whole point of this demo is watching this same pointer keep
|
||||||
|
// working, correctly, across as many reloads as you make. The one
|
||||||
|
// case where that stops being true: if you delete live_tick (see
|
||||||
|
// LivePlugin.cpp suggestion #2) and later re-add it under the same
|
||||||
|
// name, the re-added one is a NEW, unrelated symbol as far as
|
||||||
|
// diffing is concerned -- there is no "old" address to patch for
|
||||||
|
// it, so it comes back as HOT_RELOAD_SYMBOL_ADDED, not PATCHED, and
|
||||||
|
// this specific pointer (already redirected to the fallback below)
|
||||||
|
// keeps running that fallback rather than picking the new one back
|
||||||
|
// up automatically. Restart the Host to resolve it fresh, or call
|
||||||
|
// hot_reload_get_symbol() again yourself, if you want to see that.
|
||||||
|
using TickFunction = int (*)(int);
|
||||||
|
auto tick = reinterpret_cast<TickFunction>(hot_reload_get_symbol(ctx, "live", "live_tick"));
|
||||||
|
|
||||||
|
std::error_code timeStatus;
|
||||||
|
auto lastWriteTime = std::filesystem::last_write_time(pluginPath, timeStatus);
|
||||||
|
|
||||||
|
int frame = 0;
|
||||||
|
while (g_running)
|
||||||
|
{
|
||||||
|
std::error_code currentTimeStatus;
|
||||||
|
auto currentWriteTime = std::filesystem::last_write_time(pluginPath, currentTimeStatus);
|
||||||
|
if (!currentTimeStatus && currentWriteTime != lastWriteTime)
|
||||||
|
{
|
||||||
|
// A short settle delay for a linker that might still be
|
||||||
|
// mid-write on a slower filesystem -- not load-bearing for
|
||||||
|
// correctness either way: a reload attempted too early
|
||||||
|
// just fails cleanly (hot_reload_reload() never disturbs
|
||||||
|
// the running version on failure) and gets retried next
|
||||||
|
// iteration, since lastWriteTime below is only updated on
|
||||||
|
// success.
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(150));
|
||||||
|
|
||||||
|
std::printf("\n-- change detected in %s, reloading --\n", pluginPath.c_str());
|
||||||
|
HotReloadStatus status = hot_reload_reload(ctx, "live", pluginPath.c_str());
|
||||||
|
if (status == HOT_RELOAD_OK)
|
||||||
|
{
|
||||||
|
lastWriteTime = currentWriteTime;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
std::printf("-- reload failed: %s -- still running the previous version; fix and save again --\n",
|
||||||
|
hot_reload_last_error(ctx));
|
||||||
|
}
|
||||||
|
std::printf("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tick != nullptr)
|
||||||
|
{
|
||||||
|
tick(frame);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
std::printf(" (live_tick not found -- build the plugin; see instructions above)\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlike tick above, deliberately re-resolved every frame:
|
||||||
|
// live_extra is not expected to exist until you add it (see
|
||||||
|
// LivePlugin.cpp suggestion #3), so the Host has to keep
|
||||||
|
// checking rather than assume it already knows the answer.
|
||||||
|
// This is a cheap lookup over a handful of symbols, not a
|
||||||
|
// reload -- fine to do every frame.
|
||||||
|
using ExtraFunction = int (*)(int);
|
||||||
|
auto extra = reinterpret_cast<ExtraFunction>(hot_reload_get_symbol(ctx, "live", "live_extra"));
|
||||||
|
if (extra != nullptr)
|
||||||
|
{
|
||||||
|
extra(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
++frame;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("\nShutting down.\n");
|
||||||
|
hot_reload_context_destroy(ctx);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# The one target this whole example is about rebuilding, in place,
|
||||||
|
# while Host is already running and has it loaded. Hand-written rather
|
||||||
|
# than add_lib_module() for the same reason as HotReloadDemo/PluginV1
|
||||||
|
# -- see that file's comment -- even though there's only one build of
|
||||||
|
# this one: what makes this target unusual isn't needing two versions,
|
||||||
|
# it's needing to be a completely ordinary, repeatedly-overwritable
|
||||||
|
# build output while another process holds an OLDER build of it open
|
||||||
|
# via source/HotReload (see that library's HotReloadContext::
|
||||||
|
# stagePathForLoad() for why the running Host never itself locks this
|
||||||
|
# exact path).
|
||||||
|
|
||||||
|
if(BUILD_EXAMPLES)
|
||||||
|
add_library(hotreload_live_plugin SHARED src/LivePlugin.cpp)
|
||||||
|
else()
|
||||||
|
add_library(hotreload_live_plugin SHARED EXCLUDE_FROM_ALL src/LivePlugin.cpp)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set_target_properties(hotreload_live_plugin PROPERTIES
|
||||||
|
OUTPUT_NAME "hotreload_live_plugin"
|
||||||
|
WINDOWS_EXPORT_ALL_SYMBOLS ON)
|
||||||
|
_set_output_dirs(hotreload_live_plugin
|
||||||
|
"${CMAKE_BINARY_DIR}/${RUNTIME_OUTPUT_SUBDIR}/examples"
|
||||||
|
"${CMAKE_BINARY_DIR}/${LIBRARY_OUTPUT_SUBDIR}/examples")
|
||||||
|
_apply_compile_options(hotreload_live_plugin)
|
||||||
|
hot_reload_enable_patchable_functions(hotreload_live_plugin)
|
||||||
|
|
||||||
|
_register_module_target(EXAMPLE hotreload_live_plugin)
|
||||||
|
add_dependencies(all_examples hotreload_live_plugin)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* LivePlugin.cpp -- THE FILE TO EDIT.
|
||||||
|
*
|
||||||
|
* With source/examples/HotReloadLiveDemo/Host running in one
|
||||||
|
* terminal, rebuild just this file from another with:
|
||||||
|
*
|
||||||
|
* cmake --build <your build dir> --target hotreload_live_plugin
|
||||||
|
*
|
||||||
|
* or run watch.sh (in this same directory) in that other terminal to
|
||||||
|
* have it rebuild automatically every time you save. Either way, the
|
||||||
|
* running Host notices within about a second of a successful rebuild
|
||||||
|
* and hot-reloads -- no restart.
|
||||||
|
*
|
||||||
|
* Things worth trying, roughly in order:
|
||||||
|
*
|
||||||
|
* 1. Change the message or the multiplier in live_tick() below and
|
||||||
|
* rebuild -- watch the running Host's output change mid-flight,
|
||||||
|
* still counting the same frame number it already had.
|
||||||
|
*
|
||||||
|
* 2. Delete live_tick() entirely (or rename it) and rebuild -- the
|
||||||
|
* Host supplies a fallback for exactly this case, so you'll see
|
||||||
|
* that kick in instead of a crash. Bring the function back under
|
||||||
|
* the same name and rebuild again... and notice it does NOT
|
||||||
|
* automatically take back over -- see the Host's own comment on
|
||||||
|
* why, right where it resolves live_tick.
|
||||||
|
*
|
||||||
|
* 3. Add a brand new function:
|
||||||
|
*
|
||||||
|
* extern "C" int live_extra(int frame) { ... }
|
||||||
|
*
|
||||||
|
* and rebuild -- the Host looks for this one on every frame and
|
||||||
|
* starts calling it the moment it exists.
|
||||||
|
*
|
||||||
|
* 4. Add a new exported variable, e.g.
|
||||||
|
*
|
||||||
|
* extern "C" int live_marker = 1;
|
||||||
|
*
|
||||||
|
* then change its value on a later edit and rebuild -- watch the
|
||||||
|
* reload log call it out as data, classified and left alone,
|
||||||
|
* never patched, exactly like plugin_build_marker in
|
||||||
|
* source/examples/HotReloadDemo.
|
||||||
|
*/
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
extern "C" int live_tick(int frame)
|
||||||
|
{
|
||||||
|
int multiplier = 2; // <- try changing this
|
||||||
|
int value = frame * multiplier;
|
||||||
|
std::printf(" live_tick(%d) -> %d\n", frame, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Hot Reload -- live demo
|
||||||
|
|
||||||
|
Unlike `source/examples/HotReloadDemo` (a fixed script that loads two
|
||||||
|
pre-built versions and exits), this one is interactive: a host that
|
||||||
|
keeps running, and one plugin file you edit and rebuild yourself,
|
||||||
|
however many times you like, while it's live.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake -S . -B out/build -DBUILD_EXAMPLES=ON
|
||||||
|
cmake --build out/build
|
||||||
|
./out/build/bin/examples/hotreload_live_demo_host
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave that running. In another terminal, either rebuild by hand after
|
||||||
|
each edit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build out/build --target hotreload_live_plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
or run the watcher in this directory so saving is all you need to do:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
source/examples/HotReloadLiveDemo/watch.sh out/build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `Plugin/src/LivePlugin.cpp` -- it has its own comment with
|
||||||
|
several concrete things worth trying, in order (change a value, delete
|
||||||
|
the function, add a new one, add a data symbol). Each one lines up
|
||||||
|
with something `source/HotReload/README.md`'s "Hot reloading C++
|
||||||
|
symbols" and "The two guarantees" sections describe -- this is a place
|
||||||
|
to go watch each of those claims happen, not just read about them.
|
||||||
|
|
||||||
|
## What you should see
|
||||||
|
|
||||||
|
Editing and saving `live_tick()`'s multiplier and rebuilding should
|
||||||
|
change the running Host's printed values within about a second, on
|
||||||
|
the frame count it was already at -- not a restart. This was verified
|
||||||
|
in exactly this shape while building the feature: host running,
|
||||||
|
`watch.sh` running, and nothing else done except editing the file and
|
||||||
|
saving -- the running process picked up the new multiplier on its own
|
||||||
|
a couple of seconds later.
|
||||||
|
|
||||||
|
## If a reload doesn't seem to happen
|
||||||
|
|
||||||
|
- Check the target actually rebuilt -- `watch.sh` prints a build
|
||||||
|
failure clearly rather than silently doing nothing; a plain compile
|
||||||
|
error leaves the previous `.so`/`.dll` completely untouched, which
|
||||||
|
is why the Host would keep running old code without any error of
|
||||||
|
its own in that case.
|
||||||
|
- The Host polls once a second (see `Host/Main.cpp`) -- give it a
|
||||||
|
moment.
|
||||||
|
- Confirm you rebuilt the target this example actually watches,
|
||||||
|
`hotreload_live_plugin`, not the whole project (rebuilding
|
||||||
|
everything works too, it's just slower per iteration).
|
||||||
|
|
||||||
|
## Why rebuilding a `.so`/`.dll` the Host already has loaded works at all
|
||||||
|
|
||||||
|
On Linux/macOS this is unremarkable -- a running process's existing
|
||||||
|
mapping of a file survives the file being replaced, which is exactly
|
||||||
|
this project's normal build-then-relink cycle.
|
||||||
|
|
||||||
|
On Windows it's normally *not* possible to overwrite or delete a DLL
|
||||||
|
file a running process has loaded, which would otherwise make this
|
||||||
|
exact workflow (keep the Host running, rebuild the DLL it already
|
||||||
|
loaded) fail with the linker unable to write its output. This example
|
||||||
|
avoids that for a reason that already exists elsewhere in this
|
||||||
|
library, not anything specific to this demo:
|
||||||
|
`HotReloadContext::stagePathForLoad()` (`source/HotReload/src/
|
||||||
|
HotReloadContext.cpp`) copies a plugin to a fresh, uniquely-named file
|
||||||
|
before ever calling `LoadLibrary`/`dlopen` on it -- built originally to
|
||||||
|
sidestep a dlopen caching gotcha on POSIX (see that function's
|
||||||
|
comment) -- so the path this example's build actually writes to is
|
||||||
|
never the one Windows locks. This reasoning was not verified against
|
||||||
|
a real Windows rebuild-while-running cycle specifically (unlike the
|
||||||
|
rest of this library's Windows behavior, which was exercised under
|
||||||
|
Wine -- see `source/HotReload/README.md`'s verification notes); if a
|
||||||
|
rebuild on Windows still fails while the Host is running, that
|
||||||
|
mismatch between reasoning and reality is worth reporting.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rebuilds hotreload_live_plugin every time LivePlugin.cpp's mtime
|
||||||
|
# changes, so you can just edit-and-save instead of switching to a
|
||||||
|
# terminal to rebuild by hand. Run this in one terminal; run
|
||||||
|
# hotreload_live_demo_host (built once) in another; edit
|
||||||
|
# Plugin/src/LivePlugin.cpp in your editor. Matches this project's
|
||||||
|
# other scripts/*.sh conventions (see scripts/run.sh) even though it
|
||||||
|
# lives here rather than in scripts/, since it's specific to this one
|
||||||
|
# example rather than general project tooling.
|
||||||
|
#
|
||||||
|
# Usage (from anywhere):
|
||||||
|
# source/examples/HotReloadLiveDemo/watch.sh [build-dir]
|
||||||
|
#
|
||||||
|
# build-dir defaults to out/build (relative to the project root, same
|
||||||
|
# default as scripts/build.sh); pass your own if you configured
|
||||||
|
# somewhere else, e.g.:
|
||||||
|
# source/examples/HotReloadLiveDemo/watch.sh out/build-clang
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR/../../.."
|
||||||
|
|
||||||
|
BUILD_DIR="${1:-out/build}"
|
||||||
|
SOURCE_FILE="$SCRIPT_DIR/Plugin/src/LivePlugin.cpp"
|
||||||
|
TARGET="hotreload_live_plugin"
|
||||||
|
|
||||||
|
if [ ! -d "$BUILD_DIR" ]; then
|
||||||
|
echo "error: build directory '$BUILD_DIR' not found." >&2
|
||||||
|
echo "Configure it first, e.g.:" >&2
|
||||||
|
echo " cmake -S . -B $BUILD_DIR -DBUILD_EXAMPLES=ON" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_mtime() {
|
||||||
|
# GNU stat (Linux) and BSD/macOS stat use incompatible flags --
|
||||||
|
# try one, fall back to the other.
|
||||||
|
stat -c %Y "$SOURCE_FILE" 2>/dev/null || stat -f %m "$SOURCE_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Watching: $SOURCE_FILE"
|
||||||
|
echo "Rebuilds into: $BUILD_DIR (target: $TARGET)"
|
||||||
|
echo "Edit and save that file -- Ctrl+C here to stop watching (the running Host keeps going either way)."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
last="$(file_mtime)"
|
||||||
|
while true; do
|
||||||
|
current="$(file_mtime)"
|
||||||
|
if [ "$current" != "$last" ]; then
|
||||||
|
last="$current"
|
||||||
|
echo "---- rebuilding ($(date '+%H:%M:%S')) ----"
|
||||||
|
if cmake --build "$BUILD_DIR" --target "$TARGET"; then
|
||||||
|
echo "---- done -- the running Host should pick this up within about a second ----"
|
||||||
|
else
|
||||||
|
echo "---- build failed -- fix the error above and save again ----"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Reuses PluginV1/PluginV2 from source/examples/HotReloadDemo as test
|
||||||
|
# fixtures rather than building yet another pair of throwaway shared
|
||||||
|
# libraries -- they already exist specifically to differ from each
|
||||||
|
# other in the ways this test needs to check (a changed function, a
|
||||||
|
# removed one, an added one, an unchanged exported variable). Requires
|
||||||
|
# BUILD_EXAMPLES as well as BUILD_TESTS for that reason.
|
||||||
|
add_test_module(hotreload_tests OUTPUT_NAME "hotreload_tests" DEPENDS hotreload_lib)
|
||||||
|
target_include_directories(hotreload_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../examples/HotReloadDemo/Shared)
|
||||||
|
|
||||||
|
if(BUILD_EXAMPLES)
|
||||||
|
add_dependencies(hotreload_tests hotreload_demo_plugin_v1 hotreload_demo_plugin_v2)
|
||||||
|
target_compile_definitions(hotreload_tests PRIVATE
|
||||||
|
"PLUGIN_V1_PATH=\"$<TARGET_FILE:hotreload_demo_plugin_v1>\""
|
||||||
|
"PLUGIN_V2_PATH=\"$<TARGET_FILE:hotreload_demo_plugin_v2>\"")
|
||||||
|
else()
|
||||||
|
message(WARNING "hotreload_tests needs BUILD_EXAMPLES=ON too (it reuses the HotReloadDemo plugins as fixtures); skipping its checks.")
|
||||||
|
target_compile_definitions(hotreload_tests PRIVATE HOTRELOAD_TESTS_SKIP=1)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# UndefinedSymbolFixture is a SIBLING under source/tests/, discovered
|
||||||
|
# independently by this project's own _discover_module_subdirectories()
|
||||||
|
# -- deliberately NOT add_subdirectory()'d from here, even though it's
|
||||||
|
# conceptually "this test's fixture": add_test_module()'s own source
|
||||||
|
# glob (*.cpp, recursive from CMAKE_CURRENT_SOURCE_DIR) would otherwise
|
||||||
|
# also sweep up the fixture's .cpp directly into hotreload_tests itself
|
||||||
|
# if it lived in a subdirectory of this one -- found the hard way while
|
||||||
|
# building this: the fixture's deliberately-undefined symbol then
|
||||||
|
# fails the whole test executable's OWN link, which does not tolerate
|
||||||
|
# an undefined symbol the way a shared library does.
|
||||||
|
add_dependencies(hotreload_tests hotreload_tests_undefined_symbol_fixture)
|
||||||
|
target_compile_definitions(hotreload_tests PRIVATE
|
||||||
|
"UNDEFINED_SYMBOL_FIXTURE_PATH=\"$<TARGET_FILE:hotreload_tests_undefined_symbol_fixture>\"")
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/* Exercises source/HotReload end to end against the real PluginV1/
|
||||||
|
* PluginV2 builds from source/examples/HotReloadDemo -- see
|
||||||
|
* ../../examples/HotReloadDemo/Shared/PluginApi.h and CppWidgetApi.h
|
||||||
|
* for what each exported name is there to prove. Deliberately does
|
||||||
|
* NOT use assert(): this project's default build type is Release,
|
||||||
|
* where assert() is compiled out by NDEBUG and would make every
|
||||||
|
* check here a silent, permanent no-op -- see CHECK() below instead.
|
||||||
|
*/
|
||||||
|
#include "CppWidgetApi.h"
|
||||||
|
#include "hot_reload/hot_reload.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#ifndef HOTRELOAD_TESTS_SKIP
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
int g_failures = 0;
|
||||||
|
|
||||||
|
void checkImpl(bool condition, const char* expression, const char* file, int line)
|
||||||
|
{
|
||||||
|
if (!condition)
|
||||||
|
{
|
||||||
|
std::fprintf(stderr, "CHECK FAILED: %s (%s:%d)\n", expression, file, line);
|
||||||
|
++g_failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#define CHECK(expr) checkImpl((expr), #expr, __FILE__, __LINE__)
|
||||||
|
|
||||||
|
using PluginFunction = int (*)(int);
|
||||||
|
|
||||||
|
std::map<std::string, HotReloadSymbolEvent> g_events;
|
||||||
|
|
||||||
|
void onSymbolEvent(const HotReloadSymbolEventInfo* event, void* /*userData*/)
|
||||||
|
{
|
||||||
|
g_events[event->name] = event->kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
HotReloadOptions options{};
|
||||||
|
options.on_symbol_event = &onSymbolEvent;
|
||||||
|
// Deliberately no on_deleted_symbol here -- this test covers the
|
||||||
|
// DEFAULT deletion policy (leave the old implementation running);
|
||||||
|
// source/examples/HotReloadDemo/Host/Main.cpp covers supplying a
|
||||||
|
// stub instead. Between the two, both documented behaviors for
|
||||||
|
// HOT_RELOAD_SYMBOL_REMOVED get exercised somewhere in this repo.
|
||||||
|
|
||||||
|
HotReloadContext* ctx = hot_reload_context_create(&options);
|
||||||
|
CHECK(ctx != nullptr);
|
||||||
|
|
||||||
|
// --- load V1 ---
|
||||||
|
CHECK(hot_reload_load(ctx, "plugin", PLUGIN_V1_PATH) == HOT_RELOAD_OK);
|
||||||
|
|
||||||
|
auto tick = reinterpret_cast<PluginFunction>(hot_reload_get_symbol(ctx, "plugin", "plugin_tick"));
|
||||||
|
auto legacy = reinterpret_cast<PluginFunction>(hot_reload_get_symbol(ctx, "plugin", "plugin_legacy_feature"));
|
||||||
|
auto markerBefore = static_cast<int*>(hot_reload_get_symbol(ctx, "plugin", "plugin_build_marker"));
|
||||||
|
CHECK(tick != nullptr);
|
||||||
|
CHECK(legacy != nullptr);
|
||||||
|
CHECK(markerBefore != nullptr);
|
||||||
|
|
||||||
|
CHECK(tick(10) == 20); // V1: input * 2
|
||||||
|
CHECK(legacy(7) == 7); // V1's own body
|
||||||
|
CHECK(*markerBefore == 1);
|
||||||
|
|
||||||
|
// --- a failed reload must be a complete no-op ---
|
||||||
|
HotReloadStatus badStatus = hot_reload_reload(ctx, "plugin", "/nonexistent/path/does-not-exist.so");
|
||||||
|
CHECK(badStatus == HOT_RELOAD_ERROR_LOAD_FAILED);
|
||||||
|
CHECK(tick(10) == 20); // still V1 -- untouched by the failed attempt
|
||||||
|
|
||||||
|
// --- reload() before any load() must fail distinctly ---
|
||||||
|
CHECK(hot_reload_reload(ctx, "no-such-module", PLUGIN_V2_PATH) == HOT_RELOAD_ERROR_NO_PREVIOUS_VERSION);
|
||||||
|
|
||||||
|
// --- real reload to V2 ---
|
||||||
|
g_events.clear();
|
||||||
|
CHECK(hot_reload_reload(ctx, "plugin", PLUGIN_V2_PATH) == HOT_RELOAD_OK);
|
||||||
|
|
||||||
|
// Same, never-re-resolved pointers now running V2's code:
|
||||||
|
CHECK(tick(10) == 30); // V2: input * 3 -- the actual jump patch took effect
|
||||||
|
CHECK(legacy(7) == 7); // no on_deleted_symbol supplied -> old V1 body keeps running, unharmed
|
||||||
|
CHECK(*markerBefore == 1); // V1's copy: never patched, still reads its original value
|
||||||
|
|
||||||
|
auto markerAfter = static_cast<int*>(hot_reload_get_symbol(ctx, "plugin", "plugin_build_marker"));
|
||||||
|
CHECK(markerAfter != nullptr);
|
||||||
|
CHECK(*markerAfter == 2); // V2's own, freshly-resolved copy
|
||||||
|
|
||||||
|
auto newFeature = reinterpret_cast<PluginFunction>(hot_reload_get_symbol(ctx, "plugin", "plugin_new_feature"));
|
||||||
|
CHECK(newFeature != nullptr);
|
||||||
|
CHECK(newFeature(10) == 100); // V2: input * 10
|
||||||
|
|
||||||
|
// --- every symbol classified exactly as PluginApi.h says it should be ---
|
||||||
|
CHECK(g_events["plugin_tick"] == HOT_RELOAD_SYMBOL_PATCHED);
|
||||||
|
CHECK(g_events["plugin_legacy_feature"] == HOT_RELOAD_SYMBOL_REMOVED);
|
||||||
|
CHECK(g_events["plugin_new_feature"] == HOT_RELOAD_SYMBOL_ADDED);
|
||||||
|
CHECK(g_events["plugin_build_marker"] == HOT_RELOAD_SYMBOL_SKIPPED_NOT_CODE);
|
||||||
|
if (g_events.find("plugin_tiny_function") != g_events.end())
|
||||||
|
{
|
||||||
|
// Only exists on GCC/Clang builds (see PluginApi.h) -- absent
|
||||||
|
// entirely under MSVC, which has no equivalent per-function
|
||||||
|
// override to test here.
|
||||||
|
CHECK(g_events["plugin_tiny_function"] == HOT_RELOAD_SYMBOL_SKIPPED_TOO_SMALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the demangling utility ---
|
||||||
|
char* demangled = hot_reload_demangle("_ZN9CppWidget4tickEi");
|
||||||
|
CHECK(demangled != nullptr);
|
||||||
|
CHECK(demangled != nullptr && std::strcmp(demangled, "CppWidget::tick(int)") == 0);
|
||||||
|
hot_reload_free_string(demangled);
|
||||||
|
|
||||||
|
char* unchanged = hot_reload_demangle("plugin_tick"); // not a mangled name -- must come back as-is
|
||||||
|
CHECK(unchanged != nullptr);
|
||||||
|
CHECK(unchanged != nullptr && std::strcmp(unchanged, "plugin_tick") == 0);
|
||||||
|
hot_reload_free_string(unchanged);
|
||||||
|
|
||||||
|
hot_reload_context_destroy(ctx);
|
||||||
|
|
||||||
|
// --- a real C++ class, no extern "C" anywhere -- CppWidgetApi.h ---
|
||||||
|
// Its own HotReloadContext: HOT_RELOAD_ERROR_INVALID_ARGUMENT
|
||||||
|
// above (loading "plugin" twice under one context) already covers
|
||||||
|
// that specific rule, so a fresh context here keeps this section
|
||||||
|
// independent and easier to read on its own.
|
||||||
|
HotReloadContext* cppCtx = hot_reload_context_create(nullptr);
|
||||||
|
CHECK(cppCtx != nullptr);
|
||||||
|
CHECK(hot_reload_load(cppCtx, "cpp-plugin", PLUGIN_V1_PATH) == HOT_RELOAD_OK);
|
||||||
|
|
||||||
|
auto makeCppWidget =
|
||||||
|
reinterpret_cast<CppWidget* (*) ()>(hot_reload_get_symbol(cppCtx, "cpp-plugin", "make_cpp_widget"));
|
||||||
|
CHECK(makeCppWidget != nullptr);
|
||||||
|
CppWidget* widget = makeCppWidget != nullptr ? makeCppWidget() : nullptr;
|
||||||
|
CHECK(widget != nullptr);
|
||||||
|
|
||||||
|
using CppWidgetTickFunction = int (*)(CppWidget*, int);
|
||||||
|
auto tickByMangledName = reinterpret_cast<CppWidgetTickFunction>(
|
||||||
|
hot_reload_get_symbol(cppCtx, "cpp-plugin", "_ZN9CppWidget4tickEi"));
|
||||||
|
CHECK(tickByMangledName != nullptr);
|
||||||
|
|
||||||
|
if (widget != nullptr)
|
||||||
|
{
|
||||||
|
CHECK(widget->virtualTick(10) == 20); // V1, through the real vtable
|
||||||
|
}
|
||||||
|
if (tickByMangledName != nullptr && widget != nullptr)
|
||||||
|
{
|
||||||
|
CHECK(tickByMangledName(widget, 10) == 20); // V1, through a manually-resolved mangled name
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK(hot_reload_reload(cppCtx, "cpp-plugin", PLUGIN_V2_PATH) == HOT_RELOAD_OK);
|
||||||
|
|
||||||
|
if (widget != nullptr)
|
||||||
|
{
|
||||||
|
CHECK(widget->virtualTick(10) == 30); // same object, same vtable slot -- now V2
|
||||||
|
}
|
||||||
|
if (tickByMangledName != nullptr && widget != nullptr)
|
||||||
|
{
|
||||||
|
CHECK(tickByMangledName(widget, 10) == 30); // same resolved pointer -- also now V2
|
||||||
|
}
|
||||||
|
|
||||||
|
delete widget;
|
||||||
|
hot_reload_context_destroy(cppCtx);
|
||||||
|
|
||||||
|
// --- hot_reload_last_error(): a genuinely undefined symbol, only
|
||||||
|
// reachable once something calls into the function that ODR-uses
|
||||||
|
// it -- see UndefinedSymbolFixture/src/Fixture.cpp for the full
|
||||||
|
// story. Checking for a non-empty message is deliberately the
|
||||||
|
// only assertion here (not the exact dlerror() wording, which
|
||||||
|
// isn't a portability guarantee this library makes) -- the
|
||||||
|
// property this test protects is "some actionable detail exists
|
||||||
|
// at all", not a specific string.
|
||||||
|
HotReloadContext* fixtureCtx = hot_reload_context_create(nullptr);
|
||||||
|
CHECK(fixtureCtx != nullptr);
|
||||||
|
HotReloadStatus fixtureStatus = hot_reload_load(fixtureCtx, "fixture", UNDEFINED_SYMBOL_FIXTURE_PATH);
|
||||||
|
CHECK(fixtureStatus == HOT_RELOAD_ERROR_LOAD_FAILED);
|
||||||
|
const char* fixtureError = hot_reload_last_error(fixtureCtx);
|
||||||
|
CHECK(fixtureError != nullptr);
|
||||||
|
CHECK(fixtureError != nullptr && std::strlen(fixtureError) > 0);
|
||||||
|
hot_reload_context_destroy(fixtureCtx);
|
||||||
|
|
||||||
|
if (g_failures == 0)
|
||||||
|
{
|
||||||
|
std::printf("hotreload_tests: all checks passed\n");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
std::fprintf(stderr, "hotreload_tests: %d check(s) failed\n", g_failures);
|
||||||
|
}
|
||||||
|
return g_failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
std::printf("hotreload_tests: skipped (needs BUILD_EXAMPLES=ON too -- see this module's CMakeLists.txt)\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Hand-written rather than add_lib_module(), same reasoning as
|
||||||
|
# HotReloadDemo/PluginV1's CMakeLists.txt: this needs to be a plain,
|
||||||
|
# always-rebuildable SHARED library gated on BUILD_TESTS rather than a
|
||||||
|
# PRIMARY install target.
|
||||||
|
|
||||||
|
if(BUILD_TESTS)
|
||||||
|
add_library(hotreload_tests_undefined_symbol_fixture SHARED src/Fixture.cpp)
|
||||||
|
else()
|
||||||
|
add_library(hotreload_tests_undefined_symbol_fixture SHARED EXCLUDE_FROM_ALL src/Fixture.cpp)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set_target_properties(hotreload_tests_undefined_symbol_fixture PROPERTIES
|
||||||
|
OUTPUT_NAME "hotreload_tests_undefined_symbol_fixture"
|
||||||
|
WINDOWS_EXPORT_ALL_SYMBOLS ON)
|
||||||
|
_set_output_dirs(hotreload_tests_undefined_symbol_fixture
|
||||||
|
"${CMAKE_BINARY_DIR}/${RUNTIME_OUTPUT_SUBDIR}/tests"
|
||||||
|
"${CMAKE_BINARY_DIR}/${LIBRARY_OUTPUT_SUBDIR}/tests")
|
||||||
|
_apply_compile_options(hotreload_tests_undefined_symbol_fixture)
|
||||||
|
|
||||||
|
_register_module_target(TEST hotreload_tests_undefined_symbol_fixture)
|
||||||
|
add_dependencies(all_tests hotreload_tests_undefined_symbol_fixture)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/* A genuine ODR violation, kept deliberately: a non-inline static
|
||||||
|
* class member that is declared but never given an out-of-class
|
||||||
|
* definition. This compiles cleanly and links cleanly into a shared
|
||||||
|
* library -- a shared library is allowed to keep undefined symbols by
|
||||||
|
* default -- and only fails once something actually calls
|
||||||
|
* trigger_undefined_symbol() below, ODR-using counter for the first
|
||||||
|
* time. See source/HotReload/README.md's "hot_reload_last_error()"
|
||||||
|
* section for the full story; HotReloadTests/Main.cpp loads this
|
||||||
|
* fixture specifically to confirm hot_reload_last_error() reports
|
||||||
|
* something useful for exactly this case, rather than leaving it a
|
||||||
|
* silent "load failed" black box.
|
||||||
|
*/
|
||||||
|
class HasUndefinedStaticMember
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static int useIt()
|
||||||
|
{
|
||||||
|
return counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static int counter; // declared, deliberately never defined anywhere
|
||||||
|
};
|
||||||
|
|
||||||
|
extern "C" int trigger_undefined_symbol()
|
||||||
|
{
|
||||||
|
return HasUndefinedStaticMember::useIt();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user