From 3c7a7c1e9a2d435590bbb410d4953bd89914b18e Mon Sep 17 00:00:00 2001 From: Edward Nolan Date: Fri, 19 Jun 2026 10:10:56 +0100 Subject: [PATCH] Implement CMake support This use case comes out of the Beman project. So far most users of mpark/wg21 maintain their papers in separate repositories from those papers' reference implementations. For the Beman project, I wanted to make it possible to update the paper and reference implementation in lockstep by keeping them in the same repository. The issue is that the C++ reference implementations use CMake for their build system and mpark/wg21 currently hard depends on Make. At first, I attempted to work around this by pulling in mpark/wg21 via FetchContent and adding a custom command that called out to Make for building: ``` FetchContent_Declare( wg21 GIT_REPOSITORY https://github.com/mpark/wg21.git GIT_TAG 49c655869c14d80645962b6fceb503249f69bc9e EXCLUDE_FROM_ALL ) FetchContent_MakeAvailable(wg21) add_custom_command( OUTPUT P2728.html COMMAND ${CMAKE_COMMAND} -E env SRCDIR=${CMAKE_CURRENT_SOURCE_DIR} OUTDIR=${CMAKE_CURRENT_BINARY_DIR} make -f ${wg21_SOURCE_DIR}/Makefile html WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/P2728.md VERBATIM ) add_custom_target(p2728 ALL DEPENDS P2728.html) ``` But this has various disadvantages. Although the CMake best practice is to handle dependencies in terms of packages, and rely on the user to configure their environment such that those packages are made available to CMake, by unconditionally using FetchContent here we bypass CMake's package system and just pull mpark/wg21 down as a subdirectory. Then, the mpark/wg21 Makefile pulls down its own dependencies-- pandoc and the Python packages via venv-- also invisibly to CMake and against best practices. Both the dependencies that mpark/wg21 downloads and the reference data files it generates are added to subdirectories of the mpark/wg21 checkout instead of a separate build tree directory. And the custom command and target are easy to get wrong. This commit adds CMake machinery to mpark/wg21 as a pure addition; existing users of the Makefile aren't affected. The updated CMake corresponding to the excerpt above is now simply: ``` find_package(MparkWg21 REQUIRED) wg21_add_paper(P2728 ALL) ``` You can either satisfy the `find_package` call by `cmake --install`-ing mpark/wg21 into your environment, or replace the `find_package` call with `FetchContent`; both are supported. The following files are added: - CMakeLists.txt: Defines the project and configures installation and add_subdirectory/FetchContent support - cmake/FindMparkWg21.cmake: Implements the MparkWg21 CMake package discovery logic and loads the wg21_add_paper API - cmake/MparkWg21Config.cmake: Makes installed copies findable via CMAKE_PREFIX_PATH - cmake/FindPandoc.cmake: Helper to detect the pandoc executable in the environment (since pandoc, as a Haskell package, doesn't ship CMake package-config files) - cmake/MparkWg21Helpers.cmake: Defines wg21_add_paper, runs dependency checks, and implements logic for invoking refs.py/srefs.py/srefs-md.py - cmake/render.cmake: Script for wrapping invocations of toc-depth.py and pandoc Full disclosure: this change was produced using AI assistance, but I've gone over it closely. I've also verified that rendering tests/*.md with wg21_add_paper produces identical results to what the Makefile produces. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 48 ++++++++++ cmake/FindMparkWg21.cmake | 47 +++++++++ cmake/FindPandoc.cmake | 38 ++++++++ cmake/MparkWg21Config.cmake | 23 +++++ cmake/MparkWg21Helpers.cmake | 180 +++++++++++++++++++++++++++++++++++ cmake/render.cmake | 61 ++++++++++++ 6 files changed, 397 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 cmake/FindMparkWg21.cmake create mode 100644 cmake/FindPandoc.cmake create mode 100644 cmake/MparkWg21Config.cmake create mode 100644 cmake/MparkWg21Helpers.cmake create mode 100644 cmake/render.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..d53f8f3 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,48 @@ +# MPark.WG21 +# +# Distributed under the Boost Software License, Version 1.0. +# (See accompanying file LICENSE.md or copy at http://boost.org/LICENSE_1_0.txt) +# +# This CMake framework for mpark/wg21 provides the function wg21_add_paper +# (see cmake/MparkWg21Helpers.cmake). +# +# This top-level file either configures installation of mpark/wg21 for +# consumption using find_package(MparkWg21) or configures the project to be +# consumed via add_subdirectory(). + +cmake_minimum_required(VERSION 3.21) + +project(MparkWg21 + VERSION 0.1.0 + DESCRIPTION "Pandoc-based framework for authoring and reviewing WG21 papers" + HOMEPAGE_URL "https://github.com/mpark/wg21" + LANGUAGES NONE) + +if(PROJECT_IS_TOP_LEVEL) + include(GNUInstallDirs) + + install(DIRECTORY data/ + DESTINATION "${CMAKE_INSTALL_DATADIR}/mpark-wg21/data" + USE_SOURCE_PERMISSIONS) # pandoc runs the filters via their shebang; + # keep them executable + install(FILES + cmake/FindMparkWg21.cmake + cmake/MparkWg21Helpers.cmake + cmake/FindPandoc.cmake + cmake/render.cmake + DESTINATION "${CMAKE_INSTALL_DATADIR}/mpark-wg21/cmake") + + include(CMakePackageConfigHelpers) + write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/MparkWg21ConfigVersion.cmake" + COMPATIBILITY AnyNewerVersion + ARCH_INDEPENDENT) + install(FILES + cmake/MparkWg21Config.cmake + "${CMAKE_CURRENT_BINARY_DIR}/MparkWg21ConfigVersion.cmake" + DESTINATION "${CMAKE_INSTALL_DATADIR}/cmake/MparkWg21") +else() + set(MparkWg21_DATA_SRC "${CMAKE_CURRENT_SOURCE_DIR}/data") + set(MparkWg21_RENDER_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/cmake/render.cmake") + include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/MparkWg21Helpers.cmake") +endif() diff --git a/cmake/FindMparkWg21.cmake b/cmake/FindMparkWg21.cmake new file mode 100644 index 0000000..1002172 --- /dev/null +++ b/cmake/FindMparkWg21.cmake @@ -0,0 +1,47 @@ +# FindMparkWg21 -- locate the framework's data/ tree and render.cmake, then +# include MparkWg21Helpers.cmake to define the build API (wg21_add_paper() and +# the wg21_data target). Sets MparkWg21_FOUND / MparkWg21_DATA_SRC / +# MparkWg21_RENDER_SCRIPT. Self-locates relative to this file in both the +# source-checkout and install layouts; MparkWg21_ROOT overrides. + +find_path(MparkWg21_DATA_SRC + NAMES metadata.yaml + HINTS "${CMAKE_CURRENT_LIST_DIR}/../data" + ${MparkWg21_ROOT} ENV MparkWg21_ROOT + PATH_SUFFIXES data share/mpark-wg21/data + NO_DEFAULT_PATH + DOC "MPark/WG21 source data directory") + +find_file(MparkWg21_RENDER_SCRIPT + NAMES render.cmake + HINTS "${CMAKE_CURRENT_LIST_DIR}" + ${MparkWg21_ROOT} ENV MparkWg21_ROOT + PATH_SUFFIXES cmake share/mpark-wg21/cmake + NO_DEFAULT_PATH + DOC "MPark/WG21 render.cmake") + +# The helpers find the tools (Pandoc, Python3 + modules) and, on success, +# define the build API. Tool failures land in MparkWg21_TOOLS_MISSING and are +# reported below through FPHSA, which honors REQUIRED/QUIET. +set(MparkWg21_TOOLS_MISSING "") +if(MparkWg21_DATA_SRC AND MparkWg21_RENDER_SCRIPT) + set(_MparkWg21_from_find_module TRUE) + include("${CMAKE_CURRENT_LIST_DIR}/MparkWg21Helpers.cmake") + unset(_MparkWg21_from_find_module) +endif() + +if(MparkWg21_TOOLS_MISSING) + set(MparkWg21_TOOLS "MparkWg21_TOOLS-NOTFOUND") + # Join without semicolons: the reason passes through FPHSA's arg parsing. + list(JOIN MparkWg21_TOOLS_MISSING " and " _MparkWg21_reason) +else() + set(MparkWg21_TOOLS TRUE) + set(_MparkWg21_reason "") +endif() + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(MparkWg21 + REQUIRED_VARS MparkWg21_DATA_SRC MparkWg21_RENDER_SCRIPT MparkWg21_TOOLS + REASON_FAILURE_MESSAGE "${_MparkWg21_reason}") + +mark_as_advanced(MparkWg21_DATA_SRC MparkWg21_RENDER_SCRIPT) diff --git a/cmake/FindPandoc.cmake b/cmake/FindPandoc.cmake new file mode 100644 index 0000000..80816b2 --- /dev/null +++ b/cmake/FindPandoc.cmake @@ -0,0 +1,38 @@ +# FindPandoc -- locate the pandoc executable (it ships no CMake config) and +# probe its version. Sets Pandoc_FOUND / Pandoc_EXECUTABLE / Pandoc_VERSION and +# a GLOBAL imported target Pandoc::pandoc. Hint: Pandoc_ROOT (or a system +# pandoc on PATH). + +find_program(Pandoc_EXECUTABLE + NAMES pandoc + HINTS ${Pandoc_ROOT} ENV Pandoc_ROOT + PATH_SUFFIXES bin + DOC "Path to the pandoc executable") + +if(Pandoc_EXECUTABLE) + execute_process( + COMMAND "${Pandoc_EXECUTABLE}" --version + OUTPUT_VARIABLE _pandoc_version_output + ERROR_QUIET + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _pandoc_version_rc) + if(_pandoc_version_rc EQUAL 0 AND + _pandoc_version_output MATCHES "pandoc[^0-9]*([0-9]+(\\.[0-9]+)+)") + set(Pandoc_VERSION "${CMAKE_MATCH_1}") + endif() + unset(_pandoc_version_output) + unset(_pandoc_version_rc) +endif() + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(Pandoc + REQUIRED_VARS Pandoc_EXECUTABLE + VERSION_VAR Pandoc_VERSION) + +if(Pandoc_FOUND AND NOT TARGET Pandoc::pandoc) + add_executable(Pandoc::pandoc IMPORTED GLOBAL) + set_target_properties(Pandoc::pandoc PROPERTIES + IMPORTED_LOCATION "${Pandoc_EXECUTABLE}") +endif() + +mark_as_advanced(Pandoc_EXECUTABLE) diff --git a/cmake/MparkWg21Config.cmake b/cmake/MparkWg21Config.cmake new file mode 100644 index 0000000..91b2559 --- /dev/null +++ b/cmake/MparkWg21Config.cmake @@ -0,0 +1,23 @@ +# MPark.WG21 +# +# Distributed under the Boost Software License, Version 1.0. +# (See accompanying file LICENSE.md or copy at http://boost.org/LICENSE_1_0.txt) +# +# Config-mode entry point, so `cmake --install` + CMAKE_PREFIX_PATH works with +# no CMAKE_MODULE_PATH setup. Delegates to the find module, which does the +# real discovery and defines the build API. Works from both the install +# layout (share/cmake/MparkWg21/, with the module under +# share/mpark-wg21/cmake/) and a source checkout (side by side in cmake/). + +foreach(_mparkwg21_dir + "${CMAKE_CURRENT_LIST_DIR}" + "${CMAKE_CURRENT_LIST_DIR}/../../mpark-wg21/cmake") + if(EXISTS "${_mparkwg21_dir}/FindMparkWg21.cmake") + include("${_mparkwg21_dir}/FindMparkWg21.cmake") + break() + endif() +endforeach() + +if(NOT MparkWg21_FOUND) + set(MparkWg21_NOT_FOUND_MESSAGE "${_MparkWg21_reason}") +endif() diff --git a/cmake/MparkWg21Helpers.cmake b/cmake/MparkWg21Helpers.cmake new file mode 100644 index 0000000..f460bb0 --- /dev/null +++ b/cmake/MparkWg21Helpers.cmake @@ -0,0 +1,180 @@ +# MPark.WG21 +# +# Distributed under the Boost Software License, Version 1.0. +# (See accompanying file LICENSE.md or copy at http://boost.org/LICENSE_1_0.txt) +# +# Defines wg21_add_paper() + the shared staging/generation targets. Included by +# FindMparkWg21.cmake. Set before including: +# MparkWg21_DATA_SRC - the framework's source data/ directory +# MparkWg21_RENDER_SCRIPT - path to render.cmake + +# find_package() re-runs its module on each call; define everything once. +if(COMMAND wg21_add_paper) + return() +endif() + +# FindPandoc.cmake is shipped alongside this file. +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}") + +# Transitive tools (raw find_package; the caller satisfies them). The Python +# modules are asserted, not provisioned -- that's the caller's job. Failures +# are collected in MparkWg21_TOOLS_MISSING: when included from FindMparkWg21 +# (which sets _MparkWg21_from_find_module) they become a standard not-found +# result honoring REQUIRED/QUIET; a direct add_subdirectory fails here. +if(MparkWg21_FIND_QUIETLY) + set(_mparkwg21_quiet QUIET) +else() + set(_mparkwg21_quiet) +endif() + +set(MparkWg21_TOOLS_MISSING "") +set(_mparkwg21_py_err "") + +find_package(Python3 3.10 ${_mparkwg21_quiet} COMPONENTS Interpreter) +if(Python3_FOUND) + set(_mparkwg21_modules panflute bs4 lxml requests yaml) + list(JOIN _mparkwg21_modules ", " _mparkwg21_imports) + execute_process( + COMMAND "${Python3_EXECUTABLE}" -c "import ${_mparkwg21_imports}" + RESULT_VARIABLE _mparkwg21_py_rc + ERROR_VARIABLE _mparkwg21_py_err) + if(NOT _mparkwg21_py_rc EQUAL 0) + # No semicolons here: the string passes through FPHSA's argument parsing. + list(APPEND MparkWg21_TOOLS_MISSING + "Python modules for ${Python3_EXECUTABLE} (need ${_mparkwg21_imports} -- \ +try `pip install panflute beautifulsoup4 lxml requests pyyaml`)") + endif() +else() + list(APPEND MparkWg21_TOOLS_MISSING "Python3 >= 3.10") +endif() + +find_package(Pandoc 3.9.0.2 ${_mparkwg21_quiet}) +if(NOT Pandoc_FOUND) + list(APPEND MparkWg21_TOOLS_MISSING "Pandoc >= 3.9.0.2 (hint: Pandoc_ROOT)") +endif() + +if(MparkWg21_TOOLS_MISSING) + if(_MparkWg21_from_find_module) + return() + endif() + list(JOIN MparkWg21_TOOLS_MISSING "\n " _mparkwg21_missing) + message(FATAL_ERROR + "MparkWg21: missing tools:\n ${_mparkwg21_missing}\n${_mparkwg21_py_err}") +endif() + +set(MparkWg21_RENDER_SCRIPT "${MparkWg21_RENDER_SCRIPT}" CACHE INTERNAL "") +# Unlike find_program results, FindPython3's Python3_EXECUTABLE is scope-local; +# snapshot it so wg21_add_paper sees it from any call site. +set(MparkWg21_PYTHON "${Python3_EXECUTABLE}" CACHE INTERNAL "") + +# Staged data dir in the build tree: pandoc's --data-dir must hold the +# committed data/ files plus the generated reference files. Staging there keeps +# the source/install read-only; one shared location means one stage + one fetch +# per build. +set(MparkWg21_STAGED_DATA_DIR "${CMAKE_BINARY_DIR}/mpark-wg21/data" + CACHE INTERNAL "wg21 staged data dir") +set(_sentinel "${MparkWg21_STAGED_DATA_DIR}/metadata.yaml") +set(_csl "${MparkWg21_STAGED_DATA_DIR}/csl.json") +set(_srefs "${MparkWg21_STAGED_DATA_DIR}/srefs.json") +set(_srefs_defs "${MparkWg21_STAGED_DATA_DIR}/srefs.defs") + +# The copied metadata.yaml is the sentinel for the bulk copy. Deliberately no +# DEPENDS: staging runs once, edits under data/ need a fresh build tree. +add_custom_command( + OUTPUT "${_sentinel}" + COMMAND "${CMAKE_COMMAND}" -E copy_directory + "${MparkWg21_DATA_SRC}" "${MparkWg21_STAGED_DATA_DIR}" + COMMENT "Staging wg21 data directory" + VERBATIM) + +# Generated reference data (network-backed; fetched once). Paths go in as +# positional args ($1..) to keep them out of the `bash -c` string. +add_custom_command( + OUTPUT "${_csl}" + COMMAND bash -c "\"$1\" \"$2\" > \"$3\"" + wg21 "${Python3_EXECUTABLE}" "${MparkWg21_DATA_SRC}/refs.py" "${_csl}" + DEPENDS "${MparkWg21_DATA_SRC}/refs.py" "${_sentinel}" + COMMENT "Fetching citation reference data (network)" + VERBATIM) + +add_custom_command( + OUTPUT "${_srefs}" + COMMAND bash -c "\"$1\" \"$2\" > \"$3\"" + wg21 "${Python3_EXECUTABLE}" "${MparkWg21_DATA_SRC}/srefs.py" "${_srefs}" + DEPENDS "${MparkWg21_DATA_SRC}/srefs.py" "${_sentinel}" + COMMENT "Fetching stable-name reference data (network)" + VERBATIM) + +add_custom_command( + OUTPUT "${_srefs_defs}" + COMMAND bash -c "\"$1\" \"$2\" < \"$3\" > \"$4\"" + wg21 "${Python3_EXECUTABLE}" "${MparkWg21_DATA_SRC}/srefs-md.py" + "${_srefs}" "${_srefs_defs}" + DEPENDS "${MparkWg21_DATA_SRC}/srefs-md.py" "${_srefs}" "${_sentinel}" + COMMENT "Generating stable-name markdown" + VERBATIM) + +add_custom_target(wg21_data DEPENDS "${_sentinel}" "${_csl}" "${_srefs}" "${_srefs_defs}") + +# wg21_add_paper( +# [SOURCE ] default: .md in the caller's dir +# [FORMATS ...] default: html +# [OUTDIR ] default: caller's CMAKE_CURRENT_BINARY_DIR +# [ALL]) attach to the default build target +function(wg21_add_paper name) + cmake_parse_arguments(PARSE_ARGV 1 ARG + "ALL" + "SOURCE;OUTDIR" + "FORMATS") + if(ARG_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "wg21_add_paper(${name}): unexpected arguments: " + "${ARG_UNPARSED_ARGUMENTS}") + endif() + + if(NOT ARG_SOURCE) + set(ARG_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/${name}.md") + endif() + if(NOT ARG_FORMATS) + set(ARG_FORMATS html) + endif() + if(NOT ARG_OUTDIR) + set(ARG_OUTDIR "${CMAKE_CURRENT_BINARY_DIR}") + endif() + + get_filename_component(_venv_bin "${MparkWg21_PYTHON}" DIRECTORY) + set(_data "${MparkWg21_STAGED_DATA_DIR}") + # Render from the source's directory so relative resources (e.g. images) + # resolve, as they do when running make from a paper directory. + get_filename_component(_src_dir "${ARG_SOURCE}" DIRECTORY) + + set(_outputs "") + foreach(fmt IN LISTS ARG_FORMATS) + set(_out "${ARG_OUTDIR}/${name}.${fmt}") + add_custom_command( + OUTPUT "${_out}" + COMMAND "${CMAKE_COMMAND}" + "-DPANDOC=${Pandoc_EXECUTABLE}" + "-DPYTHON=${MparkWg21_PYTHON}" + "-DVENV_BIN=${_venv_bin}" + "-DSRC=${ARG_SOURCE}" + "-DOUT=${_out}" + "-DFORMAT=${fmt}" + "-DDATA_DIR=${_data}" + -P "${MparkWg21_RENDER_SCRIPT}" + DEPENDS "${ARG_SOURCE}" + "${Pandoc_EXECUTABLE}" + "${_data}/metadata.yaml" + "${_data}/csl.json" "${_data}/srefs.json" "${_data}/srefs.defs" + WORKING_DIRECTORY "${_src_dir}" + COMMENT "Rendering ${name}.${fmt}" + VERBATIM) + list(APPEND _outputs "${_out}") + endforeach() + + if(ARG_ALL) + add_custom_target(${name} ALL DEPENDS ${_outputs}) + else() + add_custom_target(${name} DEPENDS ${_outputs}) + endif() + add_dependencies(${name} wg21_data) +endfunction() diff --git a/cmake/render.cmake b/cmake/render.cmake new file mode 100644 index 0000000..598b98a --- /dev/null +++ b/cmake/render.cmake @@ -0,0 +1,61 @@ +# MPark.WG21 +# +# Distributed under the Boost Software License, Version 1.0. +# (See accompanying file LICENSE.md or copy at http://boost.org/LICENSE_1_0.txt) +# +# Build-time Pandoc driver (cmake -P). Inputs via -D: +# PANDOC, SRC, OUT, VENV_BIN, FORMAT, DATA_DIR, PYTHON + +foreach(_required PANDOC SRC OUT FORMAT DATA_DIR) + if(NOT DEFINED ${_required}) + message(FATAL_ERROR "render.cmake: ${_required} is required") + endif() +endforeach() + +# venv first on PATH so pandoc's filters resolve python3 (with panflute) via +# their shebang. Unix-only for now. +get_filename_component(_pandoc_dir "${PANDOC}" DIRECTORY) +set(_prefix "${_pandoc_dir}") +if(DEFINED VENV_BIN AND NOT VENV_BIN STREQUAL "") + set(_prefix "${VENV_BIN}:${_prefix}") +endif() +set(ENV{PATH} "${_prefix}:$ENV{PATH}") + +# srefs.defs (stable-reference link defs) is concatenated before the source +# so definitions in the user's file take precedence, matching base.mk. +set(_args "") +if(EXISTS "${DATA_DIR}/srefs.defs") + list(APPEND _args "${DATA_DIR}/srefs.defs") +endif() +list(APPEND _args "${SRC}" + -o "${OUT}" + "--data-dir=${DATA_DIR}" + -M "data-dir=${DATA_DIR}" + -d doc -d formatting) + +# Bridge the source's front-matter toc-depth into --toc-depth (html only; +# pandoc ignores the front-matter value otherwise). +if(FORMAT STREQUAL "html") + if(NOT DEFINED PYTHON) + message(FATAL_ERROR "render.cmake: PYTHON is required for html output") + endif() + execute_process( + COMMAND "${PYTHON}" "${DATA_DIR}/toc-depth.py" + INPUT_FILE "${SRC}" + OUTPUT_VARIABLE _toc_depth + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _rc) + if(NOT _rc EQUAL 0) + message(FATAL_ERROR "render.cmake: toc-depth.py failed for ${SRC} (exit ${_rc})") + endif() + if(_toc_depth) + list(APPEND _args --toc-depth ${_toc_depth}) + endif() +endif() + +execute_process( + COMMAND "${PANDOC}" ${_args} + RESULT_VARIABLE _rc) +if(NOT _rc EQUAL 0) + message(FATAL_ERROR "render.cmake: pandoc failed for ${SRC} -> ${OUT} (exit ${_rc})") +endif()