CMake: added generic FetchGithubDep.cmake, to download and cache remote external dependencies.

This commit is contained in:
cbnolok 2025-06-11 13:15:26 +02:00
parent 42a329abd6
commit 792c0d8f0b
4 changed files with 213 additions and 30 deletions

2
.gitignore vendored
View file

@ -8,7 +8,9 @@
/.ctagsd
/.codelite
/_deps
/CMakeFiles
/external
/spheresvr.dir
/bin

View file

@ -286,10 +286,6 @@ foreach(tgt ${TARGETS})
target_precompile_headers(${tgt} ${pch_list})
endforeach()
if(UNIT_TESTING)
include("tests/CMakeLists.txt") # quick and dirty
setup_tests()
endif()
# --------------------
@ -306,6 +302,11 @@ add_subdirectory(lib)
toolchain_exe_stuff() # stuff to be executed after ADD_EXECUTABLE
if(UNIT_TESTING)
include("tests/CMakeLists.txt") # quick and dirty
setup_tests()
endif()
# Get the Git revision number
message(STATUS)
message(STATUS "Retrieving git data...")

195
cmake/FetchGithubDep.cmake Normal file
View file

@ -0,0 +1,195 @@
include(FetchContent)
# ------------------------------------------------------------------
# fetch_github_library():
# - Derives the GitHub API URL for latest release
# - Caches the JSON metadata locally
# - Extracts `tag_name` from JSON (cached or freshly downloaded)
# - Invokes FetchContent to clone/update into a local cache dir
# - On offline/download failure, automatically reuses the existing cache
# ------------------------------------------------------------------
function(fetch_github_library project_name repo_url local_cache_dir)
# project_name e.g. "doctest"
# repo_url e.g. "https://github.com/doctest/doctest.git"
# local_cache_dir e.g. "${CMAKE_SOURCE_DIR}/external"
# --------------------------------------------------------------
# 1) Parse <owner> and <repo> from the provided Git URL
# --------------------------------------------------------------
string(REGEX MATCH
"github\\.com[/:]([^/]+)/([^/]+)\\.git$"
_ "${repo_url}"
)
#[[
Parsing the GitHub owner/repo from a `.git` URL
- `github\.com`
- `\.` a literal dot. (In regex, `.` normally means any character, so we escape it to match an actual `.`.)
- `[/:]`
- A character class matching exactly one of the characters inside: either a slash `/` or a colon `:`.
- This covers both URL styles:
- HTTPS: `https://github.com/owner/repo.git`
- SSH: `git@github.com:owner/repo.git`
- `([^/]+)`
- `(` `)` defines a capturing group, so whatever matches inside will be saved as CMAKE_MATCH_1.
- `[^/]` another character class, but with a `^` at the start: anything but a slash.
- `+` one or more of the preceding item.
- Together, `([^/]+)` means capture one or more characters that are not `/`. That gives us the owner.
- `/`
- Literal, not a special character. Separates owner from repo name.
- `([^/]+)`
- Same as the previous group, capturing the repo name.
- `\.git`
- Literal. Matches the suffix `.git`.
- `$`
- The end-of-string anchor. Ensures theres nothing after `.git`.
]]
if(NOT CMAKE_MATCH_COUNT EQUAL 2)
message(FATAL_ERROR
"Could not parse owner/repo from '${repo_url}'")
endif()
set(owner "${CMAKE_MATCH_1}")
set(repo "${CMAKE_MATCH_2}")
# --------------------------------------------------------------
# 2) Build the GitHub API URL for latest release
# --------------------------------------------------------------
set(api_url
"https://api.github.com/repos/${owner}/${repo}/releases/latest"
)
# --------------------------------------------------------------
# 3) Prepare cache paths (JSON + source tree)
# --------------------------------------------------------------
file(MAKE_DIRECTORY "${local_cache_dir}")
if(NOT EXISTS "${local_cache_dir}")
message(FATAL_ERROR
"Failed to create cache directory: ${local_cache_dir}")
endif()
set(json_file "${local_cache_dir}/${project_name}_latest.json")
set(source_cache_dir "${local_cache_dir}/${project_name}")
# --------------------------------------------------------------
# 4) Try to read a cached JSON tag_name
# --------------------------------------------------------------
set(lib_tag "")
set(offline FALSE)
if(EXISTS "${json_file}")
message(STATUS "Found cached JSON for ${project_name}, validating…")
file(READ "${json_file}" _data)
string(REGEX MATCH "\"tag_name\"[ \t]*:[ \t]*\"([^\"]+)\"" _ "${_data}")
#[[
Extracting the `"tag_name"` value from the JSON
- `"tag_name"`
- A literal double-quoted string, matching exactly `"tag_name"`.
- `[ \t]*`
- A character class containing a space and `\t` (tab).
- `*` means zero or more of that class.
- So this matches any amount of whitespace (spaces or tabs) after `"tag_name"`.
- `:`
- A literal colon, since in JSON the key/value separator is `:`.
- `[ \t]*`
- Again, any amount of spaces/tabs after the colon.
- `"`
- A literal opening quote for the tag value.
- `([^"]+)`
- Capturing group #1.
- `[^"]` is any character except a quote.
- `+` is one or more.
- So this grabs all characters inside the quotes (e.g. `v2.4.1`) until it hits the next `"`.
- `"`
- The closing literal quote.
]]
if(CMAKE_MATCH_COUNT EQUAL 1)
set(lib_tag "${CMAKE_MATCH_1}")
message(STATUS "✔ Using cached tag: ${lib_tag}")
else()
message(WARNING "Cached JSON is invalid. Will attempt fresh download.")
endif()
endif()
# --------------------------------------------------------------
# 5) If no valid cached tag, download the latest JSON
# --------------------------------------------------------------
if(NOT lib_tag)
message(STATUS "Downloading latest release info: ${api_url}")
file(DOWNLOAD
"${api_url}" # remote
"${json_file}" # local destination
STATUS dl_status
TIMEOUT 5
SHOW_PROGRESS
INACTIVITY_TIMEOUT 5
)
list(GET dl_status 0 code)
if(code EQUAL 0 AND EXISTS "${json_file}")
file(READ "${json_file}" _data)
string(REGEX MATCH "\"tag_name\"[ \t]*:[ \t]*\"([^\"]+)\"" _ "${_data}")
if(CMAKE_MATCH_COUNT EQUAL 1)
set(lib_tag "${CMAKE_MATCH_1}")
message(STATUS "✔ Fetched tag: ${lib_tag}")
else()
message(WARNING "Failed to parse tag_name from downloaded JSON.")
set(offline TRUE)
endif()
else()
message(WARNING "Download failed (code=${code}). Will fall back to cache if any.")
set(offline TRUE)
endif()
endif()
# --------------------------------------------------------------
# 6) Final sanity check: ensure we have a lib_tag
# --------------------------------------------------------------
if(NOT lib_tag)
message(FATAL_ERROR
"Could not determine a valid tag for ${project_name}. Aborting.")
endif()
# --------------------------------------------------------------
# 7) Declare FetchContent variables *before* we call it
#
# NEW: In offline mode we must prevent both git pull and the
# very first git clone. The trick is to:
#
# a) Point CMake at the already-downloaded directory via
# FETCHCONTENT_SOURCE_DIR_<NAME>
# b) Disable updates via FETCHCONTENT_UPDATES_DISCONNECTED_<NAME>
#
# These must be set *before* FetchContent_MakeAvailable().
# --------------------------------------------------------------
string(TOUPPER "${project_name}" UC_NAME) # doctest DOCTEST
if(offline AND EXISTS "${source_cache_dir}/CMakeLists.txt")
# ----------------------------------------------------------
# We *have* source on disk and cannot reach the network
# pretend the project is already populated.
# ----------------------------------------------------------
set(FETCHCONTENT_SOURCE_DIR_${UC_NAME} "${source_cache_dir}")
set(FETCHCONTENT_UPDATES_DISCONNECTED_${UC_NAME} ON)
message(STATUS "Offline mode → reusing existing source at ${source_cache_dir}")
else()
message(STATUS "Online mode → CMake may clone or update ${project_name}")
endif()
# --------------------------------------------------------------
# 8) Formal declaration (still points at the cache dir so both
# online and offline modes end up in the same place).
# --------------------------------------------------------------
FetchContent_Declare(
${project_name}
GIT_REPOSITORY ${repo_url}
GIT_TAG ${lib_tag}
SOURCE_DIR "${source_cache_dir}"
)
# --------------------------------------------------------------
# 9) Bring the dependency into the build
# --------------------------------------------------------------
FetchContent_MakeAvailable(${project_name})
endfunction()

