From 1ed8953b354d01ba1b03f1c8458380c4b2abd647 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Mon, 10 Aug 2026 16:47:01 +0200 Subject: [PATCH 1/3] fix: the public API is exported when MrDocs is built `MRDOCS_DECL` expanded to nothing in a static build, so it marked nothing and its misuse went unnoticed: it was applied to some classes *and* some of their members, which MSVC rejects. `MRDOCS_TOOL`, which says MrDocs itself is being built, now comes first in every library that exports part of the API, so the symbols a plugin calls end up in the tool's export table, while a static consumer still sees plain declarations. `assert_failed`, the out-of-line members of `Corpus`, and the comparison operators a plugin reaches through the headers gain an attribute instead, since nothing else marks them and a plugin cannot call what the tool does not export. Exporting the API also makes MSVC warn about the standard library members of the exported types, which the libraries now suppress as mrdocs-core already did. The attributes move to an include of their own because Platform.hpp includes Assert.hpp first. --- include/mrdocs/Corpus.hpp | 11 ++++ include/mrdocs/Engines/JavaScript/Value.hpp | 16 +++-- include/mrdocs/Engines/Lua/Context.hpp | 1 - include/mrdocs/Metadata/DocComment.hpp | 1 - include/mrdocs/Metadata/Name/NameBase.hpp | 1 + include/mrdocs/Metadata/Symbol/Concept.hpp | 1 + include/mrdocs/Metadata/Symbol/Function.hpp | 1 + include/mrdocs/Metadata/Symbol/Guide.hpp | 1 + include/mrdocs/Metadata/Symbol/Namespace.hpp | 2 + include/mrdocs/Metadata/Symbol/Record.hpp | 1 + include/mrdocs/Metadata/Symbol/Typedef.hpp | 2 + include/mrdocs/Metadata/Symbol/Variable.hpp | 2 + include/mrdocs/Platform.hpp | 35 +---------- .../Support/Concurrency/ExecutorGroup.hpp | 2 +- include/mrdocs/Support/Error/Assert.hpp | 2 + include/mrdocs/Support/Export.hpp | 60 +++++++++++++++++++ libs/CMakeLists.txt | 6 +- libs/dom/CMakeLists.txt | 5 +- libs/dom/include/mrdocs/Dom/Platform.hpp | 21 ++++--- libs/handlebars/CMakeLists.txt | 1 + .../include/mrdocs/Handlebars/Platform.hpp | 21 ++++--- 21 files changed, 131 insertions(+), 62 deletions(-) create mode 100644 include/mrdocs/Support/Export.hpp diff --git a/include/mrdocs/Corpus.hpp b/include/mrdocs/Corpus.hpp index 9235c66d8c..6e76db2b73 100644 --- a/include/mrdocs/Corpus.hpp +++ b/include/mrdocs/Corpus.hpp @@ -92,11 +92,13 @@ class MRDOCS_VISIBLE /** Return the begin iterator for the index of all symbols. */ + MRDOCS_DECL iterator begin() const noexcept; /** Return the end iterator for the index. */ + MRDOCS_DECL iterator end() const noexcept; @@ -109,6 +111,7 @@ class MRDOCS_VISIBLE @return true if the corpus is empty, otherwise false. */ + MRDOCS_DECL bool empty() const noexcept; @@ -132,11 +135,13 @@ class MRDOCS_VISIBLE @return The matching Symbol, or an error if not found. If multiple symbols match, one is returned arbitrarily. */ + MRDOCS_DECL Expected lookup(SymbolID const& context, std::string_view name) const; /** Return the Symbol with the matching ID, or nullptr. */ + MRDOCS_DECL Symbol const* find(SymbolID const& id) const noexcept; @@ -172,6 +177,7 @@ class MRDOCS_VISIBLE /** Return the metadata for the global namespace. */ + MRDOCS_DECL NamespaceSymbol const& globalNamespace() const noexcept; @@ -336,6 +342,7 @@ class MRDOCS_VISIBLE @param I The Symbol to get the qualified name for. @param temp The string to store the result in. */ + MRDOCS_DECL void qualifiedName( Symbol const& I, @@ -356,6 +363,7 @@ class MRDOCS_VISIBLE @param context The context used to qualify the name. @param result Output string receiving the name. */ + MRDOCS_DECL void qualifiedName( Symbol const& I, @@ -374,6 +382,7 @@ class MRDOCS_VISIBLE /** Finalize the corpus. */ + MRDOCS_DECL void finalize(Config const& config); @@ -402,6 +411,7 @@ class MRDOCS_VISIBLE /** Return the Symbol with the specified symbol ID, or nullptr. */ + MRDOCS_DECL Symbol* find(SymbolID const& id) noexcept; @@ -461,6 +471,7 @@ class MRDOCS_VISIBLE /// @copydoc lookup(SymbolID const&, std::string_view) const + MRDOCS_DECL Expected lookup(SymbolID const& context, std::string_view name); diff --git a/include/mrdocs/Engines/JavaScript/Value.hpp b/include/mrdocs/Engines/JavaScript/Value.hpp index 6c3bc4eb7e..5c1d9424e0 100644 --- a/include/mrdocs/Engines/JavaScript/Value.hpp +++ b/include/mrdocs/Engines/JavaScript/Value.hpp @@ -74,7 +74,7 @@ class MRDOCS_DECL Value Releases the underlying engine handle; lifetime is tied to the shared @ref Context::Impl, not to a stack frame. */ - MRDOCS_DECL ~Value(); + ~Value(); /** Constructor @@ -84,35 +84,35 @@ class MRDOCS_DECL Value The value is undefined. */ - MRDOCS_DECL Value() noexcept; + Value() noexcept; /** Constructor Duplicates the underlying engine handle held by `value` and shares the same runtime state. */ - MRDOCS_DECL Value(Value const&); + Value(Value const&); /** Constructor The function associates the existing value with this object. */ - MRDOCS_DECL Value(Value&&) noexcept; + Value(Value&&) noexcept; /** Copy assignment. @copydetails Value(Value const&) */ - MRDOCS_DECL Value& operator=(Value const&); + Value& operator=(Value const&); /** Move assignment. @copydetails Value(Value&&) */ - MRDOCS_DECL Value& operator=(Value&&) noexcept; + Value& operator=(Value&&) noexcept; /** Return the type of the value. @@ -135,7 +135,7 @@ class MRDOCS_DECL Value internal ECMAScript class `Function`. */ - MRDOCS_DECL Type type() const noexcept; + Type type() const noexcept; /** Check if the value is undefined. @@ -389,7 +389,6 @@ class MRDOCS_DECL Value @param key The key to set. @param value The value to set. */ - MRDOCS_DECL void set( std::string_view key, @@ -400,7 +399,6 @@ class MRDOCS_DECL Value @param key The key to set. @param value The value to set. */ - MRDOCS_DECL void set( std::string_view key, diff --git a/include/mrdocs/Engines/Lua/Context.hpp b/include/mrdocs/Engines/Lua/Context.hpp index bba29601bf..a1e5bc1809 100644 --- a/include/mrdocs/Engines/Lua/Context.hpp +++ b/include/mrdocs/Engines/Lua/Context.hpp @@ -58,7 +58,6 @@ class MRDOCS_DECL the operation you need (for example, registering a native C function that the script can call as a global). */ - MRDOCS_DECL void* nativeState() const noexcept; }; diff --git a/include/mrdocs/Metadata/DocComment.hpp b/include/mrdocs/Metadata/DocComment.hpp index 89e0fcc3e6..b40809bc5f 100644 --- a/include/mrdocs/Metadata/DocComment.hpp +++ b/include/mrdocs/Metadata/DocComment.hpp @@ -130,7 +130,6 @@ struct MRDOCS_DECL DocComment { /** Constructor. */ - MRDOCS_DECL DocComment() noexcept; /** Constructor diff --git a/include/mrdocs/Metadata/Name/NameBase.hpp b/include/mrdocs/Metadata/Name/NameBase.hpp index 23c031bd80..2c4af1c905 100644 --- a/include/mrdocs/Metadata/Name/NameBase.hpp +++ b/include/mrdocs/Metadata/Name/NameBase.hpp @@ -73,6 +73,7 @@ struct Name /** Order names by kind, identifier, id, and prefix. */ + MRDOCS_DECL std::strong_ordering operator<=>(Name const& other) const; diff --git a/include/mrdocs/Metadata/Symbol/Concept.hpp b/include/mrdocs/Metadata/Symbol/Concept.hpp index e2603bc125..06daf37e17 100644 --- a/include/mrdocs/Metadata/Symbol/Concept.hpp +++ b/include/mrdocs/Metadata/Symbol/Concept.hpp @@ -47,6 +47,7 @@ struct ConceptSymbol final /** Compare concept symbols by base info, template, and constraint. */ + MRDOCS_DECL std::strong_ordering operator<=>(ConceptSymbol const& other) const; }; diff --git a/include/mrdocs/Metadata/Symbol/Function.hpp b/include/mrdocs/Metadata/Symbol/Function.hpp index ce0ab238ff..b5eb58c199 100644 --- a/include/mrdocs/Metadata/Symbol/Function.hpp +++ b/include/mrdocs/Metadata/Symbol/Function.hpp @@ -169,6 +169,7 @@ struct FunctionSymbol final /** Compare functions by signature, qualifiers, and metadata. */ + MRDOCS_DECL std::strong_ordering operator<=>(FunctionSymbol const& other) const; }; diff --git a/include/mrdocs/Metadata/Symbol/Guide.hpp b/include/mrdocs/Metadata/Symbol/Guide.hpp index db6abd33da..b744a818ca 100644 --- a/include/mrdocs/Metadata/Symbol/Guide.hpp +++ b/include/mrdocs/Metadata/Symbol/Guide.hpp @@ -58,6 +58,7 @@ struct GuideSymbol final /** Compare guides by params/deduced/template/explicit. */ + MRDOCS_DECL std::strong_ordering operator<=>(GuideSymbol const& other) const; }; diff --git a/include/mrdocs/Metadata/Symbol/Namespace.hpp b/include/mrdocs/Metadata/Symbol/Namespace.hpp index 99ccf6aebb..0b1c849048 100644 --- a/include/mrdocs/Metadata/Symbol/Namespace.hpp +++ b/include/mrdocs/Metadata/Symbol/Namespace.hpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -121,6 +122,7 @@ struct NamespaceSymbol final /** Compare namespaces by attributes and member lists. */ + MRDOCS_DECL std::strong_ordering operator<=>(NamespaceSymbol const&) const; }; diff --git a/include/mrdocs/Metadata/Symbol/Record.hpp b/include/mrdocs/Metadata/Symbol/Record.hpp index e0068b141e..2093cd5276 100644 --- a/include/mrdocs/Metadata/Symbol/Record.hpp +++ b/include/mrdocs/Metadata/Symbol/Record.hpp @@ -123,6 +123,7 @@ struct RecordSymbol final /** Compare records including bases, members, and flags. */ + MRDOCS_DECL std::strong_ordering operator<=>(RecordSymbol const& other) const; }; diff --git a/include/mrdocs/Metadata/Symbol/Typedef.hpp b/include/mrdocs/Metadata/Symbol/Typedef.hpp index 427d81f2f3..b7ca25035f 100644 --- a/include/mrdocs/Metadata/Symbol/Typedef.hpp +++ b/include/mrdocs/Metadata/Symbol/Typedef.hpp @@ -20,6 +20,7 @@ #include #include #include +#include #include namespace mrdocs { @@ -62,6 +63,7 @@ struct TypedefSymbol final /** Compare typedef symbols, including alias target and template. */ + MRDOCS_DECL std::strong_ordering operator<=>(TypedefSymbol const& other) const; diff --git a/include/mrdocs/Metadata/Symbol/Variable.hpp b/include/mrdocs/Metadata/Symbol/Variable.hpp index 9ba0adf1dc..7df425b74d 100644 --- a/include/mrdocs/Metadata/Symbol/Variable.hpp +++ b/include/mrdocs/Metadata/Symbol/Variable.hpp @@ -20,6 +20,7 @@ #include #include #include +#include #include namespace mrdocs { @@ -97,6 +98,7 @@ struct VariableSymbol final /** Compare variables by type, flags, and initializer. */ + MRDOCS_DECL std::strong_ordering operator<=>(VariableSymbol const& other) const; }; diff --git a/include/mrdocs/Platform.hpp b/include/mrdocs/Platform.hpp index d7b8e75ab8..640dbe4888 100644 --- a/include/mrdocs/Platform.hpp +++ b/include/mrdocs/Platform.hpp @@ -13,6 +13,7 @@ #define MRDOCS_API_PLATFORM_HPP #include +#include #include #if __cplusplus < 202002L @@ -31,40 +32,6 @@ namespace mrdocs { */ #define MRDOCS_MINIMUM_LLVM_VERSION 15 -//------------------------------------------------ -// -// Shared Libraries -// -//------------------------------------------------ - -// static linking -#if defined(MRDOCS_STATIC_LINK) -# define MRDOCS_DECL -# define MRDOCS_VISIBLE - -// MSVC -#elif defined(_MSC_VER) -# define MRDOCS_SYMBOL_EXPORT __declspec(dllexport) -# define MRDOCS_SYMBOL_IMPORT __declspec(dllimport) -# if defined(MRDOCS_TOOL) // building tool -# define MRDOCS_DECL MRDOCS_SYMBOL_EXPORT -# else -# define MRDOCS_DECL MRDOCS_SYMBOL_IMPORT -# endif -# define MRDOCS_VISIBLE - -// (unknown) -#elif defined(__GNUC__) -# if defined(MRDOCS_TOOL) // building library -# define MRDOCS_DECL -# else -# define MRDOCS_DECL __attribute__((__visibility__("default"))) -#endif -# define MRDOCS_VISIBLE __attribute__((__visibility__("default"))) -#else -# error unknown platform for dynamic linking -#endif - //------------------------------------------------ #ifndef FMT_CONSTEVAL diff --git a/include/mrdocs/Support/Concurrency/ExecutorGroup.hpp b/include/mrdocs/Support/Concurrency/ExecutorGroup.hpp index a2ff33699d..afe31588df 100644 --- a/include/mrdocs/Support/Concurrency/ExecutorGroup.hpp +++ b/include/mrdocs/Support/Concurrency/ExecutorGroup.hpp @@ -35,7 +35,7 @@ class MRDOCS_DECL ExecutorGroupBase protected: /** Type-erased agent holder used by the base class. */ - struct MRDOCS_DECL AnyAgent + struct AnyAgent { /** Virtual destructor to allow deleting through the base pointer. */ diff --git a/include/mrdocs/Support/Error/Assert.hpp b/include/mrdocs/Support/Error/Assert.hpp index c2090db8d3..2d9f3ce2c0 100644 --- a/include/mrdocs/Support/Error/Assert.hpp +++ b/include/mrdocs/Support/Error/Assert.hpp @@ -12,6 +12,7 @@ #ifndef MRDOCS_API_SUPPORT_ERROR_ASSERT_HPP #define MRDOCS_API_SUPPORT_ERROR_ASSERT_HPP +#include #include /** Core MrDocs support utilities. @@ -42,6 +43,7 @@ namespace mrdocs { @param file Source file where the assertion triggered. @param line Line within the source file. */ + MRDOCS_DECL void assert_failed( char const* msg, diff --git a/include/mrdocs/Support/Export.hpp b/include/mrdocs/Support/Export.hpp new file mode 100644 index 0000000000..0e387de5d8 --- /dev/null +++ b/include/mrdocs/Support/Export.hpp @@ -0,0 +1,60 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2023 Vinnie Falco (vinnie.falco@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +// The attributes that carry the public API across a shared library +// boundary. They live in their own header because the lowest-level +// headers, which mrdocs/Platform.hpp itself pulls in, need them too. + +#ifndef MRDOCS_API_SUPPORT_EXPORT_HPP +#define MRDOCS_API_SUPPORT_EXPORT_HPP + +//------------------------------------------------ +// +// Shared Libraries +// +//------------------------------------------------ + +// MRDOCS_TOOL is defined when MrDocs itself is being built, and it comes +// first: the API is marked for export even in a static build, because that +// is what puts the symbols a plugin calls in the tool's export table. A +// static consumer of the library, which defines MRDOCS_STATIC_LINK and not +// MRDOCS_TOOL, still sees plain declarations. + +// MSVC +#if defined(_MSC_VER) +# define MRDOCS_SYMBOL_EXPORT __declspec(dllexport) +# define MRDOCS_SYMBOL_IMPORT __declspec(dllimport) +# if defined(MRDOCS_TOOL) // building MrDocs +# define MRDOCS_DECL MRDOCS_SYMBOL_EXPORT +# elif defined(MRDOCS_STATIC_LINK) +# define MRDOCS_DECL +# else +# define MRDOCS_DECL MRDOCS_SYMBOL_IMPORT +# endif +# define MRDOCS_VISIBLE + +// (unknown) +#elif defined(__GNUC__) +# if defined(MRDOCS_TOOL) // building MrDocs +# define MRDOCS_DECL +# define MRDOCS_VISIBLE __attribute__((__visibility__("default"))) +# elif defined(MRDOCS_STATIC_LINK) +# define MRDOCS_DECL +# define MRDOCS_VISIBLE +# else +# define MRDOCS_DECL __attribute__((__visibility__("default"))) +# define MRDOCS_VISIBLE __attribute__((__visibility__("default"))) +# endif +#else +# error unknown platform for dynamic linking +#endif + +#endif // MRDOCS_API_SUPPORT_EXPORT_HPP diff --git a/libs/CMakeLists.txt b/libs/CMakeLists.txt index 1ac50e6c2a..4e692a63ba 100644 --- a/libs/CMakeLists.txt +++ b/libs/CMakeLists.txt @@ -37,7 +37,11 @@ elseif (MSVC) # exceptions, and without it MSVC raises C4530 (fatal under /WX). /permissive- # puts the compiler in conformance mode so /W4 diagnostics match those under # which mrdocs-core builds this same code. These match mrdocs-core's options. - add_compile_options(/permissive- /EHs /MP) + # /wd4251 and /wd4275: these libraries export their API so that a plugin + # can call it, and their exported types have standard library members and + # bases. mrdocs-core needs the same two, and gets them from LLVM's + # HandleLLVMOptions, which is included in src/ and so does not reach here. + add_compile_options(/permissive- /EHs /MP /wd4251 /wd4275) # On develop this code lived in mrdocs-core and inherited these Windows # defines from the global add_definitions(${LLVM_DEFINITIONS}); as decoupled # libraries that never link LLVM, they must set them directly. diff --git a/libs/dom/CMakeLists.txt b/libs/dom/CMakeLists.txt index 58912959f7..92c1440a95 100644 --- a/libs/dom/CMakeLists.txt +++ b/libs/dom/CMakeLists.txt @@ -28,8 +28,11 @@ target_link_libraries(mrdocs-dom PUBLIC mrdocs::polyfill) # dom owns its export macro (mrdocs/Dom/Platform.hpp -> MRDOCS_DOM_DECL); it just # needs the link mode so that macro expands the same way (static vs shared) as -# mrdocs-core, which links this library. +# mrdocs-core, which links this library. MRDOCS_TOOL says the library is being +# built rather than consumed, which is what marks its API for export, so that a +# plugin loaded by the tool can call it. target_compile_definitions(mrdocs-dom PUBLIC ${MRDOCS_LINK_MODE_DEFINITION}) +target_compile_definitions(mrdocs-dom PRIVATE -DMRDOCS_TOOL) # mrdocs-core links this PUBLIC, so it must be part of the same export set. if (MRDOCS_INSTALL) diff --git a/libs/dom/include/mrdocs/Dom/Platform.hpp b/libs/dom/include/mrdocs/Dom/Platform.hpp index 8918096034..be479fb7fe 100644 --- a/libs/dom/include/mrdocs/Dom/Platform.hpp +++ b/libs/dom/include/mrdocs/Dom/Platform.hpp @@ -4,6 +4,7 @@ // SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception // // Copyright (c) 2023 Alan de Freitas (alandefreitas@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) // // Official repository: https://github.com/cppalliance/mrdocs // @@ -13,19 +14,25 @@ // Export/visibility macros for the dom library. Self-contained on purpose: dom // does not depend on mrdocs-core's . The expansion is -// driven by the same MRDOCS_STATIC_LINK / MRDOCS_SHARED_LINK define the build -// passes to every target; with the default static build it is empty. +// driven by the same MRDOCS_TOOL / MRDOCS_STATIC_LINK defines the build passes +// to every target. +// +// MRDOCS_TOOL, defined when MrDocs itself is being built, comes first: the API +// is marked for export even in a static build, because that is what puts the +// symbols a plugin calls in the tool's export table. A static consumer of the +// library, which defines MRDOCS_STATIC_LINK and not MRDOCS_TOOL, still sees +// plain declarations. -#if defined(MRDOCS_STATIC_LINK) -# define MRDOCS_DOM_DECL -#elif defined(_MSC_VER) -# if defined(MRDOCS_TOOL) +#if defined(_MSC_VER) +# if defined(MRDOCS_TOOL) // building MrDocs # define MRDOCS_DOM_DECL __declspec(dllexport) +# elif defined(MRDOCS_STATIC_LINK) +# define MRDOCS_DOM_DECL # else # define MRDOCS_DOM_DECL __declspec(dllimport) # endif #elif defined(__GNUC__) -# if defined(MRDOCS_TOOL) +# if defined(MRDOCS_TOOL) || defined(MRDOCS_STATIC_LINK) # define MRDOCS_DOM_DECL # else # define MRDOCS_DOM_DECL __attribute__((__visibility__("default"))) diff --git a/libs/handlebars/CMakeLists.txt b/libs/handlebars/CMakeLists.txt index 2b9e6cb9de..befe10ead5 100644 --- a/libs/handlebars/CMakeLists.txt +++ b/libs/handlebars/CMakeLists.txt @@ -30,6 +30,7 @@ target_link_libraries(mrdocs-handlebars PUBLIC mrdocs::dom mrdocs::polyfill) # MRDOCS_HANDLEBARS_DECL); it just needs the link mode so that macro expands the # same way (static vs shared) as mrdocs-core, which links this library. target_compile_definitions(mrdocs-handlebars PUBLIC ${MRDOCS_LINK_MODE_DEFINITION}) +target_compile_definitions(mrdocs-handlebars PRIVATE -DMRDOCS_TOOL) # mrdocs-core links this PUBLIC, so it must be part of the same export set. if (MRDOCS_INSTALL) diff --git a/libs/handlebars/include/mrdocs/Handlebars/Platform.hpp b/libs/handlebars/include/mrdocs/Handlebars/Platform.hpp index 8ee98f4a0a..27335ce7d1 100644 --- a/libs/handlebars/include/mrdocs/Handlebars/Platform.hpp +++ b/libs/handlebars/include/mrdocs/Handlebars/Platform.hpp @@ -4,6 +4,7 @@ // SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception // // Copyright (c) 2023 Alan de Freitas (alandefreitas@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) // // Official repository: https://github.com/cppalliance/mrdocs // @@ -13,19 +14,25 @@ // Export/visibility macro for the handlebars library. Self-contained on purpose: // handlebars does not depend on mrdocs-core's . The -// expansion is driven by the same MRDOCS_STATIC_LINK / MRDOCS_SHARED_LINK define -// the build passes to every target; with the default static build it is empty. +// expansion is driven by the same MRDOCS_TOOL / MRDOCS_STATIC_LINK defines the +// build passes to every target. +// +// MRDOCS_TOOL, defined when MrDocs itself is being built, comes first: the API +// is marked for export even in a static build, because that is what puts the +// symbols a plugin calls in the tool's export table. A static consumer of the +// library, which defines MRDOCS_STATIC_LINK and not MRDOCS_TOOL, still sees +// plain declarations. -#if defined(MRDOCS_STATIC_LINK) -# define MRDOCS_HANDLEBARS_DECL -#elif defined(_MSC_VER) -# if defined(MRDOCS_TOOL) +#if defined(_MSC_VER) +# if defined(MRDOCS_TOOL) // building MrDocs # define MRDOCS_HANDLEBARS_DECL __declspec(dllexport) +# elif defined(MRDOCS_STATIC_LINK) +# define MRDOCS_HANDLEBARS_DECL # else # define MRDOCS_HANDLEBARS_DECL __declspec(dllimport) # endif #elif defined(__GNUC__) -# if defined(MRDOCS_TOOL) +# if defined(MRDOCS_TOOL) || defined(MRDOCS_STATIC_LINK) # define MRDOCS_HANDLEBARS_DECL # else # define MRDOCS_HANDLEBARS_DECL __attribute__((__visibility__("default"))) From 163dd660068a740d684c2383a250d2775d6b2957 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Mon, 10 Aug 2026 16:47:01 +0200 Subject: [PATCH 2/3] feat: support plugins MrDocs loads the shared libraries in the plugins subdirectory of each addon root as it starts up, and lets each one install a generator. A plugin resolves the MrDocs symbols it calls against the tool, so writing an output format takes the MrDocs headers and nothing else: no LLVM, no Clang, no separate library to ship. Closes #58. --- CMakeLists.txt | 12 + data/mrdocs/addons/plugins/README.md | 9 +- docs/modules/ROOT/nav.adoc | 1 + .../ROOT/pages/extensions/plugins.adoc | 89 ++++++ docs/mrdocs.yml | 17 +- examples/generators/CMakeLists.txt | 29 ++ examples/generators/native/stats/mrdocs.yml | 4 + examples/generators/native/stats/plugin.cpp | 121 ++++++++ examples/generators/native/stats/simple.cpp | 36 +++ examples/generators/native/stats/stats.txt | 7 + include/mrdocs/Generator.hpp | 5 +- include/mrdocs/Plugin.hpp | 275 ++++++++++++++++++ src/mrdocs/Support/PluginLoader.cpp | 248 ++++++++++++++++ src/mrdocs/Support/PluginLoader.hpp | 43 +++ tests/CMakeLists.txt | 9 +- tests/plugin-api/CMakeLists.txt | 16 + tests/plugin-api/LinkProbe.cpp | 61 ++++ tests/unit/Support/PluginLoader.cpp | 192 ++++++++++++ tools/mrdocs/CMakeLists.txt | 14 +- tools/mrdocs/src/Main.cpp | 11 + 20 files changed, 1183 insertions(+), 16 deletions(-) create mode 100644 docs/modules/ROOT/pages/extensions/plugins.adoc create mode 100644 examples/generators/native/stats/mrdocs.yml create mode 100644 examples/generators/native/stats/plugin.cpp create mode 100644 examples/generators/native/stats/simple.cpp create mode 100644 examples/generators/native/stats/stats.txt create mode 100644 include/mrdocs/Plugin.hpp create mode 100644 src/mrdocs/Support/PluginLoader.cpp create mode 100644 src/mrdocs/Support/PluginLoader.hpp create mode 100644 tests/plugin-api/CMakeLists.txt create mode 100644 tests/plugin-api/LinkProbe.cpp create mode 100644 tests/unit/Support/PluginLoader.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d87470fb6..730f49efbd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,18 @@ option(MRDOCS_BUILD_HEADERS_ONLY "Build only public-headers for self-reference" option(MRDOCS_GENERATE_REFERENCE "Generate MrDocs reference" ${MRDOCS_BUILD_DOCS}) option(MRDOCS_GENERATE_ANTORA_REFERENCE "Generate MrDocs reference in Antora module pages" OFF) +# A plugin is a shared module the tool loads while it runs. A build that +# links statically can neither produce one, since the static startup files +# carry relocations a shared object cannot hold, nor load one afterwards, +# so the plugin example and its link test are left out of such a build. +# The loader itself still builds; it simply finds nothing to load. +if (CMAKE_CXX_FLAGS MATCHES "(^| )-static($| )" OR + CMAKE_EXE_LINKER_FLAGS MATCHES "(^| )-static($| )") + set(MRDOCS_BUILD_PLUGIN_MODULES OFF) +else () + set(MRDOCS_BUILD_PLUGIN_MODULES ON) +endif () + set_ternary(MRDOCS_LINK_MODE MRDOCS_BUILD_SHARED SHARED "") set_ternary(MRDOCS_LINK_MODE_DEFINITION MRDOCS_BUILD_SHARED MRDOCS_SHARED_LINK MRDOCS_STATIC_LINK) set_ternary(MRDOCS_GCC "CMAKE_CXX_COMPILER_ID STREQUAL \"GNU\"" ON OFF) diff --git a/data/mrdocs/addons/plugins/README.md b/data/mrdocs/addons/plugins/README.md index 43d9aef03e..034b818cac 100644 --- a/data/mrdocs/addons/plugins/README.md +++ b/data/mrdocs/addons/plugins/README.md @@ -1,3 +1,10 @@ # data/mrdocs/addons/plugins/ -Holds the DLLs or shared libraries that MrDocs loads when it is launched. +Holds the shared libraries that MrDocs loads when it is launched: every +`.dll`, `.so`, or `.dylib` directly inside it is loaded, in name order, and +asked what it provides. Any other file here, this one included, is ignored. + +A supplemental addons directory can carry a `plugins` directory of its own, so +a plugin does not have to be installed next to MrDocs. + +See the Plugins page of the documentation for how to write one. diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 2ff9cb5774..1f6eb54a18 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -28,6 +28,7 @@ ** xref:extensions/corpus-extensions.adoc[Extensions] ** xref:extensions/handlebars-extensions.adoc[Handlebars Extensions] ** xref:extensions/data-driven-generators.adoc[Data-Driven Generators] +** xref:extensions/plugins.adoc[Plugins] ** xref:extensions/antora.adoc[Antora Extensions] ** xref:extensions/as-library.adoc[Mr.Docs as a Library] ** xref:reference:index.adoc[Library Reference] diff --git a/docs/modules/ROOT/pages/extensions/plugins.adoc b/docs/modules/ROOT/pages/extensions/plugins.adoc new file mode 100644 index 0000000000..c547743e2e --- /dev/null +++ b/docs/modules/ROOT/pages/extensions/plugins.adoc @@ -0,0 +1,89 @@ += Plugins +:url-native: https://github.com/cppalliance/mrdocs/tree/{page-origin-refname}/examples/generators/native + +A plugin is a shared library that Mr.Docs loads as it starts up and hands a context to. Through that context, the plugin installs what it provides, so the rest of the run sees it as if it had shipped with the tool. Today, a plugin installs generators. + +Reach for a plugin when the output needs {cpp}: a format whose rules are awkward to express in templates, a generator that depends on a system library, or one whose work is heavy enough to want compiled code. The lighter options remain xref:extensions/data-driven-generators.adoc[data-driven generators] (templates only) and script-driven generators (Lua or JavaScript, see xref:extensions/corpus-extensions.adoc[Extensions]). The difference from xref:extensions/as-library.adoc[Mr.Docs as a library] is that a plugin runs inside the ordinary `mrdocs` tool: you keep the command line, the configuration, and everything else Mr.Docs does, and you do not build Mr.Docs or LLVM. + +== Where plugins live + +Mr.Docs looks in the `plugins` subdirectory of every addon root, that is under both the xref:configuration/reference.adoc#addons_option[`addons`] directory and each xref:configuration/reference.adoc#addons-supplemental_option[`addons-supplemental`] directory, in that order: + +[source] +---- +my-addons/ +└── plugins/ + └── stats.dll <1> +---- +<1> `.dll` on Windows, `.so` on Linux, `.dylib` or `.so` on macOS. + +Every library in such a directory is loaded, in name order within a root, and roots are visited in the order the configuration lists them. One library reachable through several roots is loaded once. Anything that is not a library is ignored, so a directory can hold a README, an import library, or debug information without confusing the search. + +A library that cannot be loaded, does not export the entry points, or reports an error of its own stops the run. + +== The entry point + +The `MRDOCS_PLUGIN_MAIN` macro defines the function Mr.Docs calls, naming the cpp:PluginContext[] parameter it receives: + +.`plugin.cpp` +[source,cpp] +---- +include::example$examples/generators/native/stats/plugin.cpp[tag=main] +---- + +The context is how a plugin reaches Mr.Docs: cpp:PluginContext::installGenerator[] adds a generator, and cpp:PluginContext::config[] returns the configuration the run was started with, already loaded and normalized, in case what the plugin installs depends on it. The reference is valid for the duration of the call. + +Along with the entry point, the macro defines a function that reports the version of the plugin interface the library was compiled against. Mr.Docs compares it with its own and refuses to call a plugin that was built against a different one, rather than calling it with the wrong expectations. A plugin therefore has to be rebuilt when it is used with a Mr.Docs whose plugin interface has changed. + +The macro reports the toolchain as well, and Mr.Docs checks that too, for the reason the next section explains. + +== The generator + +A plugin's generator is an ordinary cpp:Generator[] subclass: the same interface the built-in formats implement, and the same one the xref:extensions/as-library.adoc[library] examples use. + +.`plugin.cpp` +[source,cpp] +---- +include::example$examples/generators/native/stats/plugin.cpp[tag=generator] +---- + +cpp:Generator::build[] receives the corpus and the configuration, and owns everything from there: where its files go and what goes in them. This one counts the symbols by kind and writes a single file: + +.`plugin.cpp` +[source,cpp] +---- +include::example$examples/generators/native/stats/plugin.cpp[tag=build] +---- + +== Building a plugin + +A plugin is a CMake module library that links the `mrdocs` tool. Linking the tool is what resolves the Mr.Docs symbols the plugin calls, and it is the whole build dependency: no LLVM, no Clang, no separate library to ship alongside. That holds because the public headers only ever forward-declare the LLVM types they name; LLVM comes back only for a plugin that reaches past them into Mr.Docs's internal headers, as one supplying its own compilation database would. + +[source,cmake] +---- +find_package(mrdocs REQUIRED CONFIG) +add_library(stats MODULE plugin.cpp) +target_link_libraries(stats PRIVATE mrdocs::mrdocs) +target_compile_features(stats PRIVATE cxx_std_23) +---- + +A plugin and Mr.Docs pass {cpp} objects between them: the plugin allocates a generator that Mr.Docs destroys, and both inline the standard library types the API exposes. So the two have to be built with the same compiler and standard library, in a configuration that lays those types out the same way. On Windows that includes the debug or release choice, since it changes the iterator debug level. + +That requirement is checked rather than assumed: the plugin reports its compiler, its standard library, and the settings that affect those layouts, and Mr.Docs refuses a plugin whose report differs from its own, naming both. So, a plugin built the wrong way is turned away at startup instead of crashing later, somewhere unrelated. + +One configuration rules plugins out altogether: a statically linked Mr.Docs. Such a build has no dependable way to load a shared library while it runs, and cannot produce one either, since the startup files a static link brings in carry relocations a shared object cannot hold. Plugins therefore need a Mr.Docs that links dynamically. + +Drop the resulting library into the `plugins` directory of an addon root and the generator it installs is selectable like any other, through the xref:configuration/reference.adoc#generator_option[`generator`] option: + +[source,yaml] +---- +generator: stats +---- + +For the {url-native}/stats[`stats`^] example, whose input describes a handful of symbols, the generator writes: + +.`stats.txt` +[source,text] +---- +include::example$examples/generators/native/stats/stats.txt[] +---- diff --git a/docs/mrdocs.yml b/docs/mrdocs.yml index 9e2eaaecca..bd57a2a28c 100644 --- a/docs/mrdocs.yml +++ b/docs/mrdocs.yml @@ -16,16 +16,13 @@ file-patterns: - '*.hpp' include-symbols: - 'mrdocs::**' -# MrDocs's own macros are maintainer-facing implementation helpers, not -# public API: the MRDOCS_* support macros and the internal X-macros -# (INFO/LOG/F) and compat shims used across the headers. Keep them out of -# MrDocs's own reference. -exclude-macros: - - 'MRDOCS_*' - - 'INFO' - - 'LOG' - - 'F' - - 'FMT_CONSTEVAL' +# MrDocs's own macros are maintainer-facing implementation helpers rather +# than public API, except for the plugin interface, which is what a plugin +# author writes. Naming what to keep is what keeps the reference stable: +# a support macro added later stays out without anyone having to exclude +# it, which a list of exclusions cannot promise. +include-macros: + - 'MRDOCS_PLUGIN_*' implementation-defined: - '**::detail' # Injected by the MRDOCS_DESCRIBE_* macros diff --git a/examples/generators/CMakeLists.txt b/examples/generators/CMakeLists.txt index 2efa882dfc..efc86256dc 100644 --- a/examples/generators/CMakeLists.txt +++ b/examples/generators/CMakeLists.txt @@ -41,3 +41,32 @@ foreach (script_driven IN ITEMS search-index json) set_property(TEST mrdocs-generator-script-driven-${script_driven} PROPERTY ENVIRONMENT "MRDOCS=$") endforeach () + +if (MRDOCS_BUILD_PLUGIN_MODULES) + add_library(mrdocs-stats-plugin-example MODULE native/stats/plugin.cpp) + target_link_libraries(mrdocs-stats-plugin-example PRIVATE mrdocs) + target_compile_features(mrdocs-stats-plugin-example PRIVATE cxx_std_23) + + # MrDocs looks for plugins in the `plugins` subdirectory of an addon root, + # so the library is built into one. The generator expression keeps CMake + # from appending a per-configuration subdirectory, which would put the + # library somewhere the test cannot name. + set(MRDOCS_STATS_ADDONS "${CMAKE_CURRENT_BINARY_DIR}/native/stats/addons") + set_target_properties(mrdocs-stats-plugin-example PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "$<1:${MRDOCS_STATS_ADDONS}/plugins>") + + # Running the example is the test: the configuration asks for the `stats` + # generator, which exists only if the plugin was found, loaded, and + # installed it. + add_test(NAME mrdocs-generator-example-native-stats + COMMAND + mrdocs + "--config=${CMAKE_CURRENT_SOURCE_DIR}/native/stats/mrdocs.yml" + "--output=${CMAKE_CURRENT_BINARY_DIR}/native/stats/reference-output" + "--addons=${CMAKE_SOURCE_DIR}/data/mrdocs/addons" + "--addons-supplemental=${MRDOCS_STATS_ADDONS}" + "--stdlib-includes=${LIBCXX_DIR}" + "--libc-includes=${CMAKE_SOURCE_DIR}/data/mrdocs/headers/libc-stubs" + --log-level=warn + ) +endif () diff --git a/examples/generators/native/stats/mrdocs.yml b/examples/generators/native/stats/mrdocs.yml new file mode 100644 index 0000000000..fcf94ea941 --- /dev/null +++ b/examples/generators/native/stats/mrdocs.yml @@ -0,0 +1,4 @@ +generator: stats +source-root: . +file-patterns: + - simple.cpp diff --git a/examples/generators/native/stats/plugin.cpp b/examples/generators/native/stats/plugin.cpp new file mode 100644 index 0000000000..fa66d8b389 --- /dev/null +++ b/examples/generators/native/stats/plugin.cpp @@ -0,0 +1,121 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +// A MrDocs plugin: a shared library that MrDocs loads as it starts up +// and that installs a generator counting the extracted symbols by kind. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// tag::generator[] +// A generator that writes one line per symbol kind, indicating the kind and +// how many symbols of that kind the corpus has. +class StatsGenerator final + : public mrdocs::Generator +{ +public: + std::string_view + id() const noexcept override + { + return "stats"; + } + + std::string_view + displayName() const noexcept override + { + return "Symbol statistics"; + } + + std::string_view + fileExtension() const noexcept override + { + return "txt"; + } + + mrdocs::Expected + build( + mrdocs::Corpus const& corpus, + mrdocs::Config const& config) const override; +}; +// end::generator[] + +// Count the symbols of the corpus by kind, ordered by kind name. +std::map +countByKind(mrdocs::Corpus const& corpus) +{ + std::map counts; + for (mrdocs::Symbol const& symbol : corpus) + { + ++counts[mrdocs::toString(symbol.Kind)]; + } + return counts; +} + +// Resolve the directory the generator writes into. +std::filesystem::path +outputDir(mrdocs::Config const& config) +{ + std::filesystem::path dir(config.configDir()); + dir /= config.output; + return dir; +} + +// tag::build[] +mrdocs::Expected +StatsGenerator:: +build( + mrdocs::Corpus const& corpus, + mrdocs::Config const& config) const +{ + std::map const counts = + countByKind(corpus); + std::filesystem::path const dir = outputDir(config); + std::error_code ec; + std::filesystem::create_directories(dir, ec); + std::filesystem::path const file = dir / "stats.txt"; + + mrdocs::Expected result; + std::ofstream os(file); + if (!os) + { + result = mrdocs::Unexpected(mrdocs::formatError( + "could not open \"{}\" for writing", file.string())); + } + else + { + for (auto const& [kind, count] : counts) + { + os << kind << ' ' << count << '\n'; + } + } + return result; +} +// end::build[] + +} // (anon) + +// tag::main[] +MRDOCS_PLUGIN_MAIN(context) +{ + return context.installGenerator(std::make_unique()); +} +// end::main[] diff --git a/examples/generators/native/stats/simple.cpp b/examples/generators/native/stats/simple.cpp new file mode 100644 index 0000000000..c4ae102702 --- /dev/null +++ b/examples/generators/native/stats/simple.cpp @@ -0,0 +1,36 @@ +/// A small library of geometric helpers. +namespace geometry { + +/// The supported coordinate systems. +enum class System +{ + /// Distances along two perpendicular axes. + cartesian, + /// A distance and an angle measure. + polar +}; + +/// A point in two dimensions. +struct Point +{ + /** Compute the distance from the origin. + + @return The distance from the origin. + */ + double length() const; + + /** Translate by an offset. + + @param dx The offset along the first axis. + @param dy The offset along the second axis. + */ + void translate(double dx, double dy); +}; + +/// A distance in the units of the coordinate system. +using Distance = double; + +/// The coordinate system the helpers assume. +extern System defaultSystem; + +} // namespace geometry diff --git a/examples/generators/native/stats/stats.txt b/examples/generators/native/stats/stats.txt new file mode 100644 index 0000000000..257cc1966c --- /dev/null +++ b/examples/generators/native/stats/stats.txt @@ -0,0 +1,7 @@ +enum 1 +enum-constant 2 +function 2 +namespace 2 +record 1 +typedef 1 +variable 1 diff --git a/include/mrdocs/Generator.hpp b/include/mrdocs/Generator.hpp index 9f5382254f..058e4e26f8 100644 --- a/include/mrdocs/Generator.hpp +++ b/include/mrdocs/Generator.hpp @@ -100,8 +100,9 @@ class MRDOCS_VISIBLE This function registers a generator with the global generator registry, making it available for use. - Plugins can use this function to register custom - generators. + A plugin installs its generators through + @ref PluginContext::installGenerator, which calls + this function. @par Thread Safety This function is thread-safe and may be called diff --git a/include/mrdocs/Plugin.hpp b/include/mrdocs/Plugin.hpp new file mode 100644 index 0000000000..65da33acb4 --- /dev/null +++ b/include/mrdocs/Plugin.hpp @@ -0,0 +1,275 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +// The interface a shared library implements to extend MrDocs from +// outside the tool. + +#ifndef MRDOCS_API_PLUGIN_HPP +#define MRDOCS_API_PLUGIN_HPP + +#include +#include +#include +#include +#include +#include + +/** The version of the interface a plugin is built against. + + A plugin exports `mrdocs_plugin_api_version`, which returns the + value of this macro as it was defined when the plugin was compiled. + MrDocs calls the plugin only when that value matches its own, so a + plugin built against a different interface is reported as an error + instead of being called with the wrong expectations. + + The value changes whenever the plugin interface changes, which means + every plugin has to be rebuilt against the new headers. +*/ +#define MRDOCS_PLUGIN_API_VERSION 1 + +#define MRDOCS_PLUGIN_STRINGIZE_(x) #x +#define MRDOCS_PLUGIN_STRINGIZE(x) MRDOCS_PLUGIN_STRINGIZE_(x) + +#if defined(_MSC_VER) +# define MRDOCS_PLUGIN_COMPILER_TAG \ + "msvc " MRDOCS_PLUGIN_STRINGIZE(_MSC_VER) +#elif defined(__clang__) +# define MRDOCS_PLUGIN_COMPILER_TAG \ + "clang " MRDOCS_PLUGIN_STRINGIZE(__clang_major__) +#elif defined(__GNUC__) +# define MRDOCS_PLUGIN_COMPILER_TAG \ + "gcc " MRDOCS_PLUGIN_STRINGIZE(__GNUC__) +#else +# define MRDOCS_PLUGIN_COMPILER_TAG "unrecognized compiler" +#endif + +#if defined(_DLL) +# define MRDOCS_PLUGIN_CRT_TAG "dynamic" +#else +# define MRDOCS_PLUGIN_CRT_TAG "static" +#endif + +#if defined(_GLIBCXX_DEBUG) +# define MRDOCS_PLUGIN_GLIBCXX_DEBUG_TAG "on" +#else +# define MRDOCS_PLUGIN_GLIBCXX_DEBUG_TAG "off" +#endif + +// Alongside the standard library itself, only the settings that change how +// its types are laid out, or which heap they allocate from: the MSVC +// iterator debug level and C runtime, the libc++ ABI version, and the +// libstdc++ debug mode. Assertion and hardening settings are left out, as +// they change what the inline code checks rather than what it operates on, +// and a plugin should not have to match them. +#if defined(_MSVC_STL_UPDATE) +# define MRDOCS_PLUGIN_STDLIB_TAG \ + "msvc-stl " MRDOCS_PLUGIN_STRINGIZE(_MSVC_STL_UPDATE) \ + ", iterator-debug-level " \ + MRDOCS_PLUGIN_STRINGIZE(_ITERATOR_DEBUG_LEVEL) \ + ", crt " MRDOCS_PLUGIN_CRT_TAG +#elif defined(_LIBCPP_VERSION) +# define MRDOCS_PLUGIN_STDLIB_TAG \ + "libc++ " MRDOCS_PLUGIN_STRINGIZE(_LIBCPP_VERSION) \ + ", abi " MRDOCS_PLUGIN_STRINGIZE(_LIBCPP_ABI_VERSION) +#elif defined(_GLIBCXX_RELEASE) +# define MRDOCS_PLUGIN_STDLIB_TAG \ + "libstdc++ " MRDOCS_PLUGIN_STRINGIZE(_GLIBCXX_RELEASE) \ + ", debug " MRDOCS_PLUGIN_GLIBCXX_DEBUG_TAG +#else +# define MRDOCS_PLUGIN_STDLIB_TAG "unrecognized standard library" +#endif + +/** The toolchain a plugin was built with. + + A plugin and MrDocs pass C++ objects between them: the plugin + allocates a generator that MrDocs destroys, and both inline the + standard library types the API exposes. That holds together only when + the two were built with the same compiler and standard library, in a + configuration that lays those types out the same way. A plugin + reports this through `mrdocs_plugin_build_tag`, so that MrDocs can + refuse one whose toolchain differs rather than fail later, somewhere + unrelated. +*/ +#define MRDOCS_PLUGIN_BUILD_TAG \ + MRDOCS_PLUGIN_COMPILER_TAG ", " MRDOCS_PLUGIN_STDLIB_TAG + +/** The attribute that makes a plugin's entry points visible. + + MrDocs looks the entry points up by name in the loaded library, so + they have to be exported from it. +*/ +#if defined(_MSC_VER) +# define MRDOCS_PLUGIN_EXPORT __declspec(dllexport) +#else +# define MRDOCS_PLUGIN_EXPORT __attribute__((__visibility__("default"))) +#endif + +namespace mrdocs { + +/** The interface a plugin uses to extend MrDocs. + + MrDocs creates a context and passes it to the plugin entry point, + which installs through it whatever the plugin provides. The + reference is valid for the duration of that call only. +*/ +class MRDOCS_VISIBLE + PluginContext +{ +protected: + /** Destructor. + + MrDocs owns the context, so a plugin never destroys it. + */ + ~PluginContext() noexcept = default; + +public: + /** Return the configuration MrDocs is running with. + + The configuration is already loaded and normalized when the + plugin is called, so a plugin can decide what to install from + the settings the user wrote. + */ + virtual + Config const& + config() const noexcept = 0; + + /** Install a generator. + + The generator becomes selectable under its own id, on the + command line and in the configuration, alongside the ones that + ship with MrDocs. + + @return An error if a generator with the same id already + exists. + + @param G The generator to install. Ownership is transferred to + MrDocs, which keeps it for the rest of the run. + */ + virtual + Expected + installGenerator(std::unique_ptr G) = 0; +}; + +/** The type of the version function a plugin exports. + + The function is `extern "C"` and named `mrdocs_plugin_api_version`. + It returns @ref MRDOCS_PLUGIN_API_VERSION as the plugin saw it. +*/ +using PluginApiVersionFn = int (*)(); + +/** The type of the toolchain function a plugin exports. + + The function is `extern "C"` and named `mrdocs_plugin_build_tag`. It + returns @ref MRDOCS_PLUGIN_BUILD_TAG as the plugin saw it, in storage + that outlives the call. +*/ +using PluginBuildTagFn = char const* (*)(); + +/** The type of the entry point a plugin exports. + + The function is `extern "C"` and named `mrdocs_plugin_main`. MrDocs + calls it once, while it starts up, with a context and the address of + an error to fill in. A plugin that returns `false` stops the run, + reporting the error it left behind. + + A plugin defines the entry point with @ref MRDOCS_PLUGIN_MAIN rather + than writing this signature out: the body of the macro returns an + `Expected`, which the macro turns into the two. +*/ +using PluginMainFn = bool (*)(PluginContext&, Error*); + +/** Load the plugins the configuration makes visible. + + Each addon root contributes the libraries directly under its + `plugins` subdirectory, in root order and then by name within a + root, and one reachable through more than one root is taken once. + Every one of them is loaded for the lifetime of the process + and its entry point is called once, so that what a plugin installs + is in place before anything looks for it. + + Call this before a generator is looked up by id with + @ref findGenerator. It is one of the pieces the command-line tool + composes to run its generate step; the order of that step lives in + the tool. + + A library that cannot be loaded, does not export the entry points, + was built against another version of the plugin interface or with + another toolchain, or reports an error of its own fails the call: a + plugin is there because the user put it there, so one that does + nothing is not silently accepted. + + @par Thread Safety + Installs into the process-global registry, so it may not be called + concurrently with @ref installGenerator. + + @return The error, if any occurred. + + @param config The resolved configuration whose addon roots are + walked, and which the plugins read. +*/ +MRDOCS_DECL +Expected +loadPlugins(Config const& config); + +} // mrdocs + +/** Define the entry point of a plugin. + + Name the @ref mrdocs::PluginContext parameter and write a body that + returns what came of the work: + + @code + MRDOCS_PLUGIN_MAIN(ctx) + { + return ctx.installGenerator(std::make_unique()); + } + @endcode + + Along with the entry point, which passes the error the body returned + back to MrDocs, the macro defines the functions reporting the + interface version and the toolchain the plugin was built with, so + that none of the three can disagree with each other. + + @param context The name the body uses for the context it receives. +*/ +#define MRDOCS_PLUGIN_MAIN(context) \ + static ::mrdocs::Expected \ + mrdocsPluginMain(::mrdocs::PluginContext&); \ + \ + extern "C" MRDOCS_PLUGIN_EXPORT int \ + mrdocs_plugin_api_version() \ + { \ + return MRDOCS_PLUGIN_API_VERSION; \ + } \ + \ + extern "C" MRDOCS_PLUGIN_EXPORT char const* \ + mrdocs_plugin_build_tag() \ + { \ + return MRDOCS_PLUGIN_BUILD_TAG; \ + } \ + \ + extern "C" MRDOCS_PLUGIN_EXPORT bool \ + mrdocs_plugin_main( \ + ::mrdocs::PluginContext& mrdocsContext, \ + ::mrdocs::Error* mrdocsError) \ + { \ + ::mrdocs::Expected const mrdocsResult = \ + mrdocsPluginMain(mrdocsContext); \ + if (!mrdocsResult) \ + { \ + *mrdocsError = mrdocsResult.error(); \ + } \ + return mrdocsResult.has_value(); \ + } \ + \ + static ::mrdocs::Expected \ + mrdocsPluginMain(::mrdocs::PluginContext& context) + +#endif // MRDOCS_API_PLUGIN_HPP diff --git a/src/mrdocs/Support/PluginLoader.cpp b/src/mrdocs/Support/PluginLoader.cpp new file mode 100644 index 0000000000..1bf5bc8c6b --- /dev/null +++ b/src/mrdocs/Support/PluginLoader.cpp @@ -0,0 +1,248 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +#include "PluginLoader.hpp" +#include "AddonRoots.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs { + +namespace { + +// Whether a file name is that of a loadable library. CMake gives a +// module library the `.so` extension on macOS, so a plugin there can +// carry either name. +bool +isLibraryName(std::string_view fileName) +{ +#ifdef _WIN32 + constexpr std::string_view extensions[] = { ".dll" }; +#elif defined(__APPLE__) + constexpr std::string_view extensions[] = { ".dylib", ".so" }; +#else + constexpr std::string_view extensions[] = { ".so" }; +#endif + return std::ranges::any_of( + extensions, + [fileName](std::string_view const extension) + { + return fileName.ends_with(extension); + }); +} + +// Return the libraries directly under `dir`, ordered by name. +std::vector +scanPluginDir(std::string_view dir) +{ + namespace fs = std::filesystem; + std::vector found; + std::error_code iterEc; + fs::directory_iterator const end{}; + for (fs::directory_iterator it(dir, iterEc); + !iterEc && it != end; + it.increment(iterEc)) + { + std::error_code typeEc; + if (it->is_regular_file(typeEc) && + isLibraryName(it->path().filename().string())) + { + found.push_back(it->path().string()); + } + } + std::ranges::sort(found); + return found; +} + +// Append `from` to `to`, leaving out the libraries already there. One +// library can be reached through more than one addon root, and running its +// entry point twice would fail on the id it installed the first time. +// Identity comes from the filesystem, so a root spelled differently, or +// reached through a link, is still recognized. +void +appendNewLibraries( + std::vector const& from, + std::vector& to) +{ + for (std::string const& path : from) + { + bool const known = std::ranges::any_of( + to, + [&path](std::string const& other) + { + std::error_code ec; + return std::filesystem::equivalent(path, other, ec); + }); + if (!known) + { + to.push_back(path); + } + } +} + +// The context handed to a plugin: it reads the configuration MrDocs +// loaded and forwards what the plugin installs to the global registry. +class PluginContextImpl final + : public PluginContext +{ + Config const& config_; + +public: + explicit + PluginContextImpl(Config const& config) noexcept + : config_(config) + { + } + + Config const& + config() const noexcept override + { + return config_; + } + + Expected + installGenerator(std::unique_ptr G) override + { + return mrdocs::installGenerator(std::move(G)); + } +}; + +// Look a plugin entry point up by name. +Expected +findEntryPoint( + llvm::sys::DynamicLibrary& library, + char const* symbol, + std::string_view path) +{ + void* const address = library.getAddressOfSymbol(symbol); + MRDOCS_CHECK(address, formatError( + "the plugin \"{}\" does not export {}", path, symbol)); + return address; +} + +// Compare the interface the plugin was built against with ours. A +// mismatch means the plugin's view of the context, or of the entry +// points themselves, is not the one it is about to be called with. +Expected +checkApiVersion( + llvm::sys::DynamicLibrary& library, + std::string_view path) +{ + MRDOCS_TRY(void* const address, + findEntryPoint(library, "mrdocs_plugin_api_version", path)); + int const version = + reinterpret_cast(address)(); + MRDOCS_CHECK(version == MRDOCS_PLUGIN_API_VERSION, formatError( + "the plugin \"{}\" was built against version {} of the plugin " + "interface, and this MrDocs provides version {}", + path, version, MRDOCS_PLUGIN_API_VERSION)); + return {}; +} + +// Call the entry point at `address` and report what the plugin left +// behind, if it says it failed. +Expected +runEntryPoint( + void* address, + std::string_view path, + Config const& config) +{ + PluginContextImpl context(config); + Error error; + bool const installed = + reinterpret_cast(address)(context, &error); + MRDOCS_CHECK(installed, error.failed() + ? error + : formatError("the plugin \"{}\" reported a failure", path)); + return {}; +} + +// Compare the toolchain the plugin was built with against ours. The two +// pass C++ objects between them, so a difference in the compiler, the +// standard library, or how it lays its types out is not something either +// side can survive; refusing here is what keeps it from surfacing as a +// crash somewhere unrelated. +Expected +checkBuildTag( + llvm::sys::DynamicLibrary& library, + std::string_view path) +{ + MRDOCS_TRY(void* const address, + findEntryPoint(library, "mrdocs_plugin_build_tag", path)); + char const* const tag = + reinterpret_cast(address)(); + MRDOCS_CHECK(tag, formatError( + "the plugin \"{}\" reports no toolchain", path)); + MRDOCS_CHECK(std::string_view(tag) == MRDOCS_PLUGIN_BUILD_TAG, + formatError( + "the plugin \"{}\" was built with \"{}\", and this MrDocs " + "with \"{}\"; a plugin has to be built with the toolchain " + "MrDocs was built with", + path, tag, MRDOCS_PLUGIN_BUILD_TAG)); + return {}; +} + +// Load one library and run its entry point. +Expected +loadPlugin( + std::string const& path, + Config const& config) +{ + std::string message; + llvm::sys::DynamicLibrary library = + llvm::sys::DynamicLibrary::getPermanentLibrary(path.c_str(), &message); + MRDOCS_CHECK(library.isValid(), formatError( + "the plugin \"{}\" could not be loaded: {}", path, message)); + MRDOCS_TRY(checkApiVersion(library, path)); + MRDOCS_TRY(checkBuildTag(library, path)); + MRDOCS_TRY(void* const address, + findEntryPoint(library, "mrdocs_plugin_main", path)); + MRDOCS_TRY(runEntryPoint(address, path, config)); + report::info("Loaded plugin \"{}\"", path); + return {}; +} + +} // (anon) + +std::vector +discoverPlugins(std::vector const& roots) +{ + std::vector paths; + for (std::string const& root : roots) + { + std::string const dir = files::appendPath(root, "plugins"); + if (files::exists(dir)) + { + appendNewLibraries(scanPluginDir(dir), paths); + } + } + return paths; +} + +Expected +loadPlugins(Config const& config) +{ + for (std::string const& path : discoverPlugins(addonRoots(config))) + { + MRDOCS_TRY(loadPlugin(path, config)); + } + return {}; +} + +} // mrdocs diff --git a/src/mrdocs/Support/PluginLoader.hpp b/src/mrdocs/Support/PluginLoader.hpp new file mode 100644 index 0000000000..0ede06743e --- /dev/null +++ b/src/mrdocs/Support/PluginLoader.hpp @@ -0,0 +1,43 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +#ifndef MRDOCS_LIB_SUPPORT_PLUGINLOADER_HPP +#define MRDOCS_LIB_SUPPORT_PLUGINLOADER_HPP + +// `loadPlugins` is part of the public plugin API; see mrdocs/Plugin.hpp. +// What lives here is the path logic behind it. + +#include +#include + +namespace mrdocs { + +/** Return the paths of the plugin libraries in the addon roots, in + load order. + + Each root contributes the files directly under its `plugins` + subdirectory whose name ends with the extension the platform uses + for a loadable library. Anything else in the directory, and a root + without one, is skipped. + + Roots are searched in order, and the libraries within a root are + ordered by name, so a set of plugins always loads the same way. A + library reachable through more than one root is reported once. + + @return The paths of the libraries to load. + + @param roots The addon root directories to search. +*/ +std::vector +discoverPlugins(std::vector const& roots); + +} // mrdocs + +#endif // MRDOCS_LIB_SUPPORT_PLUGINLOADER_HPP diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ae2b57ec8a..8ec3974ea9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,10 +13,15 @@ if (MRDOCS_BUILD_HEADERS_ONLY OR NOT MRDOCS_BUILD_TESTS) endif() # Each test suite is a separate executable and owns its own CMakeLists: -# unit/ - public-API unit tests (mrdocs-unit-tests) -# golden/ - reference-output harness (mrdocs-golden-tests) +# unit/ - public-API unit tests (mrdocs-unit-tests) +# golden/ - reference-output harness (mrdocs-golden-tests) +# plugin-api/ - a module library that calls the API a plugin may call, +# where building it is the test (mrdocs-plugin-api-link-test) add_subdirectory(unit) add_subdirectory(golden) +if (MRDOCS_BUILD_PLUGIN_MODULES) + add_subdirectory(plugin-api) +endif () # Self-documentation test: run mrdocs over its own build file with the noop # generator (warn-as-error toggled by the strict flag). diff --git a/tests/plugin-api/CMakeLists.txt b/tests/plugin-api/CMakeLists.txt new file mode 100644 index 0000000000..5110e24f04 --- /dev/null +++ b/tests/plugin-api/CMakeLists.txt @@ -0,0 +1,16 @@ +# +# Licensed under the Apache License v2.0 with LLVM Exceptions. +# See https://llvm.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +# +# Official repository: https://github.com/cppalliance/mrdocs +# + +# A module library built and linked the way a plugin is, so that building it +# is the test: see LinkProbe.cpp for what that catches and what it does not. +# It is deliberately not registered with ctest, since it runs nothing. +add_library(mrdocs-plugin-api-link-test MODULE LinkProbe.cpp) +target_link_libraries(mrdocs-plugin-api-link-test PRIVATE mrdocs) +target_compile_features(mrdocs-plugin-api-link-test PRIVATE cxx_std_23) diff --git a/tests/plugin-api/LinkProbe.cpp b/tests/plugin-api/LinkProbe.cpp new file mode 100644 index 0000000000..4776903293 --- /dev/null +++ b/tests/plugin-api/LinkProbe.cpp @@ -0,0 +1,61 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +// Call the public API from outside the tool, the way a plugin does, so that +// building this file checks that the API is exported. A plugin compiles its +// own copy of every inline and template member, and those copies call +// out-of-line functions that need an export attribute of their own; a +// missing one is an unresolved external here, and nothing else in the tree +// would notice. Sorting the members is what earns the traversal its place: +// it compares every symbol kind, so it reaches far more of the API than it +// names. +// +// Nothing runs, and there is no ctest entry: the object is linked whole, so +// every symbol it names has to resolve. On Windows that resolution is +// against the tool's export table, which is where a missing attribute +// would fail. Elsewhere a module library may leave symbols to the loader, +// so what this checks there is that the public headers compile with neither +// `MRDOCS_TOOL` nor `MRDOCS_STATIC_LINK` defined, and that such a library +// links against the executable at all. + +#include +#include +#include +#include + +using namespace mrdocs; + +std::size_t +probeTraversal(Corpus const& corpus, NamespaceSymbol const& I) +{ + Corpus::TraverseOptions opts; + opts.ordered = true; + opts.recursive = true; + std::size_t count = corpus.size(); + corpus.traverse(opts, I, [&count](auto const&) { ++count; }); + for ([[maybe_unused]] Symbol const& J : corpus) + { + ++count; + } + return count; +} + +std::string +probeNames(Corpus const& corpus, SymbolID const& id) +{ + Symbol const& I = corpus.get(id); + return corpus.qualifiedName(I) + corpus.qualifiedName(I, id); +} + +Expected +probeLookup(Corpus const& corpus) +{ + return corpus.lookup("x"); +} diff --git a/tests/unit/Support/PluginLoader.cpp b/tests/unit/Support/PluginLoader.cpp new file mode 100644 index 0000000000..4b43262e69 --- /dev/null +++ b/tests/unit/Support/PluginLoader.cpp @@ -0,0 +1,192 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs { + +namespace { + +// The extension a plugin carries on this platform. +#ifdef _WIN32 +constexpr std::string_view libraryExtension = ".dll"; +#elif defined(__APPLE__) +constexpr std::string_view libraryExtension = ".dylib"; +#else +constexpr std::string_view libraryExtension = ".so"; +#endif + +// Return the path of a plugin library named `stem` in `dir`. +std::string +libraryPath( + std::string_view dir, + std::string_view stem) +{ + return files::appendPath( + dir, std::string(stem) + std::string(libraryExtension)); +} + +// Create an empty file at `path`. Discovery reports a file by name and +// never opens it, so the contents do not matter here. +void +writeFile(std::string_view path) +{ + std::ofstream os(std::string{path}, std::ios::binary | std::ios::trunc); +} + +// Create a directory, and the directories leading to it. +void +makeDirectory(std::string_view path) +{ + std::error_code ec; + std::filesystem::create_directories(path, ec); +} + +// Create `/plugins` and return its path. +std::string +makePluginDir(std::string_view root) +{ + std::string const dir = files::appendPath(root, "plugins"); + makeDirectory(dir); + return dir; +} + +} // (anon) + +struct PluginLoaderTest +{ + void + testRootWithoutPluginDir() + { + ScopedTempDirectory td("mrdocs-plugins"); + BOOST_TEST(td); + std::string const root(td.path()); + + BOOST_TEST(discoverPlugins({ root }).empty()); + } + + void + testMissingRoot() + { + ScopedTempDirectory td("mrdocs-plugins"); + BOOST_TEST(td); + std::string const root = files::appendPath(td.path(), "absent"); + + BOOST_TEST(discoverPlugins({ root }).empty()); + } + + void + testOnlyLibraries() + { + ScopedTempDirectory td("mrdocs-plugins"); + BOOST_TEST(td); + std::string const root(td.path()); + std::string const dir = makePluginDir(root); + writeFile(libraryPath(dir, "stats")); + // Anything that is not a library is left alone: the directory + // MrDocs ships documents itself with a README, and a build can + // leave an import library or debug information behind. + writeFile(files::appendPath(dir, "README.adoc")); + writeFile(files::appendPath(dir, "stats.lib")); + makeDirectory(libraryPath(dir, "nested")); + + std::vector const found = discoverPlugins({ root }); + BOOST_TEST(found.size() == 1); + if (!found.empty()) + { + BOOST_TEST(files::getFileName(found.front()).starts_with("stats.")); + } + } + + void + testNameOrderWithinRoot() + { + ScopedTempDirectory td("mrdocs-plugins"); + BOOST_TEST(td); + std::string const root(td.path()); + std::string const dir = makePluginDir(root); + // Created out of order: the load order comes from the names, not + // from the order the filesystem reports entries in. + writeFile(libraryPath(dir, "second")); + writeFile(libraryPath(dir, "first")); + + std::vector const found = discoverPlugins({ root }); + BOOST_TEST(found.size() == 2); + if (found.size() == 2) + { + BOOST_TEST(files::getFileName(found[0]).starts_with("first.")); + BOOST_TEST(files::getFileName(found[1]).starts_with("second.")); + } + } + + void + testRootOrder() + { + ScopedTempDirectory td("mrdocs-plugins"); + BOOST_TEST(td); + std::string const primary = files::appendPath(td.path(), "primary"); + std::string const supplemental = + files::appendPath(td.path(), "supplemental"); + // The library in the supplemental root sorts first by name, so a + // result in root order can only come from the roots themselves + // being searched in the order they were given. + writeFile(libraryPath(makePluginDir(primary), "zzz")); + writeFile(libraryPath(makePluginDir(supplemental), "aaa")); + + std::vector const found = + discoverPlugins({ primary, supplemental }); + BOOST_TEST(found.size() == 2); + if (found.size() == 2) + { + BOOST_TEST(files::getFileName(found[0]).starts_with("zzz.")); + BOOST_TEST(files::getFileName(found[1]).starts_with("aaa.")); + } + } + + void + testRepeatedRoot() + { + ScopedTempDirectory td("mrdocs-plugins"); + BOOST_TEST(td); + std::string const root(td.path()); + writeFile(libraryPath(makePluginDir(root), "stats")); + // A configuration can name one root twice, directly or through a + // link. Loading the library again would run its entry point a + // second time and fail on the id it already installed. + std::vector const found = + discoverPlugins({ root, root }); + BOOST_TEST(found.size() == 1); + } + + void + run() + { + testRootWithoutPluginDir(); + testMissingRoot(); + testOnlyLibraries(); + testNameOrderWithinRoot(); + testRootOrder(); + testRepeatedRoot(); + } +}; + +TEST_SUITE( + PluginLoaderTest, + "clang.mrdocs.PluginLoader"); + +} // mrdocs diff --git a/tools/mrdocs/CMakeLists.txt b/tools/mrdocs/CMakeLists.txt index 319588044f..af102efe6e 100644 --- a/tools/mrdocs/CMakeLists.txt +++ b/tools/mrdocs/CMakeLists.txt @@ -4,6 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception # # Copyright (c) 2026 Alan de Freitas (alandefreitas@gmail.com) +# Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) # # Official repository: https://github.com/cppalliance/mrdocs # @@ -19,6 +20,7 @@ add_executable(mrdocs ${TOOL_SOURCES}) target_include_directories(mrdocs PUBLIC "$" + "$" "$" PRIVATE "${PROJECT_SOURCE_DIR}/include" @@ -26,7 +28,17 @@ target_include_directories(mrdocs ) target_compile_definitions(mrdocs PRIVATE -DMRDOCS_TOOL) -target_link_libraries(mrdocs PUBLIC mrdocs-core) +target_link_libraries(mrdocs PRIVATE mrdocs-core) + +set_target_properties(mrdocs PROPERTIES ENABLE_EXPORTS ON) + +target_include_directories(mrdocs INTERFACE + "$" + "$" + "$") + +target_compile_options(mrdocs PUBLIC + "$<$:/EHs;/Zc:__cplusplus;/wd4251;/wd4275>") # The command-line frontend uses a few LLVM utilities directly (pretty stack # traces, host triple), so the tool links LLVM rather than reaching it only # through mrdocs-core. diff --git a/tools/mrdocs/src/Main.cpp b/tools/mrdocs/src/Main.cpp index dfdd672f9b..6fd94fb56b 100644 --- a/tools/mrdocs/src/Main.cpp +++ b/tools/mrdocs/src/Main.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -222,6 +223,16 @@ DoGenerateAction( Config config; MRDOCS_TRY(Config::load_file(config, configPath, dirs, argv)); + // -------------------------------------------------------------- + // + // Load plugins + // + // -------------------------------------------------------------- + // Plugins come first: one that cannot install what it provides + // fails the run, while an addon generator directory whose id is + // already taken is skipped. + MRDOCS_TRY(loadPlugins(config)); + // -------------------------------------------------------------- // // Discover addon-defined generators From a8fc0fc8a58a03de74e12447d5d527fd4070cd8c Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Wed, 12 Aug 2026 12:04:38 +0200 Subject: [PATCH 3/3] feat: let plugins also install corpus transforms A plugin could install a generator, so it could add an output format but not change what any format sees. This adds support for corpus transforms, filling the gap with extension scripts. Plugin transforms run before the script ones, since a plugin is loaded before any script is read. --- .../ROOT/pages/extensions/plugins.adoc | 34 ++++- include/mrdocs/Plugin.hpp | 18 +++ include/mrdocs/Transform.hpp | 119 ++++++++++++++++++ src/mrdocs/Support/PluginLoader.cpp | 6 + src/mrdocs/Support/Transform.cpp | 95 ++++++++++++++ tests/plugin-api/LinkProbe.cpp | 34 +++++ tools/mrdocs/src/Main.cpp | 11 ++ 7 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 include/mrdocs/Transform.hpp create mode 100644 src/mrdocs/Support/Transform.cpp diff --git a/docs/modules/ROOT/pages/extensions/plugins.adoc b/docs/modules/ROOT/pages/extensions/plugins.adoc index c547743e2e..91f4539798 100644 --- a/docs/modules/ROOT/pages/extensions/plugins.adoc +++ b/docs/modules/ROOT/pages/extensions/plugins.adoc @@ -1,7 +1,7 @@ = Plugins :url-native: https://github.com/cppalliance/mrdocs/tree/{page-origin-refname}/examples/generators/native -A plugin is a shared library that Mr.Docs loads as it starts up and hands a context to. Through that context, the plugin installs what it provides, so the rest of the run sees it as if it had shipped with the tool. Today, a plugin installs generators. +A plugin is a shared library that Mr.Docs loads as it starts up and hands a context to. Through that context, the plugin installs what it provides, so the rest of the run sees it as if it had shipped with the tool: generators, which turn the corpus into output, and transforms, which change the corpus before any generator sees it. Reach for a plugin when the output needs {cpp}: a format whose rules are awkward to express in templates, a generator that depends on a system library, or one whose work is heavy enough to want compiled code. The lighter options remain xref:extensions/data-driven-generators.adoc[data-driven generators] (templates only) and script-driven generators (Lua or JavaScript, see xref:extensions/corpus-extensions.adoc[Extensions]). The difference from xref:extensions/as-library.adoc[Mr.Docs as a library] is that a plugin runs inside the ordinary `mrdocs` tool: you keep the command line, the configuration, and everything else Mr.Docs does, and you do not build Mr.Docs or LLVM. @@ -31,7 +31,7 @@ The `MRDOCS_PLUGIN_MAIN` macro defines the function Mr.Docs calls, naming the cp include::example$examples/generators/native/stats/plugin.cpp[tag=main] ---- -The context is how a plugin reaches Mr.Docs: cpp:PluginContext::installGenerator[] adds a generator, and cpp:PluginContext::config[] returns the configuration the run was started with, already loaded and normalized, in case what the plugin installs depends on it. The reference is valid for the duration of the call. +The context is how a plugin reaches Mr.Docs: cpp:PluginContext::installGenerator[] adds a generator, cpp:PluginContext::installTransform[] adds a transform, and cpp:PluginContext::config[] returns the configuration the run was started with, already loaded and normalized, in case what the plugin installs depends on it. The reference is valid for the duration of the call. Along with the entry point, the macro defines a function that reports the version of the plugin interface the library was compiled against. Mr.Docs compares it with its own and refuses to call a plugin that was built against a different one, rather than calling it with the wrong expectations. A plugin therefore has to be rebuilt when it is used with a Mr.Docs whose plugin interface has changed. @@ -55,6 +55,36 @@ cpp:Generator::build[] receives the corpus and the configuration, and owns every include::example$examples/generators/native/stats/plugin.cpp[tag=build] ---- +== Transforming the corpus + +A plugin can also install a cpp:Transform[]: a pass that runs once, after the corpus is built and finalized and before any generator runs, so what it changes is what every output format sees. Where a generator is handed the corpus as `const`, a transform is handed it as it is, and may read it, change the symbols it finds, or both. What it cannot do is create a symbol or destroy one, since a corpus keeps its storage to itself. Everything a symbol holds is in reach, though, including the lists of members that the generators walk to decide what to write. + +[source,cpp] +---- +class BriefFiller + : public mrdocs::Transform +{ +public: + std::string_view + id() const noexcept override + { + return "brief-filler"; + } + + mrdocs::Expected + apply( + mrdocs::Corpus& corpus, + mrdocs::Config const& config) const override; +}; + +MRDOCS_PLUGIN_MAIN(context) +{ + return context.installTransform(std::make_unique()); +} +---- + +The transforms a plugin installs run before the ones an extension script registers with `mrdocs.register_transform`, and among themselves in the order they were installed. An error from a transform stops the run before any generator sees the corpus, and the diagnostic names the transform through cpp:Transform::id[]. + == Building a plugin A plugin is a CMake module library that links the `mrdocs` tool. Linking the tool is what resolves the Mr.Docs symbols the plugin calls, and it is the whole build dependency: no LLVM, no Clang, no separate library to ship alongside. That holds because the public headers only ever forward-declare the LLVM types they name; LLVM comes back only for a plugin that reaches past them into Mr.Docs's internal headers, as one supplying its own compilation database would. diff --git a/include/mrdocs/Plugin.hpp b/include/mrdocs/Plugin.hpp index 65da33acb4..c3f7520e26 100644 --- a/include/mrdocs/Plugin.hpp +++ b/include/mrdocs/Plugin.hpp @@ -19,6 +19,7 @@ #include #include #include +#include #include /** The version of the interface a plugin is built against. @@ -155,6 +156,23 @@ class MRDOCS_VISIBLE virtual Expected installGenerator(std::unique_ptr G) = 0; + + /** Install a corpus transform. + + The transform runs once, after the corpus is built and finalized + and before any generator runs, so what it changes is what every + output format sees. Transforms a plugin installs run before the + ones an extension script registers, and among themselves in the + order they were installed. + + @return An error if the transform is null. + + @param T The transform to install. Ownership is transferred to + MrDocs, which keeps it for the rest of the run. + */ + virtual + Expected + installTransform(std::unique_ptr T) = 0; }; /** The type of the version function a plugin exports. diff --git a/include/mrdocs/Transform.hpp b/include/mrdocs/Transform.hpp new file mode 100644 index 0000000000..0c06adeb25 --- /dev/null +++ b/include/mrdocs/Transform.hpp @@ -0,0 +1,119 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +// A pass over the corpus, run between extraction and generation. + +#ifndef MRDOCS_API_TRANSFORM_HPP +#define MRDOCS_API_TRANSFORM_HPP + +#include +#include +#include +#include +#include +#include +#include + + +namespace mrdocs { + +/** Base class for corpus transforms. + + A transform runs once, after the corpus is built and finalized and + before any generator runs, so whatever it changes is what every + output format sees. It is handed the corpus itself rather than a + copy, and may read it, change the symbols it finds, or both. +*/ +class MRDOCS_VISIBLE + Transform +{ +public: + /** Destructor. + */ + MRDOCS_DECL + virtual + ~Transform() noexcept; + + /** Return the symbolic name of the transform. + + A diagnostic about a transform names it with this, so a + recognizable name is worth choosing. Unlike a generator id it + selects nothing, and need not be unique. + */ + MRDOCS_DECL + virtual + std::string_view + id() const noexcept = 0; + + /** Transform the corpus. + + @par Thread Safety + Transforms run one at a time, in the order they were installed. + + @return The error, if any occurred. An error stops the run, + before any generator is given the corpus. + + @param corpus The corpus to read and change. + @param config The configuration that drove the build. + */ + MRDOCS_DECL + virtual + Expected + apply(Corpus& corpus, Config const& config) const = 0; +}; + +/** Install a corpus transform. + + This function registers a transform with the global transform + registry, so that it runs on the corpus of the current build. + + A plugin installs its transforms through + @ref PluginContext::installTransform, which calls this function. + + @par Thread Safety + This function is thread-safe and may be called concurrently from + multiple threads. + + @return An error if the transform is null. + + @param T The transform to install. Ownership is transferred to the + registry. +*/ +MRDOCS_DECL +Expected +installTransform(std::unique_ptr T); + +/** Apply the installed transforms to a corpus. + + Invokes each installed transform once, in the order the transforms + were installed, and stops at the first one that fails. + + Call this after the corpus is finalized and before a generator runs. + It is one of the pieces the command-line tool composes to run its + generate step; the order of that step lives in the tool. + + @par Thread Safety + This function may not be called concurrently with + @ref installTransform. + + @return The error, if any occurred, naming the transform it came + from. + + @param corpus The corpus to transform. + @param config The configuration that drove the build. +*/ +MRDOCS_DECL +Expected +applyTransforms(Corpus& corpus, Config const& config); + +} // mrdocs + + +#endif // MRDOCS_API_TRANSFORM_HPP diff --git a/src/mrdocs/Support/PluginLoader.cpp b/src/mrdocs/Support/PluginLoader.cpp index 1bf5bc8c6b..c81f70124d 100644 --- a/src/mrdocs/Support/PluginLoader.cpp +++ b/src/mrdocs/Support/PluginLoader.cpp @@ -121,6 +121,12 @@ class PluginContextImpl final { return mrdocs::installGenerator(std::move(G)); } + + Expected + installTransform(std::unique_ptr T) override + { + return mrdocs::installTransform(std::move(T)); + } }; // Look a plugin entry point up by name. diff --git a/src/mrdocs/Support/Transform.cpp b/src/mrdocs/Support/Transform.cpp new file mode 100644 index 0000000000..a58bc4ac56 --- /dev/null +++ b/src/mrdocs/Support/Transform.cpp @@ -0,0 +1,95 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +#include +#include +#include +#include +#include +#include +#include + + +namespace mrdocs { + +Transform:: +~Transform() noexcept = default; + +namespace { + +/* The global registry of installed transforms. + + Installing takes the lock, and applying takes it only to copy the + list out: a transform runs with the lock released, so one that calls + back into MrDocs cannot deadlock against the registry. +*/ +class TransformRegistry +{ + mutable std::mutex mutex_; + std::vector> list_; + +public: + Expected + insert(std::unique_ptr T) + { + MRDOCS_CHECK(T, "cannot install null transform"); + std::lock_guard lock(mutex_); + list_.emplace_back(std::move(T)); + return {}; + } + + std::vector + installed() const + { + std::lock_guard lock(mutex_); + std::vector result; + result.reserve(list_.size()); + for (std::unique_ptr const& T : list_) + { + result.push_back(T.get()); + } + return result; + } +}; + +TransformRegistry& +getTransformRegistry() noexcept +{ + static TransformRegistry impl; + return impl; +} + +} // (anon) + +Expected +installTransform(std::unique_ptr T) +{ + return getTransformRegistry().insert(std::move(T)); +} + +Expected +applyTransforms(Corpus& corpus, Config const& config) +{ + Expected result; + for (Transform const* T : getTransformRegistry().installed()) + { + result = T->apply(corpus, config); + if (!result) + { + std::string const reason(result.error().reason()); + result = Unexpected(formatError( + "the transform \"{}\" failed: {}", T->id(), reason)); + break; + } + } + return result; +} + +} // mrdocs diff --git a/tests/plugin-api/LinkProbe.cpp b/tests/plugin-api/LinkProbe.cpp index 4776903293..710265e529 100644 --- a/tests/plugin-api/LinkProbe.cpp +++ b/tests/plugin-api/LinkProbe.cpp @@ -27,7 +27,9 @@ #include #include +#include #include +#include #include using namespace mrdocs; @@ -59,3 +61,35 @@ probeLookup(Corpus const& corpus) { return corpus.lookup("x"); } + +namespace { + +class ProbeTransform final + : public Transform +{ +public: + std::string_view + id() const noexcept override + { + return "probe"; + } + + Expected + apply(Corpus& corpus, Config const&) const override + { + Expected result; + if (corpus.find(SymbolID::global) == nullptr) + { + result = Unexpected(formatError("the global namespace is missing")); + } + return result; + } +}; + +} // (anon) + +Expected +probeInstallTransform() +{ + return installTransform(std::make_unique()); +} diff --git a/tools/mrdocs/src/Main.cpp b/tools/mrdocs/src/Main.cpp index 6fd94fb56b..81394cc2c5 100644 --- a/tools/mrdocs/src/Main.cpp +++ b/tools/mrdocs/src/Main.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -271,6 +272,16 @@ DoGenerateAction( return {}; } + // -------------------------------------------------------------- + // + // Apply plugin transforms + // + // -------------------------------------------------------------- + // A plugin installed its transforms while MrDocs started up, before + // any script was read, so they run before the script ones for the + // same reason a plugin's generator id wins over an addon's. + MRDOCS_TRY(applyTransforms(corpus, config)); + // -------------------------------------------------------------- // // Run user extension scripts