diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a7b303..67583a1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,8 +28,14 @@ add_library(plot_core src/input/controller.cpp ) add_library(plot::core ALIAS plot_core) +# What the target is called in an installed tree. Without this the export set is +# namespaced by the target's own name (plot::plot_core), while the build tree -- +# and every consumer, and this project's own comments -- says plot::core. +set_target_properties(plot_core PROPERTIES EXPORT_NAME core) -target_include_directories(plot_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_include_directories(plot_core PUBLIC + $ + $) target_compile_features(plot_core PUBLIC cxx_std_20) target_link_libraries(plot_core PRIVATE ${CMAKE_DL_LIBS}) plot_apply_warnings(plot_core) @@ -79,3 +85,49 @@ if(PLOT_BUILD_TESTS) enable_testing() add_subdirectory(tests) endif() + +# Install and export. The core always goes in; each adapter joins the set only if +# its toolkit can be re-found on a consumer's machine, which the adapter decided +# when it was added (plot_record_adapter_export() -- the decisions and the reasons +# are in the configure log above). plotConfig.cmake then re-finds exactly those +# toolkits before including the targets file the adapters are described in, so +# find_package(plot) + target_link_libraries(consumer PRIVATE plot::core) works +# from an installed prefix with no -I or -L of the consumer's own. +if(PLOT_INSTALL) + # The adapters that could be promised to a consumer, in the order they were + # added, and the find_dependency() calls that re-create their toolkits there. + get_property(PLOT_ADAPTER_EXPORT_TARGETS GLOBAL PROPERTY PLOT_ADAPTER_EXPORT_TARGETS) + # Substituted into plotConfig.cmake below (@PLOT_ADAPTER_DEPENDENCIES@). + get_property(PLOT_ADAPTER_DEPENDENCIES GLOBAL PROPERTY PLOT_ADAPTER_DEPENDENCIES) + + include(CMakePackageConfigHelpers) + + # No INCLUDES DESTINATION here: every target already declares + # $ in its own target_include_directories(), and + # asking for it twice puts the same directory in the export twice. + install(TARGETS plot_core ${PLOT_ADAPTER_EXPORT_TARGETS} + EXPORT plotTargets + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin) + install(DIRECTORY include/plot DESTINATION include) + + install(EXPORT plotTargets + NAMESPACE plot:: + FILE plotTargets.cmake + DESTINATION lib/cmake/plot) + + configure_package_config_file( + cmake/plotConfig.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/plotConfig.cmake" + INSTALL_DESTINATION lib/cmake/plot) + write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion) + + install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/plotConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/plotConfigVersion.cmake" + DESTINATION lib/cmake/plot) +endif() diff --git a/CMakePresets.json b/CMakePresets.json index 3803362..7f771fa 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -33,6 +33,20 @@ "PLOT_BUILD_QT6": true, "PLOT_BUILD_GTK4": true } + }, + { + "name": "linux-asan", + "displayName": "Linux Debug (AddressSanitizer)", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/asan", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "PLOT_SANITIZE": "address", + "PLOT_BUILD_GLFW": true, + "PLOT_BUILD_IMGUI": true, + "PLOT_BUILD_QT6": true, + "PLOT_BUILD_GTK4": true + } } ], "buildPresets": [ @@ -43,6 +57,10 @@ { "name": "linux-release", "configurePreset": "linux-release" + }, + { + "name": "linux-asan", + "configurePreset": "linux-asan" } ] } diff --git a/adapters/glfw/CMakeLists.txt b/adapters/glfw/CMakeLists.txt index b7f3790..73c2106 100644 --- a/adapters/glfw/CMakeLists.txt +++ b/adapters/glfw/CMakeLists.txt @@ -8,10 +8,36 @@ plot_find_glfw(plot_glfw_dep) add_library(plot_glfw STATIC glfw_host.cpp) add_library(plot::glfw ALIAS plot_glfw) +# The installed tree names it plot::glfw, like the build tree (see plot_core's). +set_target_properties(plot_glfw PROPERTIES EXPORT_NAME glfw) + # The public include root is `adapters/`, so the header is reached as # -- the toolkit's name first, like every other adapter's. -target_include_directories(plot_glfw PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/..") +# The build interface is that root; the install one is the prefix's `include`, +# where glfw_host.hpp is installed as , the same spelling. +target_include_directories(plot_glfw PUBLIC + $ + $) + # GLFW stays PRIVATE: the header forward-declares GLFWwindow and includes no GLFW # header, so a consumer of plot::glfw does not inherit the toolkit. target_link_libraries(plot_glfw PUBLIC plot::core PRIVATE ${plot_glfw_dep}) plot_apply_warnings(plot_glfw) + +# PLOT_GLFW_DEP is either a target find_package()/pkg-config created or one +# FetchContent built. The first two can be promised to a consumer -- the installed +# config file re-runs the very same discovery -- but a fetched GLFW cannot: there +# is no glfw3Config.cmake on the consumer's machine to find, so exporting this +# adapter would hand out a target its link interface cannot satisfy. In that case +# the adapter is not installed at all. +plot_glfw_dependency_snippet(plot_glfw_dependency) +if(PLOT_GLFW_DEP_ORIGIN STREQUAL "FetchContent") + plot_record_adapter_export(plot_glfw FALSE + "GLFW was fetched with FetchContent, so a consumer's find_package(plot) cannot re-find it" "") +else() + plot_record_adapter_export(plot_glfw TRUE + "GLFW was resolved by ${PLOT_GLFW_DEP_ORIGIN}" "${plot_glfw_dependency}") + if(PLOT_INSTALL) + install(FILES glfw_host.hpp DESTINATION include/glfw) + endif() +endif() diff --git a/adapters/gtk4/CMakeLists.txt b/adapters/gtk4/CMakeLists.txt index 8f19e9d..81344a9 100644 --- a/adapters/gtk4/CMakeLists.txt +++ b/adapters/gtk4/CMakeLists.txt @@ -22,13 +22,33 @@ endif() add_library(plot_gtk4 STATIC gtk_plot_area.cpp gtk_plot_area.hpp gtk_input.hpp) add_library(plot::gtk4 ALIAS plot_gtk4) +# The installed tree names it plot::gtk4, like the build tree (see plot_core's). +set_target_properties(plot_gtk4 PROPERTIES EXPORT_NAME gtk4) + # The public include root is `adapters/`, so the headers are reached as # and -- the toolkit's name first, -# like every other adapter's. -target_include_directories(plot_gtk4 PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/..") +# like every other adapter's. The build interface is that root; the install one is +# the prefix's `include`, where both are installed under `gtk4/`, the same +# spelling. +target_include_directories(plot_gtk4 PUBLIC + $ + $) # GTK is PUBLIC, unlike GLFW and ImGui: the adapter's factory hands out a # GtkWidget, so a consumer that embeds the area needs GTK's headers and libraries # to put it anywhere -- the widget *is* the toolkit's object -- and GTK's include # directory is not one the compiler searches by default. target_link_libraries(plot_gtk4 PUBLIC plot::core PkgConfig::GTK4_PC) plot_apply_warnings(plot_gtk4) + +# GTK came from pkg-config, which the installed config file can re-run: the +# snippet re-creates the exact PkgConfig::GTK4_PC target the adapter's public link +# interface names, so a consumer that links plot::gtk4 gets GTK's own include +# directories and libraries the same way this build did. +plot_record_adapter_export(plot_gtk4 TRUE "GTK4 was resolved by pkg-config" + "find_dependency(PkgConfig)\npkg_check_modules(GTK4_PC REQUIRED IMPORTED_TARGET gtk4)\n") +if(PLOT_INSTALL) + # gtk_input.hpp is installed with the public header: the demo and the test suite + # reach it as , and it is a toolkit-free header a consumer + # can use without GTK. + install(FILES gtk_plot_area.hpp gtk_input.hpp DESTINATION include/gtk4) +endif() diff --git a/adapters/imgui/CMakeLists.txt b/adapters/imgui/CMakeLists.txt index fd5a496..128b4cb 100644 --- a/adapters/imgui/CMakeLists.txt +++ b/adapters/imgui/CMakeLists.txt @@ -52,3 +52,12 @@ target_include_directories(plot_imgui PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/..") # consumer of plot::imgui does not inherit the toolkit's headers. target_link_libraries(plot_imgui PUBLIC plot::core PRIVATE imgui ${plot_imgui_glfw_dep}) plot_apply_warnings(plot_imgui) + +# Not installed and not exported, and not because of a discovery fallback: ImGui +# is *always* fetched here, so there is no ImGui package for an installed config +# file to find. Exporting plot::imgui would name the fetched `imgui` target in its +# link interface and leave every consumer of `find_package(plot)` unable to +# configure -- so plot::imgui is a build-tree target only, and a consumer that +# wants it adds the adapter's source or fetches the same pinned tag itself. +plot_record_adapter_export(plot_imgui FALSE + "ImGui is supplied by FetchContent, so find_package(plot) cannot promise it to a consumer" "") diff --git a/adapters/qt6/CMakeLists.txt b/adapters/qt6/CMakeLists.txt index 8c93951..6db47ec 100644 --- a/adapters/qt6/CMakeLists.txt +++ b/adapters/qt6/CMakeLists.txt @@ -19,9 +19,19 @@ endif() add_library(plot_qt6 STATIC qt_plot_widget.cpp qt_plot_widget.hpp) add_library(plot::qt6 ALIAS plot_qt6) +# The installed tree names it plot::qt6, like the build tree (see plot_core's). +set_target_properties(plot_qt6 PROPERTIES EXPORT_NAME qt6) + # The public include root is `adapters/`, so the header is reached as # -- the toolkit's name first, like every other adapter's. -target_include_directories(plot_qt6 PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/..") +# The build interface is that root; the install one is the prefix's `include`, +# where both headers are installed as -- the same spelling. The pointer +# anchor in qt_pointer.hpp is internal API, but it still ships: the widget's header +# includes it, so a consumer that gets one without the other does not compile. +target_include_directories(plot_qt6 PUBLIC + $ + $) + # Qt is PUBLIC, unlike GLFW and ImGui: the widget's header derives from # QOpenGLWidget, so a consumer needs Qt's headers and libraries to use it at all. target_link_libraries(plot_qt6 PUBLIC @@ -35,3 +45,12 @@ target_link_libraries(plot_qt6 PUBLIC # Qt class in it, and AUTOMOC scans every source of a target it is set on. set_target_properties(plot_qt6 PROPERTIES AUTOMOC ON) plot_apply_warnings(plot_qt6) + +# Qt is a system SDK found with find_package() -- a missing Qt6 is a configure +# error above, never a fetch -- so the installed config file can re-find it with +# the same components and the widget's public Qt link interface is satisfiable. +plot_record_adapter_export(plot_qt6 TRUE "Qt6 was resolved by find_package" + "find_dependency(Qt6 COMPONENTS Core Gui Widgets OpenGLWidgets)\n") +if(PLOT_INSTALL) + install(FILES qt_plot_widget.hpp qt_pointer.hpp DESTINATION include/qt6) +endif() diff --git a/cmake/plotConfig.cmake.in b/cmake/plotConfig.cmake.in new file mode 100644 index 0000000..5fc46de --- /dev/null +++ b/cmake/plotConfig.cmake.in @@ -0,0 +1,23 @@ +@PACKAGE_INIT@ + +# Package config for `plot`. Include it with +# +# find_package(plot 0.1 REQUIRED) +# target_link_libraries(your_target PRIVATE plot::core) +# +# `plot::core` is the library, and carries no third-party dependency at all. The +# adapter targets (`plot::glfw`, `plot::qt6`, `plot::gtk4`) are here only when +# their toolkit could be promised to a consumer: an adapter whose toolkit the +# build fetched with FetchContent is deliberately not installed, because this +# file has nothing to point a consumer at -- see the build's configure log for +# which adapters were exported and which were skipped, and why. +# +# Those toolkits are re-found below, before plotTargets.cmake is included: the +# adapter targets name them in their own link interfaces, so the imported targets +# have to exist first. + +include(CMakeFindDependencyMacro) +@PLOT_ADAPTER_DEPENDENCIES@ +include("${CMAKE_CURRENT_LIST_DIR}/plotTargets.cmake") + +check_required_components(plot) diff --git a/cmake/plot_asan_suppressions.txt b/cmake/plot_asan_suppressions.txt new file mode 100644 index 0000000..439cf78 --- /dev/null +++ b/cmake/plot_asan_suppressions.txt @@ -0,0 +1,22 @@ +# LeakSanitizer suppressions for the `linux-asan` build (PLOT_SANITIZE=address). +# Pointed at by tests/CMakeLists.txt, which sets ASAN_OPTIONS on the test itself +# so `ctest --test-dir build/asan` gets it however it is launched. +# +# The boundary this file exists to hold: a sanitizer report from *our* code is a +# finding and must fail the run, so nothing here may ever suppress a frame under +# include/plot/ or src/. Only the GL driver may be named -- an EGL/GL stack +# (Mesa's libEGL, libgallium, libglapi, radeonsi's ACO, or the LLVM JIT behind +# llvmpipe) is a system library this project does not build and cannot fix, and +# it is loaded only by the headless EGL context the GL tests create. +# +# It is currently empty of entries on purpose. The asan suite runs clean against +# the installed driver -- 127 cases, no report of any origin (the leak checker was +# confirmed live with a deliberate 1 KB leak, which does report and exit 1) -- and +# a suppression nobody has needed is a suppression nobody can justify. If a driver +# report does appear, add the narrowest entry that names the library, e.g. +# +# leak:libEGL.so +# leak:libgallium* +# +# and leave the count in the commit message. The shape is `leak:`; ASan takes the same list through ASAN_OPTIONS. diff --git a/cmake/plot_options.cmake b/cmake/plot_options.cmake index 25e951b..c83d29d 100644 --- a/cmake/plot_options.cmake +++ b/cmake/plot_options.cmake @@ -16,6 +16,62 @@ option(PLOT_BUILD_GLFW "Build the GLFW adapter and demo" OFF) option(PLOT_BUILD_IMGUI "Build the Dear ImGui adapter and demo" OFF) option(PLOT_BUILD_QT6 "Build the Qt6 adapter and demo" OFF) option(PLOT_BUILD_GTK4 "Build the GTK4 adapter and demo" OFF) +option(PLOT_INSTALL "Generate install and export rules (find_package(plot))" ON) +set(PLOT_SANITIZE "" CACHE STRING + "Sanitizers to build with, as the list a -fsanitize= flag takes (e.g. address); empty for none") + +# Sanitizer flags go on every target in this project, fetched third-party sources +# included: a partially instrumented build reports as loudly about the +# instrumentation gap as about any real bug. The flags are one -fsanitize= list +# (PLOT_SANITIZE) rather than a set of per-sanitizer options so the preset, the +# tests' runtime environment and this file cannot disagree about what is on. +if(PLOT_SANITIZE) + add_compile_options(-fsanitize=${PLOT_SANITIZE} -fno-omit-frame-pointer) + add_link_options(-fsanitize=${PLOT_SANITIZE}) +endif() + +# Read by tests/CMakeLists.txt, which points ASAN_OPTIONS at this project's +# suppressions file when the address sanitizer is on. +string(REPLACE "," ";" PLOT_SANITIZE_LIST "${PLOT_SANITIZE}") +if("address" IN_LIST PLOT_SANITIZE_LIST) + set(PLOT_ASAN_ENABLED TRUE) +endif() + +# --- install / export bookkeeping -------------------------------------------- +# +# An adapter can only be installed and exported when its toolkit can be found +# again on the consumer's machine. A dependency resolved by find_package() or +# pkg-config can be: the installed config file re-finds it with the very same +# call. A dependency that FetchContent supplied cannot -- there is nothing on the +# consumer's machine to point at, and promising it would make every consumer +# clone and build that toolkit at the same revision -- so those adapters stay out +# of the export set and the configure log says why (see each adapter's +# CMakeLists.txt for its own resolution order). +# +# The results are global properties, because the adapters decide inside their own +# subdirectory while the install rules run at the top level. A property rather +# than a cache entry: a cache value cannot hold the newlines a snippet of +# find_dependency() calls needs, and nothing here has to survive the configure +# pass that produced it. + +# Records one adapter's install/export decision and prints it. `dependency` is +# the config-file snippet that re-creates the toolkit's imported target before +# plotTargets.cmake is included; it is empty when the target is not exported. +function(plot_record_adapter_export target exportable reason dependency) + if(NOT exportable) + message(STATUS "plot: not exporting ${target}: ${reason}") + elseif(NOT PLOT_INSTALL) + message(STATUS "plot: not exporting ${target}: PLOT_INSTALL is OFF") + else() + set_property(GLOBAL APPEND PROPERTY PLOT_ADAPTER_EXPORT_TARGETS "${target}") + # Read-modify-write, not APPEND: the snippets are one block of C++-style + # lines that must be substituted into one file, and a property read back is a + # list -- APPEND would join them with the separator ';' instead of a newline. + get_property(plot_dependencies GLOBAL PROPERTY PLOT_ADAPTER_DEPENDENCIES) + set_property(GLOBAL PROPERTY PLOT_ADAPTER_DEPENDENCIES "${plot_dependencies}${dependency}") + message(STATUS "plot: exporting ${target}: ${reason}") + endif() +endfunction() # Apply the project warning set to a first-party target. Warnings are never # suppressed per file. @@ -38,15 +94,22 @@ endfunction() # GLFW (the last resort, because it needs the network). The result is cached in # PLOT_GLFW_DEP, so a build with several adapters enabled discovers -- and at # worst fetches -- exactly one copy. Sets to the target to link. +# +# PLOT_GLFW_DEP_ORIGIN records which of the three supplied it, because only the +# first two can be promised to a consumer of an installed adapter (see +# plot_record_adapter_export()). function(plot_find_glfw out_var) if(NOT DEFINED PLOT_GLFW_DEP) set(plot_glfw_dep "") + set(plot_glfw_origin "") find_package(glfw3 3.3 QUIET) if(TARGET glfw) set(plot_glfw_dep glfw) + set(plot_glfw_origin find_package) elseif(TARGET glfw3::glfw3) set(plot_glfw_dep glfw3::glfw3) + set(plot_glfw_origin find_package) endif() if(NOT plot_glfw_dep) @@ -55,6 +118,7 @@ function(plot_find_glfw out_var) pkg_check_modules(GLFW_PC QUIET IMPORTED_TARGET glfw3) if(TARGET PkgConfig::GLFW_PC) set(plot_glfw_dep PkgConfig::GLFW_PC) + set(plot_glfw_origin pkg-config) endif() endif() endif() @@ -72,12 +136,29 @@ function(plot_find_glfw out_var) GIT_SHALLOW TRUE) FetchContent_MakeAvailable(glfw) set(plot_glfw_dep glfw) + set(plot_glfw_origin FetchContent) endif() set(PLOT_GLFW_DEP "${plot_glfw_dep}" CACHE INTERNAL "GLFW target the plot adapters link against") + set(PLOT_GLFW_DEP_ORIGIN "${plot_glfw_origin}" CACHE INTERNAL + "How plot resolved GLFW: find_package, pkg-config or FetchContent") message(STATUS "plot: GLFW adapter dependency: ${plot_glfw_dep}") endif() set(${out_var} "${PLOT_GLFW_DEP}" PARENT_SCOPE) endfunction() + +# The find_dependency() snippet that re-creates an exported adapter's toolkit +# target on a consumer's machine, for the toolkits a config file can re-find. +# Adapters call these when they record their export decision; the snippets are +# substituted into cmake/plotConfig.cmake.in. +function(plot_glfw_dependency_snippet out_var) + if(PLOT_GLFW_DEP_ORIGIN STREQUAL "find_package") + set(${out_var} "find_dependency(glfw3 3.3)\n" PARENT_SCOPE) + else() + set(${out_var} + "find_dependency(PkgConfig)\npkg_check_modules(GLFW_PC REQUIRED IMPORTED_TARGET glfw3)\n" + PARENT_SCOPE) + endif() +endfunction() diff --git a/include/plot/plot.hpp b/include/plot/plot.hpp index 9d0dba4..f4a268e 100644 --- a/include/plot/plot.hpp +++ b/include/plot/plot.hpp @@ -1,7 +1,18 @@ #pragma once -/// Umbrella header: pulls in every public component of the plot library. -/// Later layers (gl, data, render, plot, input) are added here as they land. +/// Umbrella header: pulls in every public component of the plot library, in +/// layer order (`core/math -> gl -> data -> render -> plot -> input`), so a +/// translation unit can include this one header and use the whole surface. +/// +/// It deliberately stops at the library: the adapters under `adapters/` are +/// separate targets -- each one drags in GLFW, Dear ImGui, Qt6 or GTK4 -- and +/// pulling any of them in here would destroy the core's zero-dependency +/// property, which is the point of `plot_core`. +/// +/// The macro-gated instrumentation under `*/detail/` is not here either: those +/// headers exist for the test suite's counters, define nothing without their +/// PLOT_*_COUNT/PLOT_RING_STATS macro, and are documented as not part of the +/// public surface. #include #include @@ -12,3 +23,36 @@ #include #include #include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9db3f48..581559c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -73,3 +73,15 @@ target_compile_definitions(plot_tests PRIVATE PLOT_PYRAMID_COUNT_COVERS plot_apply_warnings(plot_tests) add_test(NAME plot_tests COMMAND plot_tests) + +# The GL tests create a real EGL context, and llvmpipe's teardown leaks driver +# allocations that are not this library's. The suppressions file names those +# libraries and nothing else (see cmake/plot_asan_suppressions.txt); a report from +# anything under include/plot/ or src/ is still an error. The environment is set +# on the test rather than in a preset so `ctest --test-dir build/asan` gets it +# however it is launched, and print_suppressions makes the counts visible -- a run +# must show what it suppressed, not hide it. +if(PLOT_ASAN_ENABLED) + set_tests_properties(plot_tests PROPERTIES ENVIRONMENT + "ASAN_OPTIONS=detect_leaks=1:print_suppressions=1:suppressions=${CMAKE_SOURCE_DIR}/cmake/plot_asan_suppressions.txt") +endif()