View file

@ -1,37 +1,15 @@
#set(FETCHCONTENT_BASE_DIR
# "${CMAKE_SOURCE_DIR}/third_party"
#)
include(FetchContent)
message(STATUS "")
message(STATUS "Unit Tests requested")
message(STATUS "Fetching DocTest")
# Download the JSON for the latest release
set(DOCTEST_RELEASE_API
"https://api.github.com/repos/doctest/doctest/releases/latest"
)
include("cmake/FetchGithubDep.cmake")
set(EXTERNAL_CACHE_DIR "${CMAKE_SOURCE_DIR}/external")
file(DOWNLOAD
${DOCTEST_RELEASE_API}
${CMAKE_BINARY_DIR}/doctest_latest.json
SHOW_PROGRESS
)
# Extract "tag_name" via regex
file(READ "${CMAKE_BINARY_DIR}/doctest_latest.json" _json)
string(REGEX MATCH "\"tag_name\"[ \t]*:[ \t]*\"([^\"]+)\"" _
"${_json}")
set(DOCTEST_TAG "${CMAKE_MATCH_1}")
message(STATUS "Using doctest tag: ${DOCTEST_TAG}")
FetchContent_Declare(
doctest
GIT_REPOSITORY https://github.com/doctest/doctest.git
GIT_TAG ${DOCTEST_TAG}
)
FetchContent_MakeAvailable(doctest)
# Fetch Doctest
fetch_github_library(doctest "https://github.com/doctest/doctest.git" "${EXTERNAL_CACHE_DIR}")
# Enable testing
enable_testing()
@ -40,6 +18,12 @@ function(setup_tests)
foreach(tgt ${TARGETS})
target_link_libraries(${tgt} PRIVATE doctest::doctest)
target_compile_definitions(${tgt} PRIVATE UNIT_TESTING)
# Doctest for some reason uses NaN, which is disabled by -ffast-math
if("${CXX_COMPILER_ID}" STREQUAL "MSVC")
target_compile_options(${tgt} PRIVATE /fp:precise)
else()
target_compile_options(${tgt} PRIVATE -fno-finite-math-only)
endif()
#[[
add_custom_target(run_tests
@ -54,3 +38,4 @@ function(setup_tests)
COMMAND ${tgt})
endforeach()
endfunction()