diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2a346b..6b61e62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,59 @@ jobs: - name: Run nested static and shared installation round trips run: cmake -P tests/install/test_nested_install.cmake + clangd: + name: clangd (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + cxx: g++ + compiler_id: GNU + - os: windows-latest + cxx: cl + compiler_id: MSVC + - os: macos-latest + cxx: clang++ + compiler_id: AppleClang + runs-on: ${{ matrix.os }} + env: + CXX: ${{ matrix.cxx }} + steps: + - uses: actions/checkout@v7 + - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 + if: runner.os == 'Windows' + - name: Locate Windows clangd + if: runner.os == 'Windows' + shell: pwsh + run: | + $llvm = Join-Path $env:ProgramFiles 'LLVM\bin' + if (-not (Test-Path (Join-Path $llvm 'clangd.exe'))) { + choco install llvm --yes --no-progress + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + $llvm | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Ensure Linux clangd + if: runner.os == 'Linux' + run: | + if ! command -v clangd >/dev/null; then + sudo apt-get update -qq + sudo apt-get install -y clangd + fi + - name: Ensure macOS clangd + if: runner.os == 'macOS' + run: | + if ! command -v clangd >/dev/null; then + brew install llvm + echo "$(brew --prefix llvm)/bin" >> "$GITHUB_PATH" + fi + - name: Check configure-only header commands and fresh clangd navigation + run: cmake -DCPP_LIBRARY_TEST_CLANGD=ON -DCPP_LIBRARY_TEST_COMPILER_ID=${{ matrix.compiler_id }} -P tests/setup/test_clangd.cmake + - name: Check existing setup behavior + run: cmake -P tests/setup/test_target_type.cmake + - name: Check existing package behavior + run: cmake -P tests/install/test_nested_install.cmake + integration-tests: name: Integration Tests runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 51768c7..f5ab94b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .worktrees/ .claude/worktrees/ .tokensave +build/ diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 2179b51..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "version": "2.0.0", - "inputs": [ - { "id": "worktreeName", "type": "promptString", "description": "Worktree name (e.g. my-feature) — branch will be worktree-" } - ], - "tasks": [ - { - "label": "worktree: create", - "type": "shell", - "command": "git worktree add \".claude/worktrees/${input:worktreeName}\" -b \"worktree-${input:worktreeName}\" && (command -v tokensave >/dev/null 2>&1 && tokensave init \".claude/worktrees/${input:worktreeName}\" && tokensave branch add \"worktree-${input:worktreeName}\" --path \".claude/worktrees/${input:worktreeName}\" || true) && code --new-window \".claude/worktrees/${input:worktreeName}\"", - "options": { - "cwd": "${workspaceFolder}" - }, - "windows": { - "command": "git worktree add \".claude/worktrees/${input:worktreeName}\" -b \"worktree-${input:worktreeName}\" && (where tokensave >nul 2>&1 && (tokensave init \".claude/worktrees/${input:worktreeName}\" && tokensave branch add \"worktree-${input:worktreeName}\" --path \".claude/worktrees/${input:worktreeName}\") || ver>nul) && code --new-window \".claude/worktrees/${input:worktreeName}\"", - "options": { - "shell": { - "executable": "cmd.exe", - "args": ["/d", "/c"] - } - } - }, - "problemMatcher": [], - "presentation": { - "reveal": "always", - "panel": "dedicated" - }, - "detail": "Prompts for a name, creates a git worktree at .claude/worktrees/ on branch worktree-, best-effort syncs it with tokensave if installed, and opens it in a new window." - }, - { - "label": "worktree: remove", - "type": "shell", - "command": "git worktree remove \".claude/worktrees/${input:worktreeName}\" && (command -v tokensave >/dev/null 2>&1 && tokensave branch gc || true)", - "options": { - "cwd": "${workspaceFolder}" - }, - "windows": { - "command": "git worktree remove \".claude/worktrees/${input:worktreeName}\" && (where tokensave >nul 2>&1 && tokensave branch gc || ver>nul)", - "options": { - "shell": { - "executable": "cmd.exe", - "args": ["/d", "/c"] - } - } - }, - "problemMatcher": [], - "presentation": { - "reveal": "always", - "panel": "dedicated" - }, - "detail": "Prompts for a name, removes the git worktree at .claude/worktrees/, and best-effort garbage-collects tokensave's branch tracking if installed." - } - ] -} diff --git a/README.md b/README.md index d72e1bf..bd0ba38 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,47 @@ future toolkit-template changes. Prefer the shared template with only the project-specific deltas above. Documentation is configured only for top-level projects with `BUILD_DOCS` enabled. +The shared template uses `templates/DoxygenLayout.xml` for reference-first +navigation: grouped APIs appear under **Reference**, with namespace, class, file, +and other lookup pages under **Index**. Group descriptions precede member lists, +and group briefs remain visible even with `REPEAT_BRIEF = NO`. The compact +version-2 layout inherits unspecified page sections from Doxygen, avoiding a +version-specific copy of its entire default layout. Documentation generation +therefore requires **Doxygen 1.13.1 or newer**; non-documentation builds do not +require Doxygen. + +`WARN_LAYOUT_FILE = NO` suppresses warnings about intentionally inherited layout +entries. It also suppresses other layout diagnostics, including malformed XML; +normal documentation builds therefore do not validate layout edits. Other +documentation warnings remain errors. + +**Whenever the layout is updated**, temporarily enable layout warnings and review +all of them. Use these review-only overrides to see the complete warning list +rather than stopping at the first inherited-entry warning: + +```cmake +DOCS_OPTIONS + "WARN_LAYOUT_FILE = YES" + "WARN_AS_ERROR = NO" +``` + +Reconfigure and build the `docs` target. Accept missing-entry warnings only for +sections intentionally inherited from Doxygen's defaults; fix malformed XML, +unknown entries, and other unexpected diagnostics. Then remove the review-only +overrides to restore `WARN_LAYOUT_FILE = NO` and `WARN_AS_ERROR = YES`. + +The layout stays in the toolkit rather than being copied into each project. +Override it through the existing options when needed (quote paths containing +spaces): + +```cmake +DOCS_OPTIONS + "LAYOUT_FILE = \"${CMAKE_CURRENT_SOURCE_DIR}/docs/doxygen/DoxygenLayout.xml\"" +``` + +A project's custom `docs/Doxyfile` retains its own layout configuration; the shared +layout is not appended to or imposed on that override. + ### `cpp_library_copy_runtime_dlls` ```cmake @@ -686,6 +727,91 @@ The dependency provider (CMake 3.24+) tracks `find_package()` and `CPMAddPackage **Solution**: This is a known clang-tidy issue ([CMake #22979](https://gitlab.kitware.com/cmake/cmake/-/issues/22979)) where clang-tidy doesn't properly recognize MSVC's `/EHsc` exception handling flag. cpp-library automatically detects this scenario and adds `--extra-arg=/EHsc` to `CMAKE_CXX_CLANG_TIDY` when both MSVC and clang-tidy are enabled. This workaround is applied transparently and only on MSVC platforms. +## clangd and VSCode + +With `CMAKE_EXPORT_COMPILE_COMMANDS=ON` (already enabled by the supplied Ninja +presets), `cpp_library_setup()` registers each public header with an exact C++ +consumer command in CMake's ordinary `compile_commands.json`. This also works for +header-only and nested dependency libraries without tests or implementation +sources. Commands use the owner's public/transitive usage requirements, including +requirements added after setup; private implementation flags and DLL-export +definitions are not copied. Dependencies never consume their downstream owners. + +Indexing OBJECT targets are excluded from normal builds and are not installed or +exported. They do not change the production target's sources or public-header +FILE_SET. No database postprocessor or configure-time clangd dependency is needed. +Set `CPP_LIBRARY_ENABLE_CLANGD=OFF` to opt out. With database export disabled, +registration still validates ownership but creates no indexing targets or warnings. + +For manually created local INTERFACE, STATIC or SHARED libraries, register explicit +absolute paths (a local alias is also accepted): + +```cmake +cpp_library_add_clangd_headers( + TARGET my_library + HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/include/my_library/api.hpp" +) +``` + +Repeated registration with one owner is idempotent; additional headers extend its +registration. Registering a header with two different owners fails with both names +and the path, rather than leaving clangd to choose an arbitrary command. Imported +targets and generator expressions in header paths are not supported. Explicit +generated headers can be registered before they exist, provided they are declared +`GENERATED`; headers needed immediately by the editor must actually be generated +during configure (for example with `configure_file()`). Registering a build-time +header does not run its generator or add default-build prerequisites. + +### Editor setup and migration + +Install CMake 3.24+, Ninja, the selected compiler/SDK, clangd, VSCode CMake Tools, +and the clangd extension. Dependencies must be available and configuration must +succeed. Select a configure preset with **CMake: Select Configure Preset**, then +run **CMake: Configure**. No build or previously opened includer is required: +restart clangd if necessary and open a registered public header first. + +New projects receive `.vscode/settings.json` with: + +```json +{ + "cmake.useCMakePresets": "always", + "cmake.configureOnOpen": true, + "cmake.copyCompileCommands": "${workspaceFolder}/compile_commands.json" +} +``` + +CMake Tools copies the selected build tree's unmodified database to the workspace +root after successful configuration. Selecting another preset and configuring +refreshes that copy. No `cmake.postConfigureTask` is added, and clangd is not pinned +to any preset directory. CLI-only `cmake --preset ...` configuration generates the +build-tree database but does **not** invoke CMake Tools' root-copy action. + +Ordinary configuration preserves an existing settings file byte-for-byte, including +custom copy destinations, post-configure hooks, and unrelated settings. For an +existing workspace, add the three keys above manually, reconciling your current +copy destination/hook rather than adding a competing database writer. The `init` +preset / `CPP_LIBRARY_FORCE_INIT=ON` intentionally regenerates templates, **including +settings.json**; back up custom settings before using it. + +### Supported boundaries + +Use a single-configuration Ninja or Makefile generator. Visual Studio and Xcode +generators are not supported for database export. Active multi-configuration +generators (including Ninja Multi-Config) are rejected: their duplicate per-header +configuration entries do not select clangd's active variant. Use single-config +Ninja or disable this feature instead. + +Normal configuration-dependent usage requirements are evaluated by CMake at +generation time. Target-context-sensitive expressions evaluate in the private +indexing **consumer** context, not the producer's: custom consumer properties are +not automatically copied from the library. This is not a compilation-database +merger or a standalone-header validation build. + +The focused CI regression covers Windows/MSVC, Linux/GCC and macOS/Apple Clang. +Local evidence from this change is Windows/MSVC; the other platforms require their +CI runs. Protocol checks use fresh clangd processes and require Python only for the +test harness, never for consuming projects. + ## Development To update cpp-library dependencies search for `[DEPENDENCY]`. Embed the marker in an HTML @@ -715,9 +841,15 @@ cmake -P tests/install/CMakeLists.txt cmake -P tests/install/test_provider_merge.cmake cmake -P tests/install/test_nested_install.cmake cmake -P tests/setup/test_target_type.cmake +cmake -P tests/setup/test_clangd.cmake cmake -P tests/setup/test_setup_version_resolution.cmake ``` +To require fresh-clangd protocol checks as CI does, run +`cmake -DCPP_LIBRARY_TEST_CLANGD=ON -P tests/setup/test_clangd.cmake`. +This requires clangd on `PATH` and a Python 3 interpreter; otherwise the default +script reports unavailable protocol checks as skipped. + The nested-install regression configures, builds, installs, and runs a downstream consumer, checks independent and legacy installation controls, and verifies package-local dependency validation. The consumer links only the parent target and calls its API, which requires a compiled static diff --git a/cmake/clangd/CMakeLists.txt b/cmake/clangd/CMakeLists.txt new file mode 100644 index 0000000..1e9346c --- /dev/null +++ b/cmake/clangd/CMakeLists.txt @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: BSL-1.0 +# A separate directory keeps header language overrides off production targets. +cmake_minimum_required(VERSION 3.24) +set_source_files_properties(${headers} PROPERTIES LANGUAGE CXX HEADER_FILE_ONLY FALSE) +add_library("${index_target}" OBJECT EXCLUDE_FROM_ALL ${headers}) +set_target_properties("${index_target}" PROPERTIES + UNITY_BUILD OFF + EXPORT_COMPILE_COMMANDS ON) +target_link_libraries("${index_target}" PRIVATE "${owner}") diff --git a/cmake/cpp-library-clangd.cmake b/cmake/cpp-library-clangd.cmake new file mode 100644 index 0000000..0b3bdc3 --- /dev/null +++ b/cmake/cpp-library-clangd.cmake @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: BSL-1.0 +include_guard(GLOBAL) + +option(CPP_LIBRARY_ENABLE_CLANGD "Generate public-header consumer commands for clangd" ON) + +# Register absolute public header paths, including declared generated headers. +function(cpp_library_add_clangd_headers) + cmake_parse_arguments(PARSE_ARGV 0 ARG "" "TARGET" "HEADERS") + if(ARG_UNPARSED_ARGUMENTS OR ARG_KEYWORDS_MISSING_VALUES OR + NOT ARG_TARGET OR NOT ARG_HEADERS) + message(FATAL_ERROR + "cpp_library_add_clangd_headers: expected TARGET HEADERS ...") + endif() + if(NOT TARGET "${ARG_TARGET}") + message(FATAL_ERROR "cpp_library_add_clangd_headers: target '${ARG_TARGET}' does not exist") + endif() + get_target_property(owner "${ARG_TARGET}" ALIASED_TARGET) + if(NOT owner) + set(owner "${ARG_TARGET}") + endif() + get_target_property(imported "${owner}" IMPORTED) + get_target_property(type "${owner}" TYPE) + if(imported OR NOT type MATCHES "^(INTERFACE|STATIC|SHARED)_LIBRARY$") + message(FATAL_ERROR + "cpp_library_add_clangd_headers: '${owner}' must be a local INTERFACE, STATIC or SHARED library (not imported)") + endif() + + set(headers) + foreach(header IN LISTS ARG_HEADERS) + if(NOT IS_ABSOLUTE "${header}" OR header MATCHES "\\$<") + message(FATAL_ERROR + "cpp_library_add_clangd_headers: '${header}' must be an absolute header path, not a generator expression") + endif() + cmake_path(NORMAL_PATH header) + set(key_path "${header}") + if(WIN32) + string(TOLOWER "${key_path}" key_path) + endif() + string(SHA256 key "${key_path}") + get_property(previous GLOBAL PROPERTY "_CPP_LIBRARY_CLANGD_OWNER_${key}") + if(previous AND NOT previous STREQUAL owner) + message(FATAL_ERROR + "cpp_library_add_clangd_headers: header '${header}' is registered to both '${previous}' and '${owner}'; assign a single owner") + endif() + set_property(GLOBAL PROPERTY "_CPP_LIBRARY_CLANGD_OWNER_${key}" "${owner}") + list(APPEND headers "${header}") + endforeach() + list(REMOVE_DUPLICATES headers) + + if(NOT CPP_LIBRARY_ENABLE_CLANGD OR NOT CMAKE_EXPORT_COMPILE_COMMANDS) + return() + endif() + if(CMAKE_CONFIGURATION_TYPES) + message(FATAL_ERROR + "cpp_library_add_clangd_headers: multi-configuration generators produce ambiguous header commands; use single-config Ninja/Makefiles or set CPP_LIBRARY_ENABLE_CLANGD=OFF") + endif() + if(NOT CMAKE_GENERATOR MATCHES "(Ninja|Makefiles|WMake)") + message(WARNING + "cpp_library_add_clangd_headers: '${CMAKE_GENERATOR}' cannot export supported header commands; use a Ninja/Makefile generator or set CPP_LIBRARY_ENABLE_CLANGD=OFF") + return() + endif() + + get_property(index_target TARGET "${owner}" PROPERTY _CPP_LIBRARY_CLANGD_TARGET) + if(NOT index_target) + string(SHA256 owner_key "${owner}") + string(SUBSTRING "${owner_key}" 0 12 owner_key) + set(index_target "_cpp_library_clangd_${owner_key}") + if(TARGET "${index_target}") + message(FATAL_ERROR + "cpp_library_add_clangd_headers: reserved indexing target '${index_target}' already exists") + endif() + set(index_binary "${CMAKE_CURRENT_BINARY_DIR}/clangd/${owner_key}") + set_property(TARGET "${owner}" PROPERTY _CPP_LIBRARY_CLANGD_TARGET "${index_target}") + set_property(TARGET "${owner}" PROPERTY _CPP_LIBRARY_CLANGD_DIRECTORY "${index_binary}") + add_subdirectory("${CMAKE_CURRENT_FUNCTION_LIST_DIR}/clangd" "${index_binary}" EXCLUDE_FROM_ALL) + else() + get_property(index_binary TARGET "${owner}" PROPERTY _CPP_LIBRARY_CLANGD_DIRECTORY) + get_target_property(registered "${index_target}" SOURCES) + list(REMOVE_ITEM headers ${registered}) + if(headers) + set_source_files_properties(${headers} DIRECTORY "${index_binary}" + PROPERTIES LANGUAGE CXX HEADER_FILE_ONLY FALSE) + target_sources("${index_target}" PRIVATE ${headers}) + endif() + endif() +endfunction() diff --git a/cmake/cpp-library-docs.cmake b/cmake/cpp-library-docs.cmake index 3372d8f..8b6ac5c 100644 --- a/cmake/cpp-library-docs.cmake +++ b/cmake/cpp-library-docs.cmake @@ -3,7 +3,7 @@ # cpp-library-docs.cmake - Documentation setup with Doxygen # Creates 'docs' target for generating API documentation with Doxygen and doxygen-awesome-css theme. -# - Precondition: NAME, VERSION, and DESCRIPTION specified; Doxygen available +# - Precondition: NAME, VERSION, and DESCRIPTION specified; Doxygen 1.13.1+ available # - Postcondition: 'docs' custom target created, Doxyfile configured, theme downloaded via CPM function(_cpp_library_setup_docs) set(oneValueArgs @@ -37,7 +37,7 @@ function(_cpp_library_setup_docs) string(APPEND docs_extensions "${option}\n") endforeach() - find_package(Doxygen REQUIRED) + find_package(Doxygen 1.13.1 REQUIRED) # Download doxygen-awesome-css theme via CPM # https://github.com/jothepro/doxygen-awesome-css @@ -87,9 +87,9 @@ function(_cpp_library_setup_docs) # Add custom target for documentation with proper stderr capture if(WIN32) - # On Windows, use PowerShell to redirect stderr to stdout + # Preserve Doxygen's exit code when PowerShell receives non-fatal warnings. add_custom_target(docs - COMMAND PowerShell -Command "& '${DOXYGEN_EXECUTABLE}' '${DOXYFILE_OUT}' 2>&1" + COMMAND PowerShell -Command "& '${DOXYGEN_EXECUTABLE}' '${DOXYFILE_OUT}' 2>&1; exit $LASTEXITCODE" WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} COMMENT "Generating API documentation with Doxygen" VERBATIM diff --git a/cmake/cpp-library-setup.cmake b/cmake/cpp-library-setup.cmake index e2583a7..12cfd3a 100644 --- a/cmake/cpp-library-setup.cmake +++ b/cmake/cpp-library-setup.cmake @@ -151,6 +151,7 @@ function(_cpp_library_copy_templates PACKAGE_NAME) ".gitignore" ".gitattributes" ".vscode/extensions.json" + ".vscode/settings.json" ".vscode/tasks.json" "docs/index.html" "CMakePresets.json" diff --git a/cpp-library.cmake b/cpp-library.cmake index 88ab48d..1023504 100644 --- a/cpp-library.cmake +++ b/cpp-library.cmake @@ -78,6 +78,7 @@ include("${CPP_LIBRARY_ROOT}/cmake/cpp-library-setup.cmake") include("${CPP_LIBRARY_ROOT}/cmake/cpp-library-testing.cmake") include("${CPP_LIBRARY_ROOT}/cmake/cpp-library-docs.cmake") include("${CPP_LIBRARY_ROOT}/cmake/cpp-library-ci.cmake") +include("${CPP_LIBRARY_ROOT}/cmake/cpp-library-clangd.cmake") # Creates test or example executables and registers them with CTest. # - Precondition: doctest target available via CPM, source files exist in TYPE directory, enable_testing() called @@ -293,6 +294,8 @@ function(cpp_library_setup) ${install_args} ) + cpp_library_add_clangd_headers(TARGET "${ARG_NAME}" HEADERS ${GENERATED_HEADERS}) + # Only setup development infrastructure when building as top-level project if(NOT PROJECT_IS_TOP_LEVEL) return() # Early return for lightweight consumer mode diff --git a/templates/.vscode/settings.json b/templates/.vscode/settings.json new file mode 100644 index 0000000..1174fa4 --- /dev/null +++ b/templates/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cmake.useCMakePresets": "always", + "cmake.configureOnOpen": true, + "cmake.copyCompileCommands": "${workspaceFolder}/compile_commands.json" +} diff --git a/templates/.vscode/tasks.json b/templates/.vscode/tasks.json index e1161ad..297e047 100644 --- a/templates/.vscode/tasks.json +++ b/templates/.vscode/tasks.json @@ -1,70 +1,92 @@ { "version": "2.0.0", "inputs": [ - { "id": "worktreeName", "type": "promptString", "description": "Worktree name (e.g. my-feature) — branch will be worktree-" }, - { "id": "setConfigurePreset-default", "type": "command", "command": "cmake.setConfigurePreset", "args": "default" }, - { "id": "setConfigurePreset-test", "type": "command", "command": "cmake.setConfigurePreset", "args": "test" }, - { "id": "setConfigurePreset-docs", "type": "command", "command": "cmake.setConfigurePreset", "args": "docs" }, - { "id": "setConfigurePreset-clang-tidy", "type": "command", "command": "cmake.setConfigurePreset", "args": "clang-tidy" }, - { "id": "setConfigurePreset-init", "type": "command", "command": "cmake.setConfigurePreset", "args": "init" }, - { "id": "setConfigurePreset-install", "type": "command", "command": "cmake.setConfigurePreset", "args": "install" }, - { "id": "setBuildPreset-default", "type": "command", "command": "cmake.setBuildPreset", "args": "default" }, - { "id": "setBuildPreset-test", "type": "command", "command": "cmake.setBuildPreset", "args": "test" }, - { "id": "setBuildPreset-docs", "type": "command", "command": "cmake.setBuildPreset", "args": "docs" }, - { "id": "setBuildPreset-clang-tidy", "type": "command", "command": "cmake.setBuildPreset", "args": "clang-tidy" }, - { "id": "setBuildPreset-init", "type": "command", "command": "cmake.setBuildPreset", "args": "init" }, - { "id": "setBuildPreset-install", "type": "command", "command": "cmake.setBuildPreset", "args": "install" }, - { "id": "setTestPreset-test", "type": "command", "command": "cmake.setTestPreset", "args": "test" }, - { "id": "setTestPreset-clang-tidy", "type": "command", "command": "cmake.setTestPreset", "args": "clang-tidy" } - ], - "tasks": [ - // ============ Worktree Tasks ============ { - "label": "worktree: create", - "type": "shell", - "command": "git worktree add \".claude/worktrees/${input:worktreeName}\" -b \"worktree-${input:worktreeName}\" && (command -v tokensave >/dev/null 2>&1 && tokensave init \".claude/worktrees/${input:worktreeName}\" && tokensave branch add \"worktree-${input:worktreeName}\" --path \".claude/worktrees/${input:worktreeName}\" || true) && code --new-window \".claude/worktrees/${input:worktreeName}\"", - "options": { - "cwd": "${workspaceFolder}" - }, - "windows": { - "command": "git worktree add \".claude/worktrees/${input:worktreeName}\" -b \"worktree-${input:worktreeName}\" && (where tokensave >nul 2>&1 && (tokensave init \".claude/worktrees/${input:worktreeName}\" && tokensave branch add \"worktree-${input:worktreeName}\" --path \".claude/worktrees/${input:worktreeName}\") || ver>nul) && code --new-window \".claude/worktrees/${input:worktreeName}\"", - "options": { - "shell": { - "executable": "cmd.exe", - "args": ["/d", "/c"] - } - } - }, - "problemMatcher": [], - "presentation": { - "reveal": "always", - "panel": "dedicated" - }, - "detail": "Prompts for a name, creates a git worktree at .claude/worktrees/ on branch worktree-, best-effort syncs it with tokensave if installed, and opens it in a new window." + "id": "setConfigurePreset-default", + "type": "command", + "command": "cmake.setConfigurePreset", + "args": "default" }, { - "label": "worktree: remove", - "type": "shell", - "command": "git worktree remove \".claude/worktrees/${input:worktreeName}\" && (command -v tokensave >/dev/null 2>&1 && tokensave branch gc || true)", - "options": { - "cwd": "${workspaceFolder}" - }, - "windows": { - "command": "git worktree remove \".claude/worktrees/${input:worktreeName}\" && (where tokensave >nul 2>&1 && tokensave branch gc || ver>nul)", - "options": { - "shell": { - "executable": "cmd.exe", - "args": ["/d", "/c"] - } - } - }, - "problemMatcher": [], - "presentation": { - "reveal": "always", - "panel": "dedicated" - }, - "detail": "Prompts for a name, removes the git worktree at .claude/worktrees/, and best-effort garbage-collects tokensave's branch tracking if installed." + "id": "setConfigurePreset-test", + "type": "command", + "command": "cmake.setConfigurePreset", + "args": "test" + }, + { + "id": "setConfigurePreset-docs", + "type": "command", + "command": "cmake.setConfigurePreset", + "args": "docs" }, + { + "id": "setConfigurePreset-clang-tidy", + "type": "command", + "command": "cmake.setConfigurePreset", + "args": "clang-tidy" + }, + { + "id": "setConfigurePreset-init", + "type": "command", + "command": "cmake.setConfigurePreset", + "args": "init" + }, + { + "id": "setConfigurePreset-install", + "type": "command", + "command": "cmake.setConfigurePreset", + "args": "install" + }, + { + "id": "setBuildPreset-default", + "type": "command", + "command": "cmake.setBuildPreset", + "args": "default" + }, + { + "id": "setBuildPreset-test", + "type": "command", + "command": "cmake.setBuildPreset", + "args": "test" + }, + { + "id": "setBuildPreset-docs", + "type": "command", + "command": "cmake.setBuildPreset", + "args": "docs" + }, + { + "id": "setBuildPreset-clang-tidy", + "type": "command", + "command": "cmake.setBuildPreset", + "args": "clang-tidy" + }, + { + "id": "setBuildPreset-init", + "type": "command", + "command": "cmake.setBuildPreset", + "args": "init" + }, + { + "id": "setBuildPreset-install", + "type": "command", + "command": "cmake.setBuildPreset", + "args": "install" + }, + { + "id": "setTestPreset-test", + "type": "command", + "command": "cmake.setTestPreset", + "args": "test" + }, + { + "id": "setTestPreset-clang-tidy", + "type": "command", + "command": "cmake.setTestPreset", + "args": "clang-tidy" + } + ], + "tasks": [ // ============ CMake Preset Tasks ============ { "label": "Configure & Build: Default Configuration", @@ -75,14 +97,23 @@ "CMAKE_TOOLS_SYNC": "${input:setConfigurePreset-default} ${input:setBuildPreset-default}" } }, - "group": { "kind": "build", "isDefault": true }, - "problemMatcher": ["$gcc", "$msCompile"], + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [ + "$gcc", + "$msCompile" + ], "detail": "Default configuration for building the library Requires the CMake Tools extension (ms-vscode.cmake-tools) — without it, this task fails to start.", "windows": { "options": { "shell": { "executable": "cmd.exe", - "args": ["/d", "/c"] + "args": [ + "/d", + "/c" + ] } } } @@ -97,13 +128,19 @@ } }, "group": "build", - "problemMatcher": ["$gcc", "$msCompile"], + "problemMatcher": [ + "$gcc", + "$msCompile" + ], "detail": "Configuration for building and running tests Requires the CMake Tools extension (ms-vscode.cmake-tools) — without it, this task fails to start.", "windows": { "options": { "shell": { "executable": "cmd.exe", - "args": ["/d", "/c"] + "args": [ + "/d", + "/c" + ] } } } @@ -118,13 +155,19 @@ } }, "group": "build", - "problemMatcher": ["$gcc", "$msCompile"], + "problemMatcher": [ + "$gcc", + "$msCompile" + ], "detail": "Configuration for building documentation Requires the CMake Tools extension (ms-vscode.cmake-tools) — without it, this task fails to start.", "windows": { "options": { "shell": { "executable": "cmd.exe", - "args": ["/d", "/c"] + "args": [ + "/d", + "/c" + ] } } } @@ -139,13 +182,19 @@ } }, "group": "build", - "problemMatcher": ["$gcc", "$msCompile"], + "problemMatcher": [ + "$gcc", + "$msCompile" + ], "detail": "Configuration for running clang-tidy static analysis Requires the CMake Tools extension (ms-vscode.cmake-tools) — without it, this task fails to start.", "windows": { "options": { "shell": { "executable": "cmd.exe", - "args": ["/d", "/c"] + "args": [ + "/d", + "/c" + ] } } } @@ -160,13 +209,19 @@ } }, "group": "build", - "problemMatcher": ["$gcc", "$msCompile"], + "problemMatcher": [ + "$gcc", + "$msCompile" + ], "detail": "Force regeneration of template files (CMakePresets.json, CI, etc.) Requires the CMake Tools extension (ms-vscode.cmake-tools) — without it, this task fails to start.", "windows": { "options": { "shell": { "executable": "cmd.exe", - "args": ["/d", "/c"] + "args": [ + "/d", + "/c" + ] } } } @@ -181,13 +236,19 @@ } }, "group": "build", - "problemMatcher": ["$gcc", "$msCompile"], + "problemMatcher": [ + "$gcc", + "$msCompile" + ], "detail": "Release build for installation with CPM_USE_LOCAL_PACKAGES enabled for testing installed packages Requires the CMake Tools extension (ms-vscode.cmake-tools) — without it, this task fails to start.", "windows": { "options": { "shell": { "executable": "cmd.exe", - "args": ["/d", "/c"] + "args": [ + "/d", + "/c" + ] } } } @@ -201,14 +262,23 @@ "CMAKE_TOOLS_SYNC": "${input:setConfigurePreset-test} ${input:setBuildPreset-test} ${input:setTestPreset-test}" } }, - "group": { "kind": "test", "isDefault": true }, - "problemMatcher": ["$gcc", "$msCompile"], + "group": { + "kind": "test", + "isDefault": true + }, + "problemMatcher": [ + "$gcc", + "$msCompile" + ], "detail": "Configuration for building and running tests Requires the CMake Tools extension (ms-vscode.cmake-tools) — without it, this task fails to start.", "windows": { "options": { "shell": { "executable": "cmd.exe", - "args": ["/d", "/c"] + "args": [ + "/d", + "/c" + ] } } } @@ -223,13 +293,19 @@ } }, "group": "test", - "problemMatcher": ["$gcc", "$msCompile"], + "problemMatcher": [ + "$gcc", + "$msCompile" + ], "detail": "Configuration for running clang-tidy static analysis Requires the CMake Tools extension (ms-vscode.cmake-tools) — without it, this task fails to start.", "windows": { "options": { "shell": { "executable": "cmd.exe", - "args": ["/d", "/c"] + "args": [ + "/d", + "/c" + ] } } } diff --git a/templates/Doxyfile.in b/templates/Doxyfile.in index 99a2b0d..bc9b4b1 100644 --- a/templates/Doxyfile.in +++ b/templates/Doxyfile.in @@ -9,6 +9,10 @@ GENERATE_LATEX = NO QUIET = YES WARN_IF_UNDOCUMENTED = YES INPUT = "@INPUT_DIR@" +LAYOUT_FILE = "@CPP_LIBRARY_ROOT@/templates/DoxygenLayout.xml" +# Missing entries intentionally inherit defaults. Enable and review layout warnings +# whenever the layout is updated; see README.md's documentation configuration. +WARN_LAYOUT_FILE = NO RECURSIVE = YES EXCLUDE_SYMBOLS = @EXCLUDE_SYMBOLS@ EXAMPLE_PATH = "@EXAMPLES_PATH@" diff --git a/templates/DoxygenLayout.xml b/templates/DoxygenLayout.xml new file mode 100644 index 0000000..ffefd8c --- /dev/null +++ b/templates/DoxygenLayout.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/docs/fixtures/CMakeLists.txt b/tests/docs/fixtures/CMakeLists.txt index dfe58c8..50e4737 100644 --- a/tests/docs/fixtures/CMakeLists.txt +++ b/tests/docs/fixtures/CMakeLists.txt @@ -15,7 +15,29 @@ function(CPMAddPackage) endfunction() set(docs_args) -if(CASE STREQUAL "extended") +if(CASE STREQUAL "layout-brief") + list(APPEND docs_args DOCS_OPTIONS "REPEAT_BRIEF = NO") +elseif(CASE STREQUAL "layout-review") + list(APPEND docs_args DOCS_OPTIONS "WARN_LAYOUT_FILE = YES") +elseif(CASE STREQUAL "layout-review-all") + list(APPEND docs_args DOCS_OPTIONS "WARN_LAYOUT_FILE = YES" "WARN_AS_ERROR = NO") +elseif(CASE STREQUAL "layout-override" OR CASE STREQUAL "invalid-layout") + file(WRITE "${CMAKE_CURRENT_SOURCE_DIR}/custom layout.xml" + "\n" + "" + "" + "" + "\n") + if(CASE STREQUAL "invalid-layout") + file(WRITE "${CMAKE_CURRENT_SOURCE_DIR}/custom layout.xml" + "") + endif() + list(APPEND docs_args DOCS_OPTIONS + "LAYOUT_FILE = \"${CMAKE_CURRENT_SOURCE_DIR}/custom layout.xml\"") + if(CASE STREQUAL "invalid-layout") + list(APPEND docs_args DOCS_OPTIONS "WARN_LAYOUT_FILE = YES") + endif() +elseif(CASE STREQUAL "extended") list(APPEND docs_args DOCS_INPUTS "extra docs" "${CMAKE_CURRENT_SOURCE_DIR}/absolute docs/group.dox" DOCS_OPTIONS diff --git a/tests/docs/fixtures/include/fixture/api.hpp b/tests/docs/fixtures/include/fixture/api.hpp index ad3e3aa..1df8795 100644 --- a/tests/docs/fixtures/include/fixture/api.hpp +++ b/tests/docs/fixtures/include/fixture/api.hpp @@ -1,9 +1,16 @@ /** @file * @brief Fixture public API. */ +/** @defgroup fixture_reference Fixture reference + * @brief Layout brief fixture. + * + * Layout detailed fixture. + */ /** @brief Fixture namespace. */ namespace fixture { -/** @brief Return the fixture value. */ +/** @brief Return the fixture value. + * @ingroup fixture_reference + */ inline int value() { return 42; } /** @brief Internal API omitted from the extended documentation. */ struct hidden {}; diff --git a/tests/docs/test_docs.cmake b/tests/docs/test_docs.cmake index 6609f4e..53eb146 100644 --- a/tests/docs/test_docs.cmake +++ b/tests/docs/test_docs.cmake @@ -5,7 +5,8 @@ get_filename_component(toolkit_source "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE string(RANDOM LENGTH 16 ALPHABET 0123456789abcdef test_id) set(test_root "${toolkit_source}/build/docs-${test_id}") -foreach(case default extended custom missing-input malformed-option +foreach(case default extended custom layout-brief layout-override layout-review layout-review-all + invalid-layout missing-input malformed-option malformed-terminal-continuation-option malformed-continuation-option multiline-option missing-input-value missing-option-value) set(source "${test_root}/${case}/source with spaces") @@ -35,11 +36,29 @@ foreach(case default extended custom missing-input malformed-option endif() execute_process(COMMAND "${CMAKE_COMMAND}" --build "${binary}" --target docs RESULT_VARIABLE result OUTPUT_VARIABLE out ERROR_VARIABLE err) + if(case STREQUAL "invalid-layout" OR case STREQUAL "layout-review") + set(expected "custom layout.xml") + if(case STREQUAL "layout-review") + set(expected "User defined layout misses entry") + endif() + if(result EQUAL 0 OR NOT "${out}\n${err}" MATCHES "${expected}") + message(FATAL_ERROR "${case}: Layout review did not expose '${expected}':\n${out}\n${err}") + endif() + message(STATUS "PASS documentation ${case}") + continue() + endif() if(NOT result EQUAL 0) message(FATAL_ERROR "${case}: Doxygen failed:\n${out}\n${err}") endif() + if(case STREQUAL "layout-review-all") + string(REGEX MATCHALL "User defined layout misses entry" warnings "${out}\n${err}") + list(LENGTH warnings warning_count) + if(warning_count LESS 2) + message(FATAL_ERROR "${case}: Review mode did not report the inherited-entry warning list") + endif() + endif() file(READ "${binary}/html/index.html" index) - if(case STREQUAL "default") + if(case STREQUAL "default" OR case MATCHES "^layout-") string(FIND "${index}" "Documentation fixture" brief) else() string(FIND "${index}" "Customized documentation" brief) @@ -72,6 +91,35 @@ foreach(case default extended custom missing-input malformed-option message(FATAL_ERROR "Custom Doxyfile compatibility was lost") endif() endif() + file(READ "${binary}/html/menudata.js" navigation) + if(case STREQUAL "custom") + if(navigation MATCHES "text:\"Reference\"" OR navigation MATCHES "text:\"Index\"") + message(FATAL_ERROR "Custom Doxyfile unexpectedly inherited the toolkit layout") + endif() + elseif(case STREQUAL "layout-override") + if(NOT navigation MATCHES "Custom reference") + message(FATAL_ERROR "DOCS_OPTIONS layout override did not affect navigation") + endif() + else() + string(FIND "${navigation}" "text:\"Reference\"" reference) + string(FIND "${navigation}" "text:\"Index\"" indexes) + if(reference LESS 0 OR indexes LESS reference) + message(FATAL_ERROR "Default layout did not place Reference before Index:\n${navigation}") + endif() + file(READ "${binary}/html/group__fixture__reference.html" group) + string(FIND "${group}" "Layout brief fixture." brief) + string(FIND "${group}" "Layout detailed fixture." detailed) + string(FIND "${group}" "" members) + if(brief LESS 0 OR detailed LESS 0 OR members LESS detailed) + message(FATAL_ERROR "Group brief or description-first member layout was lost") + endif() + endif() + if(case STREQUAL "default" OR case STREQUAL "layout-brief") + file(READ "${binary}/html/structfixture_1_1hidden.html" class) + if(NOT class MATCHES "Internal API omitted from the extended documentation") + message(FATAL_ERROR "Partial layout lost inherited class-page content") + endif() + endif() endif() message(STATUS "PASS documentation ${case}") endforeach() diff --git a/tests/setup/check_clangd.py b/tests/setup/check_clangd.py new file mode 100644 index 0000000..542dced --- /dev/null +++ b/tests/setup/check_clangd.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: BSL-1.0 +"""Fresh-header LSP regression; Python is needed only by this optional test.""" + +import json +from pathlib import Path +import queue +import subprocess +import sys +import threading +import time + + +def check(clangd, build, header): + header = Path(header).resolve() + uri = header.as_uri() + messages = queue.Queue() + diagnostics = [] + stderr = [] + process = subprocess.Popen( + [clangd, f"--compile-commands-dir={build}", "--background-index=0", + "--clang-tidy=0", "--log=verbose"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + + def read_messages(): + try: + while True: + headers = {} + while True: + line = process.stdout.readline() + if not line: + raise EOFError("clangd closed stdout") + if line == b"\r\n": + break + name, value = line.decode("ascii").split(":", 1) + headers[name.lower()] = value.strip() + size = int(headers["content-length"]) + body = bytearray() + while len(body) < size: + part = process.stdout.read(size - len(body)) + if not part: + raise EOFError("clangd truncated a message") + body.extend(part) + messages.put(json.loads(body)) + except (EOFError, ValueError, OSError) as error: + messages.put(error) + + def read_stderr(): + for line in iter(process.stderr.readline, b""): + stderr.append(line.decode("utf-8", errors="replace")) + + output_thread = threading.Thread(target=read_messages, daemon=True) + log_thread = threading.Thread(target=read_stderr, daemon=True) + output_thread.start() + log_thread.start() + request_id = 0 + + def send(method, params, identifier=None): + message = {"jsonrpc": "2.0", "method": method, "params": params} + if identifier is not None: + message["id"] = identifier + body = json.dumps(message).encode("utf-8") + process.stdin.write(f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body) + process.stdin.flush() + + def request(method, params): + nonlocal request_id + request_id += 1 + send(method, params, request_id) + deadline = time.monotonic() + 45 + while True: + message = messages.get(timeout=max(0, deadline - time.monotonic())) + if isinstance(message, Exception): + raise message + if message.get("method") == "textDocument/publishDiagnostics": + diagnostics.extend(message["params"]["diagnostics"]) + if message.get("id") == request_id: + if "error" in message: + raise AssertionError(message["error"]) + return message.get("result") + + def definition(line, character, expected_uri, expected_line, start, end): + result = request("textDocument/definition", { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character}, + }) + if isinstance(result, dict): + result = [result] + expected = [{ + "uri": expected_uri, + "range": { + "start": {"line": expected_line, "character": start}, + "end": {"line": expected_line, "character": end}, + }, + }] + if result != expected: + raise AssertionError(f"Definition at {line}:{character}: {result!r} != {expected!r}") + + try: + request("initialize", { + "processId": None, "rootUri": header.parent.as_uri(), "capabilities": {}, + }) + send("initialized", {}) + send("textDocument/didOpen", { + "textDocument": { + "uri": uri, "languageId": "cpp", "version": 1, + "text": header.read_text(encoding="utf-8"), + }, + }) + definition(12, 33, uri, 8, 6, 16) + definition(13, 21, uri, 10, 8, 14) + config_uri = (Path(build) / "generated" / "fixture" / "config.hpp").resolve().as_uri() + definition(10, 33, config_uri, 0, 8, 25) + # Requests are serialized after the initial parse, so its diagnostics are ready. + request("textDocument/documentSymbol", {"textDocument": {"uri": uri}}) + errors = [item for item in diagnostics if item.get("severity") == 1] + if errors: + raise AssertionError(f"Header diagnostics: {errors}") + request("shutdown", None) + send("exit", None) + process.wait(timeout=15) + if process.returncode: + raise AssertionError(f"clangd exited {process.returncode}") + print("PASS fresh clangd: exact class, member and generated-config definition ranges") + except (AssertionError, EOFError, OSError, ValueError, queue.Empty, + subprocess.TimeoutExpired): + sys.stderr.write("".join(stderr)) + raise + finally: + if process.poll() is None: + process.kill() + process.wait() + output_thread.join(timeout=5) + log_thread.join(timeout=5) + process.stdin.close() + process.stdout.close() + process.stderr.close() + + +if __name__ == "__main__": + check(*sys.argv[1:]) diff --git a/tests/setup/fixtures/clangd/CMakeLists.txt b/tests/setup/fixtures/clangd/CMakeLists.txt new file mode 100644 index 0000000..dffad72 --- /dev/null +++ b/tests/setup/fixtures/clangd/CMakeLists.txt @@ -0,0 +1,108 @@ +cmake_minimum_required(VERSION 3.24) +project(clangd_fixture LANGUAGES CXX) +if(EXPECTED_COMPILER_ID AND NOT CMAKE_CXX_COMPILER_ID STREQUAL EXPECTED_COMPILER_ID) + message(FATAL_ERROR "Expected ${EXPECTED_COMPILER_ID}, got ${CMAKE_CXX_COMPILER_ID}") +endif() +include("${TOOLKIT_SOURCE}/cpp-library.cmake") + +if(WORKSPACE) + set(CPP_LIBRARY_VERSION 1.0.0) + set(FIXTURE_INSTALL OFF CACHE BOOL "" FORCE) + cpp_library_setup(DESCRIPTION "Workspace settings fixture" NAMESPACE fixture + HEADERS api.hpp REQUIRES_CPP_VERSION 20) + target_compile_definitions(clangd_fixture INTERFACE PUBLIC_CONTEXT=42 + "$,PRESET_CONTEXT=2,PRESET_CONTEXT=1>") + return() +endif() + +if(HEADER_ONLY) + add_library(owner INTERFACE) + set(usage INTERFACE) +else() + add_library(owner owner.cpp) + set_target_properties(owner PROPERTIES UNITY_BUILD OFF) + set(usage PUBLIC) + target_compile_definitions(owner PRIVATE PRODUCER_PRIVATE=1) +endif() +target_sources(owner ${usage} FILE_SET headers TYPE HEADERS + BASE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/include" + FILES "${CMAKE_CURRENT_SOURCE_DIR}/include/fixture/api.hpp") +target_include_directories(owner ${usage} + "$" + "$") +target_compile_definitions(owner ${usage} PUBLIC_CONTEXT=42) +target_compile_features(owner ${usage} cxx_std_20) + +get_source_file_property(before "${CMAKE_CURRENT_SOURCE_DIR}/include/fixture/api.hpp" HEADER_FILE_ONLY) +get_source_file_property(language_before "${CMAKE_CURRENT_SOURCE_DIR}/include/fixture/api.hpp" LANGUAGE) +get_target_property(headers owner HEADER_SET_headers) +if(PROOF) + add_subdirectory(index EXCLUDE_FROM_ALL) + set(index_target fixture_index) +else() + add_library(fixture::owner ALIAS owner) + cpp_library_add_clangd_headers(TARGET fixture::owner + HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/include/fixture/api.hpp") + cpp_library_add_clangd_headers(TARGET owner + HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/include/fixture/api.hpp") + file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/generated/fixture") + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/generated/fixture/config.hpp" + "#define GENERATED_CONTEXT 73\n") + set(generated "${CMAKE_CURRENT_BINARY_DIR}/generated/fixture/config.hpp") + set_source_files_properties("${generated}" PROPERTIES GENERATED TRUE) + cpp_library_add_clangd_headers(TARGET owner HEADERS "${generated}" + "${CMAKE_CURRENT_SOURCE_DIR}/include/fixture/extra.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/include/fixture/navigation.hpp") + set(build_generated "${CMAKE_CURRENT_BINARY_DIR}/generated/fixture/build_generated.hpp") + add_custom_command(OUTPUT "${build_generated}" + COMMAND "${CMAKE_COMMAND}" -E copy "${generated}" "${build_generated}" + DEPENDS "${generated}") + add_custom_target(generate_build_header DEPENDS "${build_generated}") + cpp_library_add_clangd_headers(TARGET owner HEADERS "${build_generated}") + add_library(public_dependency INTERFACE) + target_include_directories(public_dependency INTERFACE + "$") + target_compile_definitions(public_dependency INTERFACE DEPENDENCY_CONTEXT=19) + target_link_libraries(owner ${usage} public_dependency) + target_include_directories(owner ${usage} + "$") + target_compile_definitions(owner ${usage} LATE_CONTEXT=61 + "$<$:DEBUG_CONTEXT=1>" + "$<$:RELEASE_CONTEXT=1>" + "$<$>:PRODUCER_CONTEXT=1>" + "$<$>>:CONSUMER_CONTEXT=1>") + set_property(TARGET owner PROPERTY FIXTURE_CONSUMER_MARKER TRUE) + if(BUILD_SHARED_LIBS AND NOT HEADER_ONLY) + target_compile_definitions(owner PRIVATE OWNER_EXPORT=1) + target_compile_definitions(owner INTERFACE OWNER_IMPORT=1) + endif() + add_subdirectory(nested) + if(ERROR_CASE STREQUAL conflict) + add_library(other INTERFACE) + cpp_library_add_clangd_headers(TARGET other + HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/include/fixture/api.hpp") + elseif(ERROR_CASE STREQUAL missing) + cpp_library_add_clangd_headers(TARGET absent HEADERS "${generated}") + elseif(ERROR_CASE STREQUAL imported) + add_library(imported INTERFACE IMPORTED) + cpp_library_add_clangd_headers(TARGET imported HEADERS "${generated}") + elseif(ERROR_CASE STREQUAL executable) + add_executable(executable owner.cpp) + cpp_library_add_clangd_headers(TARGET executable HEADERS "${generated}") + elseif(ERROR_CASE STREQUAL relative) + cpp_library_add_clangd_headers(TARGET owner HEADERS include/fixture/api.hpp) + endif() + get_property(index_target TARGET owner PROPERTY _CPP_LIBRARY_CLANGD_TARGET) + install(TARGETS owner public_dependency EXPORT fixtureTargets + FILE_SET headers DESTINATION include) + install(EXPORT fixtureTargets DESTINATION lib/cmake/fixture) +endif() +get_source_file_property(after "${CMAKE_CURRENT_SOURCE_DIR}/include/fixture/api.hpp" HEADER_FILE_ONLY) +get_source_file_property(language_after "${CMAKE_CURRENT_SOURCE_DIR}/include/fixture/api.hpp" LANGUAGE) +get_target_property(headers_after owner HEADER_SET_headers) +if(NOT before STREQUAL after OR NOT language_before STREQUAL language_after OR + NOT headers STREQUAL headers_after) + message(FATAL_ERROR "Indexing changed production source properties") +endif() +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/properties.txt" "${headers}\n") +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/index-target.txt" "${index_target}") diff --git a/tests/setup/fixtures/clangd/dependency_include/dependency.hpp b/tests/setup/fixtures/clangd/dependency_include/dependency.hpp new file mode 100644 index 0000000..7398aaa --- /dev/null +++ b/tests/setup/fixtures/clangd/dependency_include/dependency.hpp @@ -0,0 +1,3 @@ +#ifndef DEPENDENCY_CONTEXT +#error Missing dependency context +#endif diff --git a/tests/setup/fixtures/clangd/include/fixture/api.hpp b/tests/setup/fixtures/clangd/include/fixture/api.hpp new file mode 100644 index 0000000..f26a46b --- /dev/null +++ b/tests/setup/fixtures/clangd/include/fixture/api.hpp @@ -0,0 +1,24 @@ +#ifndef FIXTURE_API_HPP +#define FIXTURE_API_HPP +#if PUBLIC_CONTEXT != 42 +#error Missing consumer definition +#endif +#ifdef PRODUCER_PRIVATE +#error Producer definition leaked to consumer +#endif +#ifdef LATE_CONTEXT +#include +#include +#if LATE_CONTEXT != 61 || GENERATED_CONTEXT != 73 || DEPENDENCY_CONTEXT != 19 +#error Incorrect generated or transitive consumer context +#endif +#if defined(OWNER_EXPORT) || defined(owner_EXPORTS) +#error DLL export definition leaked to consumer +#endif +#endif +namespace fixture { +struct widget { + int value() const { return 42; } +}; +} +#endif diff --git a/tests/setup/fixtures/clangd/include/fixture/extra.hpp b/tests/setup/fixtures/clangd/include/fixture/extra.hpp new file mode 100644 index 0000000..5374d0e --- /dev/null +++ b/tests/setup/fixtures/clangd/include/fixture/extra.hpp @@ -0,0 +1,4 @@ +#ifndef FIXTURE_EXTRA_HPP +#define FIXTURE_EXTRA_HPP +#include +#endif diff --git a/tests/setup/fixtures/clangd/include/fixture/navigation.hpp b/tests/setup/fixtures/clangd/include/fixture/navigation.hpp new file mode 100644 index 0000000..bc795e0 --- /dev/null +++ b/tests/setup/fixtures/clangd/include/fixture/navigation.hpp @@ -0,0 +1,17 @@ +#ifndef FIXTURE_NAVIGATION_HPP +#define FIXTURE_NAVIGATION_HPP +#include +#include +#if GENERATED_CONTEXT != 73 || DEPENDENCY_CONTEXT != 19 || LATE_CONTEXT != 61 +#error Wrong consumer context +#endif +namespace fixture { +class navigation { +public: + int answer() const { return GENERATED_CONTEXT; } +}; +inline int use_navigation(const navigation& instance) { + return instance.answer(); +} +} +#endif diff --git a/tests/setup/fixtures/clangd/index/CMakeLists.txt b/tests/setup/fixtures/clangd/index/CMakeLists.txt new file mode 100644 index 0000000..d10c9b3 --- /dev/null +++ b/tests/setup/fixtures/clangd/index/CMakeLists.txt @@ -0,0 +1,5 @@ +set(header "${PROJECT_SOURCE_DIR}/include/fixture/api.hpp") +set_source_files_properties("${header}" PROPERTIES LANGUAGE CXX HEADER_FILE_ONLY FALSE) +add_library(fixture_index OBJECT EXCLUDE_FROM_ALL "${header}") +set_target_properties(fixture_index PROPERTIES UNITY_BUILD OFF) +target_link_libraries(fixture_index PRIVATE owner) diff --git a/tests/setup/fixtures/clangd/nested/CMakeLists.txt b/tests/setup/fixtures/clangd/nested/CMakeLists.txt new file mode 100644 index 0000000..8b1ec2e --- /dev/null +++ b/tests/setup/fixtures/clangd/nested/CMakeLists.txt @@ -0,0 +1,6 @@ +project(nested LANGUAGES CXX) +set(CPP_LIBRARY_VERSION 1.0.0) +set(NESTED_INSTALL OFF CACHE BOOL "" FORCE) +cpp_library_setup(DESCRIPTION "Nested header-only fixture" NAMESPACE nested + HEADERS api.hpp REQUIRES_CPP_VERSION 20) +target_compile_definitions(nested INTERFACE NESTED_CONTEXT=29) diff --git a/tests/setup/fixtures/clangd/nested/include/nested/api.hpp b/tests/setup/fixtures/clangd/nested/include/nested/api.hpp new file mode 100644 index 0000000..841c317 --- /dev/null +++ b/tests/setup/fixtures/clangd/nested/include/nested/api.hpp @@ -0,0 +1,7 @@ +#ifndef NESTED_CONTEXT +#error Missing nested context +#endif +#ifdef PUBLIC_CONTEXT +#error Downstream requirements leaked into dependency +#endif +namespace nested { struct value {}; } diff --git a/tests/setup/fixtures/clangd/owner.cpp b/tests/setup/fixtures/clangd/owner.cpp new file mode 100644 index 0000000..67dca88 --- /dev/null +++ b/tests/setup/fixtures/clangd/owner.cpp @@ -0,0 +1 @@ +int owner_source() { return 0; } diff --git a/tests/setup/test_clangd.cmake b/tests/setup/test_clangd.cmake new file mode 100644 index 0000000..fa9af57 --- /dev/null +++ b/tests/setup/test_clangd.cmake @@ -0,0 +1,286 @@ +# SPDX-License-Identifier: BSL-1.0 +# Run as: cmake -P tests/setup/test_clangd.cmake +cmake_minimum_required(VERSION 3.24) + +string(RANDOM LENGTH 8 ALPHABET 0123456789abcdef test_id) +get_filename_component(toolkit_source "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +set(test_root "${toolkit_source}/build/clangd ${test_id}") +if(CPP_LIBRARY_TEST_CLANGD) + find_program(clangd NAMES clangd REQUIRED) + find_package(Python3 REQUIRED COMPONENTS Interpreter) +else() + find_program(clangd NAMES clangd) + find_package(Python3 QUIET COMPONENTS Interpreter) +endif() + +function(run_checked) + execute_process(COMMAND ${ARGV} + RESULT_VARIABLE result OUTPUT_VARIABLE out ERROR_VARIABLE err) + if(NOT result EQUAL 0) + message(FATAL_ERROR "Command failed (${result}): ${ARGV}\n${out}\n${err}") + endif() + if(err) + message(FATAL_ERROR "Unexpected command diagnostics: ${ARGV}\n${err}") + endif() +endfunction() + +function(check_header database header) + file(READ "${database}" json) + string(JSON length LENGTH "${json}") + math(EXPR last "${length} - 1") + set(matches 0) + foreach(i RANGE ${last}) + string(JSON path GET "${json}" ${i} file) + if(path STREQUAL header) + math(EXPR matches "${matches} + 1") + string(JSON command GET "${json}" ${i} command) + foreach(required IN LISTS ARGN) + if(NOT command MATCHES "${required}") + message(FATAL_ERROR "Header command missing ${required}: ${command}") + endif() + endforeach() + if(NOT command MATCHES "(-std=[^ ]*20|[-/]std:c\\+\\+20)") + message(FATAL_ERROR "Header command missing C++20: ${command}") + endif() + if(command MATCHES "(PRODUCER_PRIVATE|OWNER_EXPORT|owner_EXPORTS)") + message(FATAL_ERROR "Private producer definition leaked: ${command}") + endif() + set(header_command "${command}" PARENT_SCOPE) + endif() + endforeach() + if(NOT matches EQUAL 1) + message(FATAL_ERROR "Expected one exact command for ${header}, got ${matches}") + endif() +endfunction() + +foreach(kind IN ITEMS proof_compiled proof_interface compiled interface shared disabled no_export) + set(source "${test_root}/${kind}/source") + set(binary "${test_root}/${kind}/build") + file(MAKE_DIRECTORY "${source}") + file(COPY "${CMAKE_CURRENT_LIST_DIR}/fixtures/clangd/" DESTINATION "${source}") + set(header "${source}/include/fixture/api.hpp") + set(header_only OFF) + if(kind MATCHES interface) + set(header_only ON) + endif() + set(options) + if(kind MATCHES "^proof") + list(APPEND options -DPROOF=ON) + elseif(kind STREQUAL shared) + list(APPEND options -DBUILD_SHARED_LIBS=ON) + elseif(kind STREQUAL disabled) + list(APPEND options -DCPP_LIBRARY_ENABLE_CLANGD=OFF) + elseif(kind STREQUAL no_export) + list(APPEND options -DCMAKE_EXPORT_COMPILE_COMMANDS=OFF) + endif() + run_checked("${CMAKE_COMMAND}" -S "${source}" -B "${binary}" -G Ninja + "-DTOOLKIT_SOURCE=${toolkit_source}" -DCMAKE_BUILD_TYPE=Debug + "-DEXPECTED_COMPILER_ID=${CPP_LIBRARY_TEST_COMPILER_ID}" + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_UNITY_BUILD=ON + "-DHEADER_ONLY=${header_only}" ${options}) + file(READ "${binary}/index-target.txt" index_target) + if(kind STREQUAL disabled OR kind STREQUAL no_export) + if(index_target) + message(FATAL_ERROR "Inactive registration created an indexing target") + endif() + message(STATUS "PASS ${kind}: no indexing target") + continue() + endif() + check_header("${binary}/compile_commands.json" "${header}" PUBLIC_CONTEXT=42 include) + if(NOT kind MATCHES "^proof") + check_header("${binary}/compile_commands.json" + "${source}/include/fixture/extra.hpp" PUBLIC_CONTEXT=42 LATE_CONTEXT=61 DEPENDENCY_CONTEXT=19) + check_header("${binary}/compile_commands.json" + "${binary}/generated/fixture/config.hpp" PUBLIC_CONTEXT=42 LATE_CONTEXT=61 DEPENDENCY_CONTEXT=19) + check_header("${binary}/compile_commands.json" + "${binary}/generated/fixture/build_generated.hpp" PUBLIC_CONTEXT=42 LATE_CONTEXT=61) + check_header("${binary}/compile_commands.json" + "${source}/include/fixture/navigation.hpp" PUBLIC_CONTEXT=42 LATE_CONTEXT=61) + if(NOT header_command MATCHES "CONSUMER_CONTEXT=1" OR + header_command MATCHES "PRODUCER_CONTEXT=1") + message(FATAL_ERROR "Header command used producer target context: ${header_command}") + endif() + check_header("${binary}/compile_commands.json" + "${source}/nested/include/nested/api.hpp" NESTED_CONTEXT=29) + if(header_command MATCHES "PUBLIC_CONTEXT") + message(FATAL_ERROR "Nested dependency used downstream context: ${header_command}") + endif() + file(READ "${binary}/compile_commands.json" commands) + if(kind STREQUAL shared AND NOT commands MATCHES "OWNER_IMPORT=1") + message(FATAL_ERROR "Shared consumer command missing import definition") + endif() + if(NOT header_only) + string(JSON command_count LENGTH "${commands}") + math(EXPR command_last "${command_count} - 1") + set(production_count 0) + foreach(i RANGE ${command_last}) + string(JSON path GET "${commands}" ${i} file) + if(path STREQUAL "${source}/owner.cpp") + math(EXPR production_count "${production_count} + 1") + string(JSON command GET "${commands}" ${i} command) + if(NOT command MATCHES "PRODUCER_PRIVATE=1" OR command MATCHES "OWNER_IMPORT") + message(FATAL_ERROR "Production flags changed: ${command}") + endif() + endif() + endforeach() + if(NOT production_count EQUAL 1) + message(FATAL_ERROR "Production sources changed: ${production_count}") + endif() + endif() + if(clangd AND Python3_Interpreter_FOUND) + run_checked("${Python3_EXECUTABLE}" "${CMAKE_CURRENT_LIST_DIR}/check_clangd.py" + "${clangd}" "${binary}" "${source}/include/fixture/navigation.hpp") + message(STATUS "PASS ${kind}: fresh clangd exact definition ranges before build") + else() + message(STATUS "SKIP ${kind} protocol check: clangd and Python3 required (enforce with CPP_LIBRARY_TEST_CLANGD=ON)") + endif() + endif() + run_checked("${CMAKE_COMMAND}" --build "${binary}") + file(GLOB_RECURSE index_objects + "${binary}/clangd/*.obj" "${binary}/clangd/*.o" + "${binary}/nested/clangd/*.obj" "${binary}/nested/clangd/*.o" + "${binary}/index/*.obj" "${binary}/index/*.o") + if(index_objects) + message(FATAL_ERROR "Default build compiled indexing objects: ${index_objects}") + endif() + if(EXISTS "${binary}/generated/fixture/build_generated.hpp") + message(FATAL_ERROR "Default build ran indexing-only generation prerequisites") + endif() + if(NOT kind MATCHES "^proof") + run_checked("${CMAKE_COMMAND}" --build "${binary}" --target generate_build_header) + endif() + run_checked("${CMAKE_COMMAND}" --build "${binary}" --target "${index_target}") + if(NOT kind MATCHES "^proof") + run_checked("${CMAKE_COMMAND}" --install "${binary}" --prefix "${binary}/installed") + file(GLOB_RECURSE installed "${binary}/installed/*") + foreach(path IN LISTS installed) + file(RELATIVE_PATH installed_path "${binary}/installed" "${path}") + if(installed_path MATCHES "(clangd|index|\\.(obj|o)$)") + message(FATAL_ERROR "Indexing artifact installed: ${path}") + endif() + if(path MATCHES "\\.cmake$") + file(READ "${path}" export_text) + if(export_text MATCHES "_cpp_library_clangd") + message(FATAL_ERROR "Indexing target exported: ${path}") + endif() + endif() + endforeach() + endif() + file(READ "${binary}/properties.txt" properties) + if(NOT properties STREQUAL "${header}\n") + message(FATAL_ERROR "Production source properties or FILE_SET changed: ${properties}") + endif() + if(clangd) + execute_process(COMMAND "${clangd}" "--check=${header}" + "--compile-commands-dir=${binary}" --tweaks= + RESULT_VARIABLE result OUTPUT_VARIABLE out ERROR_VARIABLE err) + if(NOT result EQUAL 0 OR NOT err MATCHES "Compile command from CDB is:" OR + err MATCHES "command inferred from") + message(FATAL_ERROR "clangd did not parse with its exact header command:\n${out}\n${err}") + endif() + else() + message(STATUS "SKIP clangd parse check: clangd not found") + endif() + message(STATUS "PASS ${kind}: exact configure-time header command, isolated properties and excluded build") +endforeach() +foreach(error_case IN ITEMS conflict missing imported executable relative) + set(source "${test_root}/errors/${error_case}/source") + file(MAKE_DIRECTORY "${source}") + file(COPY "${CMAKE_CURRENT_LIST_DIR}/fixtures/clangd/" DESTINATION "${source}") + execute_process(COMMAND "${CMAKE_COMMAND}" -S "${source}" + -B "${test_root}/errors/${error_case}/build" -G Ninja + "-DTOOLKIT_SOURCE=${toolkit_source}" "-DERROR_CASE=${error_case}" + -DCPP_LIBRARY_ENABLE_CLANGD=OFF + RESULT_VARIABLE result OUTPUT_VARIABLE out ERROR_VARIABLE err) + if(result EQUAL 0 OR NOT err MATCHES "cpp_library_add_clangd_headers") + message(FATAL_ERROR "Expected actionable registration error (${error_case}): ${out}\n${err}") + endif() + if(error_case STREQUAL conflict AND + (NOT err MATCHES "owner" OR NOT err MATCHES "other" OR NOT err MATCHES "api.hpp")) + message(FATAL_ERROR "Conflict diagnostic omitted ownership details: ${err}") + endif() + message(STATUS "PASS ${error_case}: registration rejected") +endforeach() +set(source "${test_root}/multi-config/source") +file(MAKE_DIRECTORY "${source}") +file(COPY "${CMAKE_CURRENT_LIST_DIR}/fixtures/clangd/" DESTINATION "${source}") +execute_process(COMMAND "${CMAKE_COMMAND}" -S "${source}" + -B "${test_root}/multi-config/build" -G "Ninja Multi-Config" + "-DTOOLKIT_SOURCE=${toolkit_source}" -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + RESULT_VARIABLE result OUTPUT_VARIABLE out ERROR_VARIABLE err) +if(result EQUAL 0 OR NOT err MATCHES "multi-configuration generators" OR + NOT err MATCHES "CPP_LIBRARY_ENABLE_CLANGD=OFF") + message(FATAL_ERROR "Missing actionable multi-config diagnostic:\n${out}\n${err}") +endif() +message(STATUS "PASS unsupported multi-config generator: explicit diagnostic") +set(unused_cli_option -Wno-unused-cli) +if(CMAKE_VERSION VERSION_LESS 4.4) + set(unused_cli_option --no-warn-unused-cli) +endif() +foreach(settings_case IN ITEMS fresh existing) + set(source "${test_root}/ws/${settings_case}/src") + set(binary "${test_root}/ws/${settings_case}/build") + file(MAKE_DIRECTORY "${source}") + file(COPY "${CMAKE_CURRENT_LIST_DIR}/fixtures/clangd/" DESTINATION "${source}") + set(settings "${source}/.vscode/settings.json") + set(user_settings [=[{ + "editor.tabSize": 7, + "cmake.copyCompileCommands": "${workspaceFolder}/custom/commands.json", + "cmake.postConfigureTask": "my-existing-hook" +} +]=]) + if(settings_case STREQUAL existing) + file(MAKE_DIRECTORY "${source}/.vscode") + file(WRITE "${settings}" "${user_settings}") + endif() + run_checked("${CMAKE_COMMAND}" -S "${source}" -B "${binary}" -G Ninja + "-DTOOLKIT_SOURCE=${toolkit_source}" -DWORKSPACE=ON + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON) + if(NOT EXISTS "${settings}") + message(FATAL_ERROR "Fresh workspace did not receive VSCode settings") + endif() + file(READ "${settings}" actual_settings) + if(settings_case STREQUAL existing) + if(NOT actual_settings STREQUAL user_settings) + message(FATAL_ERROR "Ordinary configure replaced existing workspace settings") + endif() + else() + string(JSON presets GET "${actual_settings}" cmake.useCMakePresets) + string(JSON configure GET "${actual_settings}" cmake.configureOnOpen) + string(JSON destination GET "${actual_settings}" cmake.copyCompileCommands) + if(NOT presets STREQUAL always OR NOT configure OR + NOT destination STREQUAL "\${workspaceFolder}/compile_commands.json") + message(FATAL_ERROR "Incorrect workspace configuration: ${actual_settings}") + endif() + string(JSON hook ERROR_VARIABLE hook_error GET "${actual_settings}" cmake.postConfigureTask) + if(NOT hook_error) + message(FATAL_ERROR "Template added a post-configure hook") + endif() + endif() + foreach(preset IN ITEMS default test) + run_checked("${CMAKE_COMMAND}" "${unused_cli_option}" --preset "${preset}" -S "${source}" + "-DTOOLKIT_SOURCE=${toolkit_source}" -DWORKSPACE=ON) + set(expected_context 1) + if(preset STREQUAL test) + set(expected_context 2) + endif() + check_header("${source}/build/${preset}/compile_commands.json" + "${source}/include/fixture/api.hpp" "PRESET_CONTEXT=${expected_context}") + if(EXISTS "${source}/compile_commands.json") + message(FATAL_ERROR "CLI configure unexpectedly wrote the editor's database copy") + endif() + endforeach() + file(READ "${settings}" preserved_settings) + if(NOT preserved_settings STREQUAL actual_settings) + message(FATAL_ERROR "Preset switches replaced workspace settings") + endif() + run_checked("${CMAKE_COMMAND}" -S "${source}" -B "${binary}" -DCPP_LIBRARY_FORCE_INIT=ON) + file(READ "${settings}" forced_settings) + file(READ "${toolkit_source}/templates/.vscode/settings.json" template_settings) + if(NOT forced_settings STREQUAL template_settings) + message(FATAL_ERROR "Force-init did not regenerate workspace settings") + endif() + message(STATUS "PASS ${settings_case}: settings policy and configure-only preset switching") +endforeach() +file(REMOVE_RECURSE "${test_root}") +message(STATUS "All clangd cases passed!")