From 428a25416072c54071470a5f5c5bbc5172498220 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Thu, 7 May 2026 16:14:02 +0200 Subject: [PATCH 1/5] fix: dom::Object field lookup returns the key instead of the value The `__index` metamethod in `domObject_push_metatable()` retrieved the value correctly via `Object::get(key)`, then called `lua_replace(L, 1)` to move the result into the userdata's slot. `lua_replace` also pops the top, so, on return, the key string was at the top of the stack and Lua picked it up as the metamethod's single return value, making every field access on a `dom::Object` userdata silently return the key it was asked for. This was latent until now because no Lua script in the test suite previously read fields off a `dom::Object` userdata. Surfaced while wiring corpus extensions: a script doing `corpus.symbols[i]` saw `"symbols"` (the key) instead of the array. --- src/lib/Support/Lua.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/Support/Lua.cpp b/src/lib/Support/Lua.cpp index b49e6a6680..e3f70ad040 100644 --- a/src/lib/Support/Lua.cpp +++ b/src/lib/Support/Lua.cpp @@ -433,7 +433,6 @@ domObject_push_metatable( domValue_push(A, domObject_get(A, 1).get( luaM_getstring(A, 2))); - lua_replace(A, 1); return 1; }); lua_settable(A, -3); From 685502346660d19543db6a0b2c1ff505f4a31a02 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Wed, 6 May 2026 17:33:30 +0200 Subject: [PATCH 2/5] feat(handlebars): support Lua scripts as Handlebars helpers This mirrors the existing JS helpers for Lua. *.lua files placed in an addon's generator/{common|}/helpers/ directory are auto-registered as Handlebars helpers; files whose name starts with '_' run first as utility scripts. Two golden fixtures (lua-helper/, lua-helper-layering/) mirror their JS counterparts and cover the `addons-supplemental` override. Incidental fixes to issues uncovered by this patch: - Added a qualification to `MRDOCS_TRY` / `MRDOCS_CHECK_*` / `MRDOCS_CHECK_OR_*` / `MRDOCS_CHECK_OR_CONTINUE` to make them work with nested namespaces named `detail`. - Dropped onelua.c and ltests.c from the Lua build patch, because the former defines `main`, which conflicted with our `main`, and the latter is test scaffolding which shouldn't ship in a library build. - Added `extern "C"` around the Lua includes. --- include/mrdocs/Support/Expected.hpp | 29 +- include/mrdocs/Support/Lua.hpp | 40 +++ src/lib/Gen/hbs/Builder.cpp | 144 ++++++-- src/lib/Gen/hbs/Builder.hpp | 2 + src/lib/Support/Lua.cpp | 312 +++++++++++++++++- .../generator/adoc/layouts/index.adoc.hbs | 1 + .../generator/adoc/layouts/wrapper.adoc.hbs | 4 + .../base/generator/common/helpers/greet.lua | 4 + .../base/generator/common/helpers/keep.lua | 4 + .../generator/html/layouts/index.html.hbs | 1 + .../generator/html/layouts/wrapper.html.hbs | 7 + .../generator/common/helpers/greet.lua | 4 + .../hbs/lua-helper-layering/layering.adoc | 4 + .../hbs/lua-helper-layering/layering.cpp | 4 + .../hbs/lua-helper-layering/layering.html | 7 + .../hbs/lua-helper-layering/layering.xml | 36 ++ .../hbs/lua-helper-layering/mrdocs.yml | 8 + .../lua/generator/adoc/helpers/format_id.lua | 6 + .../lua/generator/adoc/layouts/index.adoc.hbs | 1 + .../generator/adoc/layouts/wrapper.adoc.hbs | 16 + .../lua/generator/common/helpers/_utils.lua | 35 ++ .../lua/generator/common/helpers/choose.lua | 10 + .../lua/generator/common/helpers/describe.lua | 35 ++ .../lua/generator/common/helpers/echo.lua | 13 + .../generator/common/helpers/format_id.lua | 6 + .../lua/generator/common/helpers/glue.lua | 15 + .../generator/common/helpers/hash_inspect.lua | 7 + .../lua/generator/common/helpers/when.lua | 12 + .../lua/generator/html/helpers/format_id.lua | 6 + .../lua/generator/html/layouts/index.html.hbs | 1 + .../generator/html/layouts/wrapper.html.hbs | 18 + .../generator/hbs/lua-helper/helpers.adoc | 16 + .../generator/hbs/lua-helper/helpers.cpp | 3 + .../generator/hbs/lua-helper/helpers.html | 18 + .../generator/hbs/lua-helper/helpers.xml | 36 ++ .../generator/hbs/lua-helper/mrdocs.yml | 6 + third-party/patches/lua/CMakeLists.txt | 31 +- 37 files changed, 827 insertions(+), 75 deletions(-) create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/index.adoc.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/wrapper.adoc.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/greet.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/keep.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/index.html.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/wrapper.html.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/addons/override/generator/common/helpers/greet.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/layering.adoc create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/layering.cpp create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/layering.html create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/layering.xml create mode 100644 test-files/golden-tests/generator/hbs/lua-helper-layering/mrdocs.yml create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/helpers/format_id.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/index.adoc.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/wrapper.adoc.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/_utils.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/choose.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/describe.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/echo.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/format_id.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/glue.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/hash_inspect.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/when.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/helpers/format_id.lua create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/index.html.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/wrapper.html.hbs create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/helpers.adoc create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/helpers.cpp create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/helpers.html create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/helpers.xml create mode 100644 test-files/golden-tests/generator/hbs/lua-helper/mrdocs.yml diff --git a/include/mrdocs/Support/Expected.hpp b/include/mrdocs/Support/Expected.hpp index a8f7e60be2..e1c6f2c2fd 100644 --- a/include/mrdocs/Support/Expected.hpp +++ b/include/mrdocs/Support/Expected.hpp @@ -388,22 +388,29 @@ namespace detail # define MRDOCS_LABEL_(a) MRDOCS_MERGE_(expected_result_, a) # define MRDOCS_UNIQUE_NAME MRDOCS_LABEL_(__LINE__) +// `detail::failed` and `detail::error` below are qualified with `::mrdocs::` +// so the macros remain correct when expanded inside another `detail` +// namespace (e.g. `mrdocs::lua::detail`): a qualified `detail::` lookup +// stops at the first matching nested `detail` and never falls through to +// `mrdocs::detail`. `Unexpected` and `Error` are left unqualified: ordinary +// scope walking finds them in `mrdocs::`. + /// Try to retrive expected-like type # define MRDOCS_TRY_VOID(expr) \ auto MRDOCS_UNIQUE_NAME = expr; \ - if (detail::failed(MRDOCS_UNIQUE_NAME)) { \ - return Unexpected(detail::error(MRDOCS_UNIQUE_NAME)); \ + if (::mrdocs::detail::failed(MRDOCS_UNIQUE_NAME)) { \ + return Unexpected(::mrdocs::detail::error(MRDOCS_UNIQUE_NAME)); \ } \ void(0) # define MRDOCS_TRY_VAR(var, expr) \ auto MRDOCS_UNIQUE_NAME = expr; \ - if (detail::failed(MRDOCS_UNIQUE_NAME)) { \ - return Unexpected(detail::error(MRDOCS_UNIQUE_NAME)); \ + if (::mrdocs::detail::failed(MRDOCS_UNIQUE_NAME)) { \ + return Unexpected(::mrdocs::detail::error(MRDOCS_UNIQUE_NAME)); \ } \ var = *std::move(MRDOCS_UNIQUE_NAME) # define MRDOCS_TRY_MSG(var, expr, msg) \ auto MRDOCS_UNIQUE_NAME = expr; \ - if (detail::failed(MRDOCS_UNIQUE_NAME)) { \ + if (::mrdocs::detail::failed(MRDOCS_UNIQUE_NAME)) { \ return Unexpected(Error(msg)); \ } \ var = *std::move(MRDOCS_UNIQUE_NAME) @@ -413,12 +420,12 @@ namespace detail /// Check existing expected-like type # define MRDOCS_CHECK_VOID(var) \ - if (detail::failed(var)) { \ - return Unexpected(detail::error(var)); \ + if (::mrdocs::detail::failed(var)) { \ + return Unexpected(::mrdocs::detail::error(var)); \ } \ void(0) # define MRDOCS_CHECK_MSG(var, msg) \ - if (detail::failed(var)) { \ + if (::mrdocs::detail::failed(var)) { \ return Unexpected(Error(msg)); \ } \ void(0) @@ -428,12 +435,12 @@ namespace detail /// Check existing expected-like type and return custom value otherwise # define MRDOCS_CHECK_OR_VOID(var) \ - if (detail::failed(var)) { \ + if (::mrdocs::detail::failed(var)) { \ return; \ } \ void(0) # define MRDOCS_CHECK_OR_VALUE(var, value) \ - if (detail::failed(var)) { \ + if (::mrdocs::detail::failed(var)) { \ return value; \ } \ void(0) @@ -442,7 +449,7 @@ namespace detail MRDOCS_CHECK_GET_OR_MACRO(__VA_ARGS__, MRDOCS_CHECK_OR_VALUE, MRDOCS_CHECK_OR_VOID)(__VA_ARGS__) # define MRDOCS_CHECK_OR_CONTINUE(var) \ - if (detail::failed(var)) { \ + if (::mrdocs::detail::failed(var)) { \ continue; \ } \ void(0) diff --git a/include/mrdocs/Support/Lua.hpp b/include/mrdocs/Support/Lua.hpp index ab9f873a77..ea92992d5f 100644 --- a/include/mrdocs/Support/Lua.hpp +++ b/include/mrdocs/Support/Lua.hpp @@ -4,6 +4,7 @@ // 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 // @@ -21,6 +22,9 @@ #include namespace mrdocs { + +class Handlebars; + /** Lua interop helpers for the optional scripting/backend integration. This namespace contains glue for pushing/popping values, registering @@ -607,6 +611,42 @@ class Table : public Value Param value) const; }; +/** Register a Lua helper function + + Register a Lua chunk as a Handlebars helper. The chunk source is + resolved to a callable in the following order: + + 1. **Chunk return value** - load and execute the chunk; if it returns + a function, use that. This is the idiomatic shape for a per-file + helper: + Example: `return function(x) return 'lua:' .. tostring(x) end` + + 2. **Global lookup** - if the chunk does not return a function, look + up the helper name on the global table. This handles chunks that + define a function as a side effect: + Example: `function helper_name(x) return tostring(x) end` + + The resolved function is anchored in `LUA_REGISTRYINDEX` for the + lifetime of the registration. When the helper is invoked from a + template, positional arguments are converted from @ref dom::Value to + Lua values; the trailing Handlebars options object is dropped (matching + the JavaScript helper semantics) to avoid recursive marshalling of + symbol contexts. + + @param hbs The Handlebars instance to register the helper into + @param name The name of the helper function + @param ctx The Lua context that anchors the helper closure + @param script The Lua source that defines the helper function + @return Success, or an error if the script could not be resolved to a function +*/ +[[nodiscard]] MRDOCS_DECL +Expected +registerHelper( + mrdocs::Handlebars& hbs, + std::string_view name, + Context& ctx, + std::string_view script); + } // lua } // mrdocs diff --git a/src/lib/Gen/hbs/Builder.cpp b/src/lib/Gen/hbs/Builder.cpp index feb1a90925..c21f57eb8d 100644 --- a/src/lib/Gen/hbs/Builder.cpp +++ b/src/lib/Gen/hbs/Builder.cpp @@ -5,6 +5,7 @@ // // Copyright (c) 2023 Vinnie Falco (vinnie.falco@gmail.com) // Copyright (c) 2024 Alan de Freitas (alandefreitas@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) // // Official repository: https://github.com/cppalliance/mrdocs // @@ -242,6 +243,52 @@ registerDefaultHelpers(Handlebars& hbs) hbs.registerHelper("relativize", dom::makeInvocable(relativize_fn)); } +/** Categorizes files in the helper directories by extension. + + Walks each directory recursively, picking files whose name ends in + `ext`. Files whose stem starts with `_` are treated as utility scripts + (loaded before helpers); the rest are recorded as helpers keyed by + stem. Other extensions are ignored, so JS and Lua scans do not + interfere with each other. + + @param helperDirs The directories to scan for helper files. + @param ext The file extension to match (e.g. `.js`, `.lua`). + @param[out] utilityFiles Paths of utility scripts to run before helpers. + @param[out] helperFiles (helper name, path) pairs, in directory order. + @return Success, or the underlying filesystem error. +*/ +Expected +collectHelperFiles( + std::vector const& helperDirs, + std::string_view ext, + std::vector& utilityFiles, + std::vector>& helperFiles) +{ + for (auto const& dir : helperDirs) + { + if (!files::exists(dir)) + continue; + + auto exp = forEachFile(dir, true, + [&](std::string_view pathName) -> Expected + { + if (!pathName.ends_with(ext)) + return {}; + auto name = files::getFileName(pathName); + name.remove_suffix(ext.size()); + + if (name.starts_with("_")) + utilityFiles.emplace_back(pathName); + else + helperFiles.emplace_back(std::string(name), std::string(pathName)); + return {}; + }); + if (!exp) + return Unexpected(exp.error()); + } + return {}; +} + /** Registers user-defined JavaScript helpers from addon directories. Scans the specified directories for JavaScript files and registers @@ -261,7 +308,7 @@ registerDefaultHelpers(Handlebars& hbs) @return Success, or an error if loading/registration fails. */ Expected -registerUserHelpers( +registerUserJsHelpers( Handlebars& hbs, js::Context& ctx, std::vector const& helperDirs) @@ -278,37 +325,8 @@ registerUserHelpers( std::vector utilityFiles; std::vector> helperFiles; // (name, path) - for (auto const& dir : helperDirs) - { - if (!files::exists(dir)) - continue; + MRDOCS_TRY(collectHelperFiles(helperDirs, ".js", utilityFiles, helperFiles)); - auto exp = forEachFile(dir, true, - [&](std::string_view pathName) -> Expected - { - constexpr std::string_view ext = ".js"; - if (!pathName.ends_with(ext)) - return {}; - auto name = files::getFileName(pathName); - name.remove_suffix(ext.size()); - - if (name.starts_with("_")) - { - // Utility file: will be executed as script - utilityFiles.emplace_back(pathName); - } - else - { - // Helper file: will be registered as Handlebars helper - helperFiles.emplace_back(std::string(name), std::string(pathName)); - } - return {}; - }); - if (!exp) - return Unexpected(exp.error()); - } - - // Sort utility files alphabetically for predictable load order std::sort(utilityFiles.begin(), utilityFiles.end()); // Load utilities first (they define globals available to helpers). @@ -337,6 +355,61 @@ registerUserHelpers( return {}; } +/** Registers user-defined Lua helpers from addon directories. + + Mirrors @ref registerUserJsHelpers for Lua. Files are categorized: + + - **Utility files** (prefixed with `_`): Loaded as Lua chunks and + executed once. Use them to populate the global table or `package` + modules that helpers can reference. + - **Helper files**: Registered as Handlebars helpers via + @ref lua::registerHelper, using the filename stem as the helper name. + + Both `.js` and `.lua` files can coexist in the same addon directory. + A `.lua` helper registered with the same name as an existing `.js` + helper replaces it (because Handlebars helper registration overwrites). + + @param hbs The Handlebars instance to register helpers with. + @param ctx The Lua context for script execution. + @param helperDirs The directories to scan for helper files. + @return Success, or an error if loading/registration fails. +*/ +Expected +registerUserLuaHelpers( + Handlebars& hbs, + lua::Context& ctx, + std::vector const& helperDirs) +{ + std::vector utilityFiles; + std::vector> helperFiles; // (name, path) + + MRDOCS_TRY(collectHelperFiles(helperDirs, ".lua", utilityFiles, helperFiles)); + + std::sort(utilityFiles.begin(), utilityFiles.end()); + + for (auto const& utilPath : utilityFiles) + { + lua::Scope scope(ctx); + MRDOCS_TRY(auto script, files::getFileText(utilPath)); + MRDOCS_TRY(auto chunk, scope.loadChunk(script, utilPath)); + auto exp = chunk.call(); + if (!exp) + { + return Unexpected(formatError( + "Error loading utility {}: {}", + utilPath, exp.error().message())); + } + } + + for (auto const& [name, path] : helperFiles) + { + MRDOCS_TRY(auto script, files::getFileText(path)); + MRDOCS_TRY(lua::registerHelper(hbs, name, ctx, script)); + } + + return {}; +} + /** Loads a layout template from addon directories. Searches through the layout directories for the specified template @@ -389,9 +462,14 @@ Builder( // Load partials (later dirs overwrite earlier ones because we walk in order) registerPartials(hbs_, partialDirs); - // Built-in helpers first, then user JS helpers so overrides work as expected. + // Built-in helpers first, then user scripts (JS and Lua) so user code + // can override built-ins. JS runs before Lua, so a Lua helper with the + // same name as a JS helper takes precedence (last-write-wins on the + // Handlebars side). registerDefaultHelpers(hbs_); - if (auto exp = registerUserHelpers(hbs_, ctx_, helperDirs); !exp) + if (auto exp = registerUserJsHelpers(hbs_, ctx_, helperDirs); !exp) + exp.error().Throw(); + if (auto exp = registerUserLuaHelpers(hbs_, lua_ctx_, helperDirs); !exp) exp.error().Throw(); // Load layout templates diff --git a/src/lib/Gen/hbs/Builder.hpp b/src/lib/Gen/hbs/Builder.hpp index 67e277100d..6109c5dbae 100644 --- a/src/lib/Gen/hbs/Builder.hpp +++ b/src/lib/Gen/hbs/Builder.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -41,6 +42,7 @@ namespace hbs { class Builder { js::Context ctx_; + lua::Context lua_ctx_; Handlebars hbs_; std::map> templates_; std::function escapeFn_; diff --git a/src/lib/Support/Lua.cpp b/src/lib/Support/Lua.cpp index e3f70ad040..5d065ada98 100644 --- a/src/lib/Support/Lua.cpp +++ b/src/lib/Support/Lua.cpp @@ -4,20 +4,27 @@ // 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 // #include +#include #include #include #include #include +#include +#include + +// Lua's upstream headers are C-only and ship without `extern "C"` guards. +// Wrap the includes here. +extern "C" { #include #include -#include #include -#include +} namespace mrdocs { namespace lua { @@ -34,6 +41,7 @@ static char gImplKey{}; //------------------------------------------------ static void domObject_push_metatable(Access& A); +static void domArray_push(Access& A, dom::Array const&); static void domValue_push(Access& A, dom::Value const&); //------------------------------------------------ @@ -278,8 +286,7 @@ domArray_get( lua_touserdata(A, index)); } -// Push the domObject metatable onto the stack -[[maybe_unused]] +// Push the domArray metatable onto the stack static void domArray_push_metatable( @@ -291,10 +298,15 @@ domArray_push_metatable( return; } - lua_createtable(A, 0, 3); + lua_createtable(A, 0, 4); // Effect: return t[i] // Signature: (t, i) + // + // Lua-convention 1-indexed: `arr[1]` is the first element, and any + // index outside `[1, #arr]` returns nil. Together with `__len` and + // a 1-indexed `__pairs`, this is what makes the standard `ipairs`, + // `pairs`, and `#` operator work. luaM_pushstring(A, "__index"); lua_pushcfunction(A, [](lua_State* L) @@ -306,13 +318,20 @@ domArray_push_metatable( lua_pushnil(A); return 1; } - std::size_t index = lua_tonumber(A, 2); + lua_Number raw = lua_tonumber(A, 2); auto const& arr = domArray_get(A, 1); lua_pop(A, lua_gettop(A)); - if(index < arr.size()) - domValue_push(A, arr.at(index)); + if(raw >= 1 && + static_cast(raw) <= arr.size()) + { + domValue_push( + A, + arr.at(static_cast(raw) - 1)); + } else + { lua_pushnil(A); + } return 1; }); lua_settable(A, -3); @@ -328,8 +347,23 @@ domArray_push_metatable( lua_settable(A, -3); #endif + // Effect: return #t + // Signature: (t) + luaM_pushstring(A, "__len"); + lua_pushcfunction(A, + [](lua_State* L) + { + Access A(L); + auto const& arr = domArray_get(A, 1); + lua_pushinteger(A, static_cast(arr.size())); + return 1; + }); + lua_settable(A, -3); + // Effect: return next(t [, index]) // Signature: (t [, index]) + // + // The upvalue holds the next 1-indexed key to yield. static constexpr auto const next = [](lua_State* L) { @@ -344,12 +378,14 @@ domArray_push_metatable( lua_pushnil(A); return 2; } - auto index = lua_tonumber(A, lua_upvalueindex(1)); - lua_pushnumber(A, index); - domValue_push(A, arr.at(index)); - ++index; - if(index < arr.size()) - lua_pushnumber(A, index); + lua_Number key = lua_tonumber(A, lua_upvalueindex(1)); + lua_pushnumber(A, key); + domValue_push( + A, + arr.at(static_cast(key) - 1)); + ++key; + if(static_cast(key) <= arr.size()) + lua_pushnumber(A, key); else lua_pushnil(A); lua_replace(A, lua_upvalueindex(1)); @@ -358,6 +394,8 @@ domArray_push_metatable( // Effect: return pairs(t) // Signature: (t) + // + // First key handed to `next` is 1 (Lua convention). luaM_pushstring(A, "__pairs"); lua_pushcfunction(A, [](lua_State* L) @@ -365,7 +403,7 @@ domArray_push_metatable( Access A(L); auto arr = domArray_get(A, 1); if(! arr.empty()) - lua_pushnumber(A, 0); + lua_pushnumber(A, 1); else lua_pushnil(A); lua_pushcclosure(A, next, 1); @@ -391,6 +429,21 @@ domArray_push_metatable( A->arrMetaRef = luaL_ref(A, LUA_REGISTRYINDEX); } +// Push a dom::Array onto the stack +static +void +domArray_push( + Access& A, + dom::Array const& arr) +{ + auto& arr_ = *static_cast< + dom::Array*>(lua_newuserdatauv( + A, sizeof(dom::Array), 0)); + domArray_push_metatable(A); + lua_setmetatable(A, -2); + std::construct_at(&arr_, arr); +} + //------------------------------------------------ // // dom::Object @@ -590,8 +643,7 @@ domValue_push( case dom::Kind::String: return luaM_pushstring(A, value.getString()); case dom::Kind::Array: - MRDOCS_UNREACHABLE(); - //return domArray_push(A, value.getArray()); + return domArray_push(A, value.getArray()); case dom::Kind::Object: return domObject_push(A, value.getObject()); default: @@ -710,7 +762,8 @@ push(Scope& scope) const case Kind::value: return lua_pushvalue(A, index_); case Kind::domArray: - MRDOCS_UNREACHABLE(); + domArray_push(A, arr_); + return; case Kind::domObject: domObject_push(A, obj_); return; @@ -1155,6 +1208,229 @@ callImpl( return A.construct(-1, *scope_); } +//------------------------------------------------ +// +// registerHelper +// +//------------------------------------------------ + +// Convert the Lua value at the given stack index to a dom::Value. +// Used to marshal a helper's return value back to Handlebars. +// +// Userdata wrapping our own dom::Object/dom::Array are unwrapped in place +// (preserving identity); raw Lua tables are converted to a dom::Object using +// string keys (non-string keys are skipped). Non-representable types +// (function, thread, light userdata) become null. +static +dom::Value +luaToDom(Access& A, int idx) +{ + int const t = lua_type(A, idx); + switch(t) + { + case LUA_TNIL: + return dom::Value(); + case LUA_TBOOLEAN: + return dom::Value(lua_toboolean(A, idx) != 0); + case LUA_TNUMBER: + if (lua_isinteger(A, idx)) + return dom::Value(static_cast( + lua_tointeger(A, idx))); + // dom::Value has no double kind; truncate floats. Helpers that need + // sub-integer precision should return strings. + return dom::Value(static_cast( + lua_tonumber(A, idx))); + case LUA_TSTRING: + { + std::size_t len; + char const* data = lua_tolstring(A, idx, &len); + return dom::Value(std::string(data, len)); + } + case LUA_TTABLE: + { + dom::Object obj; + int const absIdx = lua_absindex(A, idx); + lua_pushnil(A); + while (lua_next(A, absIdx) != 0) + { + if (lua_type(A, -2) == LUA_TSTRING) + { + std::size_t klen; + char const* kdata = lua_tolstring(A, -2, &klen); + obj.set( + std::string_view(kdata, klen), + luaToDom(A, -1)); + } + lua_pop(A, 1); // pop value, keep key for next iteration + } + return dom::Value(std::move(obj)); + } + case LUA_TUSERDATA: + { + if (! lua_getmetatable(A, idx)) + return dom::Value(); + int const metaIdx = lua_absindex(A, -1); + dom::Value result; + bool matched = false; + + if (A->objMetaRef != LUA_NOREF) + { + lua_rawgeti(A, LUA_REGISTRYINDEX, A->objMetaRef); + if (lua_rawequal(A, metaIdx, -1)) + { + result = dom::Value(*static_cast( + lua_touserdata(A, idx))); + matched = true; + } + lua_pop(A, 1); + } + if (! matched && A->arrMetaRef != LUA_NOREF) + { + lua_rawgeti(A, LUA_REGISTRYINDEX, A->arrMetaRef); + if (lua_rawequal(A, metaIdx, -1)) + { + result = dom::Value(*static_cast( + lua_touserdata(A, idx))); + } + lua_pop(A, 1); + } + lua_pop(A, 1); // pop metatable + return result; + } + default: + return dom::Value(); + } +} + +namespace detail { + +// Registry-anchored handle that owns a Lua function's lifetime independently +// of any Scope. The function lives in LUA_REGISTRYINDEX until the handle is +// destroyed, so the Handlebars helper closure can keep firing across renders. +struct LuaHelperHandle +{ + Context ctx; + int ref; + + LuaHelperHandle(Context c, int r) noexcept + : ctx(std::move(c)), ref(r) + { + } + LuaHelperHandle(LuaHelperHandle const&) = delete; + LuaHelperHandle& operator=(LuaHelperHandle const&) = delete; + ~LuaHelperHandle() + { + Access A(ctx); + luaL_unref(A, LUA_REGISTRYINDEX, ref); + } +}; + +// Strip the trailing Handlebars options object (matching the JS helper +// semantics), push positional args to Lua, run the helper, and return the +// converted result. Errors from lua_pcall surface as Unexpected. +static +Expected +invokeHelperRef( + std::shared_ptr const& handle, + dom::Array const& args) +{ + if (args.empty()) + { + return Unexpected(Error( + "Handlebars helper called without arguments; " + "expected options object as last argument")); + } + dom::Value const& options = args.back(); + if (! options.isObject()) + { + return Unexpected(Error( + "Handlebars helper options must be an object; " + "ensure the helper is called from a template context")); + } + + Scope scope(handle->ctx); + Access A(scope); + + lua_rawgeti(A, LUA_REGISTRYINDEX, handle->ref); + + std::size_t const narg = args.size() - 1; + for (std::size_t i = 0; i < narg; ++i) + { + Param p(args.get(i)); + Access::push(p, scope); + } + + int const rc = lua_pcall(A, static_cast(narg), 1, 0); + if (rc != LUA_OK) + return Unexpected(luaM_popError(A)); + + dom::Value result = luaToDom(A, lua_gettop(A)); + lua_pop(A, 1); + return result; +} + +} // detail + +Expected +registerHelper( + Handlebars& hbs, + std::string_view name, + Context& ctx, + std::string_view script) +{ + // Resolve a Lua chunk to a callable: the chunk's return value is preferred + // (the "return function(...) ... end" idiom), falling back to a global of + // the same name (the "function name(...) ... end" idiom). + Scope scope(ctx); + + auto chunk = scope.loadChunk(script, std::string(name)); + if (! chunk) + return Unexpected(chunk.error()); + + auto chunkResult = chunk->call(); + if (! chunkResult) + return Unexpected(chunkResult.error()); + + Access A(scope); + int ref; + + if (chunkResult->isFunction()) + { + lua_pushvalue(A, Access::index(*chunkResult)); + ref = luaL_ref(A, LUA_REGISTRYINDEX); + } + else + { + auto global = scope.getGlobal(name); + if (! global) + { + return Unexpected(formatError( + "lua helper '{}': chunk did not return a function " + "and no global of that name was defined", + name)); + } + if (! global->isFunction()) + { + return Unexpected(formatError( + "lua helper '{}' is not a function", name)); + } + lua_pushvalue(A, Access::index(*global)); + ref = luaL_ref(A, LUA_REGISTRYINDEX); + } + + auto handle = std::make_shared(ctx, ref); + + hbs.registerHelper( + std::string(name), + dom::makeVariadicInvocable( + [handle](dom::Array const& args) -> Expected + { + return detail::invokeHelperRef(handle, args); + })); + + return {}; +} + //------------------------------------------------ void diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/index.adoc.hbs b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/index.adoc.hbs new file mode 100644 index 0000000000..2c2b1d9f2d --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/index.adoc.hbs @@ -0,0 +1 @@ +{{! Index intentionally unused for this fixture }} diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/wrapper.adoc.hbs b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/wrapper.adoc.hbs new file mode 100644 index 0000000000..4ede38ccf2 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/adoc/layouts/wrapper.adoc.hbs @@ -0,0 +1,4 @@ += Layering Test + +greet: {{greet}} +keep: {{keep}} diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/greet.lua b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/greet.lua new file mode 100644 index 0000000000..a230efd348 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/greet.lua @@ -0,0 +1,4 @@ +-- Base helper - should be overridden by supplemental addon +return function() + return "base-hello" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/keep.lua b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/keep.lua new file mode 100644 index 0000000000..5dcb9fab3f --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/common/helpers/keep.lua @@ -0,0 +1,4 @@ +-- Base helper that is NOT overridden - should remain available +return function() + return "base-keep" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/index.html.hbs b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/index.html.hbs new file mode 100644 index 0000000000..2c2b1d9f2d --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/index.html.hbs @@ -0,0 +1 @@ +{{! Index intentionally unused for this fixture }} diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/wrapper.html.hbs b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/wrapper.html.hbs new file mode 100644 index 0000000000..8a2ca2d24e --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/base/generator/html/layouts/wrapper.html.hbs @@ -0,0 +1,7 @@ + + + +

{{greet}}

+

{{keep}}

+ + diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/override/generator/common/helpers/greet.lua b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/override/generator/common/helpers/greet.lua new file mode 100644 index 0000000000..468accfaf0 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/addons/override/generator/common/helpers/greet.lua @@ -0,0 +1,4 @@ +-- Override helper - should replace base greet helper +return function() + return "override-hello" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.adoc b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.adoc new file mode 100644 index 0000000000..f1e578ac3f --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.adoc @@ -0,0 +1,4 @@ += Layering Test + +greet: override‐hello +keep: base‐keep diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.cpp b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.cpp new file mode 100644 index 0000000000..1cc3102b0b --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.cpp @@ -0,0 +1,4 @@ +// Golden test for addons-supplemental layering +// Verifies that supplemental addons override base addon helpers + +void layering_entry(); diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.html b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.html new file mode 100644 index 0000000000..a0bf59b2a9 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.html @@ -0,0 +1,7 @@ + + + +

override-hello

+

base-keep

+ + diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.xml b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.xml new file mode 100644 index 0000000000..43688ca71d --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/layering.xml @@ -0,0 +1,36 @@ + + + + + + namespace + //////////////////////////8= + regular + + 6Ck47Qi5Akzd//BP/G1woD2OR1Q= + + + + layering_entry + + + layering.cpp + layering.cpp + 4 + 1 + + + function + 6Ck47Qi5Akzd//BP/G1woD2OR1Q= + regular + //////////////////////////8= + + + identifier + void + + + normal + + diff --git a/test-files/golden-tests/generator/hbs/lua-helper-layering/mrdocs.yml b/test-files/golden-tests/generator/hbs/lua-helper-layering/mrdocs.yml new file mode 100644 index 0000000000..70881123a4 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper-layering/mrdocs.yml @@ -0,0 +1,8 @@ +addons: addons/base +addons-supplemental: + - addons/override +generator: html +multipage: false +no-default-styles: true +warn-if-undocumented: false +source-root: . diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/helpers/format_id.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/helpers/format_id.lua new file mode 100644 index 0000000000..5cb81eece9 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/helpers/format_id.lua @@ -0,0 +1,6 @@ +-- AsciiDoc-specific override of format_id helper. +-- Should take precedence over the common/helpers/format_id.lua version. + +return function() + return "adoc" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/index.adoc.hbs b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/index.adoc.hbs new file mode 100644 index 0000000000..aca07fd474 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/index.adoc.hbs @@ -0,0 +1 @@ +{{! Index not used for this single-page fixture }} diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/wrapper.adoc.hbs b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/wrapper.adoc.hbs new file mode 100644 index 0000000000..6e9b1f50c0 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/adoc/layouts/wrapper.adoc.hbs @@ -0,0 +1,16 @@ += Lua Helper Output +:mrdocs: + +* echo: {{echo "mrdocs"}} +* bool: {{describe true}} +* number: {{describe 42}} +* string: {{describe "hi"}} +* null: {{describe null}} +* undefined: {{describe}} +* array: {{describe "a" "b" 3}} +* hash: {{hash_inspect "a" 1 "b" "two"}} +* glue: {{glue "|" "x" "y" "z"}} +* block: {{#choose}}then{{else}}otherwise{{/choose}} +* format: {{format_id}} + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/_utils.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/_utils.lua new file mode 100644 index 0000000000..dcab17878d --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/_utils.lua @@ -0,0 +1,35 @@ +-- Shared utility functions for Lua helpers. +-- Files starting with '_' are loaded before helper files and define +-- globals that can be used by all helpers. + +-- Normalize Handlebars arguments. Returns a packed table whose `n` field +-- is the argument count (so nils round-trip correctly). Userdata (DOM +-- objects passed in by Handlebars when no positional args are given) is +-- filtered out, mirroring the JS `normalize_args` helper. +function normalize_args(...) + local list = table.pack(...) + + local filtered = { n = 0 } + for i = 1, list.n do + local v = list[i] + if type(v) ~= "userdata" then + filtered.n = filtered.n + 1 + filtered[filtered.n] = v + end + end + return filtered +end + +-- Format an object's key-value pairs as a sorted, comma-separated string. +function format_object(obj) + local keys = {} + for k in pairs(obj) do + keys[#keys + 1] = k + end + table.sort(keys) + local parts = {} + for _, key in ipairs(keys) do + parts[#parts + 1] = key .. "=" .. tostring(obj[key]) + end + return table.concat(parts, ",") +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/choose.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/choose.lua new file mode 100644 index 0000000000..e4ca191a03 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/choose.lua @@ -0,0 +1,10 @@ +-- Block helper exercising options.fn/options.inverse. The options table is +-- dropped before the helper runs (matching the JS path), so this always +-- returns the "otherwise" branch. + +return function(options) + if options == nil or type(options) ~= "table" then + return "otherwise" + end + return "otherwise" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/describe.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/describe.lua new file mode 100644 index 0000000000..c629f30d5e --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/describe.lua @@ -0,0 +1,35 @@ +-- Describe helper: reports type and value in a deterministic string. +-- Uses normalize_args and format_object from _utils.lua. + +return function(...) + local list = normalize_args(...) + local typ + local value + + if list.n == 0 then + typ = "undefined" + value = "" + elseif list.n > 1 then + typ = "array" + local strs = {} + for i = 1, list.n do + strs[i] = tostring(list[i]) + end + value = table.concat(strs, ",") + else + local v = list[1] + if v == nil then + typ = "null" + value = "" + else + typ = type(v) + if typ == "table" then + value = format_object(v) + else + value = tostring(v) + end + end + end + + return typ .. ":" .. value +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/echo.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/echo.lua new file mode 100644 index 0000000000..16ec067cf2 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/echo.lua @@ -0,0 +1,13 @@ +-- Echo helper used in golden tests; keeps output stable across engines. +-- Uses normalize_args from _utils.lua (loaded before helper files). + +return function(...) + local list = normalize_args(...) + local value + if list.n > 0 and list[1] ~= nil then + value = list[1] + else + value = "" + end + return "lua:" .. tostring(value) +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/format_id.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/format_id.lua new file mode 100644 index 0000000000..d1774a7817 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/format_id.lua @@ -0,0 +1,6 @@ +-- Helper to test format-specific override behavior. +-- This common version should be overridden by format-specific helpers. + +return function() + return "common" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/glue.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/glue.lua new file mode 100644 index 0000000000..8a8748eb7e --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/glue.lua @@ -0,0 +1,15 @@ +-- Glue helper: joins positional args using the first argument as separator. +-- Uses normalize_args from _utils.lua. + +return function(...) + local list = normalize_args(...) + if list.n == 0 then + return "" + end + local sep = tostring(list[1]) + local items = {} + for i = 2, list.n do + items[#items + 1] = tostring(list[i]) + end + return table.concat(items, sep) +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/hash_inspect.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/hash_inspect.lua new file mode 100644 index 0000000000..2e98ef5e8f --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/hash_inspect.lua @@ -0,0 +1,7 @@ +-- Hash helper: builds a stable string from options.hash or key/value args. +-- The Handlebars options object is dropped before the helper runs (mirroring +-- the JS path), so this returns a literal value for the golden test. + +return function() + return "hash:a=1,b=two" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/when.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/when.lua new file mode 100644 index 0000000000..c045af2222 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/common/helpers/when.lua @@ -0,0 +1,12 @@ +-- Block helper that exercises options.fn/options.inverse. Registered for +-- parity with the JS fixture; the rendered template does not invoke it. + +return function(condition, options) + if options == nil or type(options) ~= "table" then + return "" + end + if condition then + return "" + end + return "" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/helpers/format_id.lua b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/helpers/format_id.lua new file mode 100644 index 0000000000..8f8156f634 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/helpers/format_id.lua @@ -0,0 +1,6 @@ +-- HTML-specific override of format_id helper. +-- Should take precedence over the common/helpers/format_id.lua version. + +return function() + return "html" +end diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/index.html.hbs b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/index.html.hbs new file mode 100644 index 0000000000..2c2b1d9f2d --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/index.html.hbs @@ -0,0 +1 @@ +{{! Index intentionally unused for this fixture }} diff --git a/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/wrapper.html.hbs b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/wrapper.html.hbs new file mode 100644 index 0000000000..70c20d13c0 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/addons/lua/generator/html/layouts/wrapper.html.hbs @@ -0,0 +1,18 @@ + + + +
    +
  • {{echo "mrdocs"}}
  • +
  • {{describe true}}
  • +
  • {{describe 42}}
  • +
  • {{describe "hi"}}
  • +
  • {{describe null}}
  • +
  • {{describe}}
  • +
  • {{describe "a" "b" 3}}
  • +
  • {{hash_inspect "a" 1 "b" "two"}}
  • +
  • {{glue "|" "x" "y" "z"}}
  • +
  • {{#choose}}then{{else}}otherwise{{/choose}}
  • +
  • {{format_id}}
  • +
+ + diff --git a/test-files/golden-tests/generator/hbs/lua-helper/helpers.adoc b/test-files/golden-tests/generator/hbs/lua-helper/helpers.adoc new file mode 100644 index 0000000000..ddbe318082 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/helpers.adoc @@ -0,0 +1,16 @@ += Lua Helper Output +:mrdocs: + +* echo: lua:mrdocs +* bool: boolean:true +* number: number:42 +* string: string:hi +* null: null: +* undefined: undefined: +* array: array:a,b,3 +* hash: hash:a=1,b=two +* glue: x|y|z +* block: otherwise +* format: adoc + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/generator/hbs/lua-helper/helpers.cpp b/test-files/golden-tests/generator/hbs/lua-helper/helpers.cpp new file mode 100644 index 0000000000..19a44d50e5 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/helpers.cpp @@ -0,0 +1,3 @@ +// Golden test input exercising multiple Handlebars helpers (JS today, room for more types later). + +void helpers_entry(); diff --git a/test-files/golden-tests/generator/hbs/lua-helper/helpers.html b/test-files/golden-tests/generator/hbs/lua-helper/helpers.html new file mode 100644 index 0000000000..02130f3b6b --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/helpers.html @@ -0,0 +1,18 @@ + + + +
    +
  • lua:mrdocs
  • +
  • boolean:true
  • +
  • number:42
  • +
  • string:hi
  • +
  • null:
  • +
  • undefined:
  • +
  • array:a,b,3
  • +
  • hash:a=1,b=two
  • +
  • x|y|z
  • +
  • otherwise
  • +
  • html
  • +
+ + diff --git a/test-files/golden-tests/generator/hbs/lua-helper/helpers.xml b/test-files/golden-tests/generator/hbs/lua-helper/helpers.xml new file mode 100644 index 0000000000..abfd7aa5f2 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/helpers.xml @@ -0,0 +1,36 @@ + + + + + + namespace + //////////////////////////8= + regular + + x33/DiL0nY266Q+QKndmXNfXoFw= + + + + helpers_entry + + + helpers.cpp + helpers.cpp + 3 + 1 + + + function + x33/DiL0nY266Q+QKndmXNfXoFw= + regular + //////////////////////////8= + + + identifier + void + + + normal + + diff --git a/test-files/golden-tests/generator/hbs/lua-helper/mrdocs.yml b/test-files/golden-tests/generator/hbs/lua-helper/mrdocs.yml new file mode 100644 index 0000000000..fd8f96ecb6 --- /dev/null +++ b/test-files/golden-tests/generator/hbs/lua-helper/mrdocs.yml @@ -0,0 +1,6 @@ +addons: addons/lua +generator: html +multipage: false +no-default-styles: true +warn-if-undocumented: false +source-root: . diff --git a/third-party/patches/lua/CMakeLists.txt b/third-party/patches/lua/CMakeLists.txt index f0213814dd..1018e7a761 100644 --- a/third-party/patches/lua/CMakeLists.txt +++ b/third-party/patches/lua/CMakeLists.txt @@ -21,25 +21,30 @@ option(BUILD_SHARED_LIBS "Create lua as a shared library" OFF) project(lua VERSION ${lua_VERSION} LANGUAGES C) +# Files we always exclude from the core+stdlib library build: +# - lua.c standalone interpreter (defines its own `main`) +# - luac.c standalone bytecode compiler (defines its own `main`) +# - onelua.c unity-build alternative that #includes the others; its +# default `MAKE_LUA` mode pulls in lua.c and would re-define +# `main`, conflicting with any application that links lua.lib. +# - ltests.c Lua-internal test harness, only valid when LUA_USER_H +# enables it. +set(LUA_EXCLUDE_NAMES lua.c luac.c onelua.c ltests.c) + # Check if ./src exists if (EXISTS "${CMAKE_CURRENT_LIST_DIR}/src") - # Lua sources live under ./src - file(GLOB LUA_SOURCES "${CMAKE_CURRENT_LIST_DIR}/src/*.c") - list(REMOVE_ITEM LUA_SOURCES - "${CMAKE_CURRENT_LIST_DIR}/src/lua.c" - "${CMAKE_CURRENT_LIST_DIR}/src/luac.c" - ) - file(GLOB LUA_HEADERS "${CMAKE_CURRENT_LIST_DIR}/src/*.h") + set(LUA_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}/src") else() # Lua sources live under ./ (github release) - file(GLOB LUA_SOURCES "${CMAKE_CURRENT_LIST_DIR}/*.c") - list(REMOVE_ITEM LUA_SOURCES - "${CMAKE_CURRENT_LIST_DIR}/lua.c" - "${CMAKE_CURRENT_LIST_DIR}/luac.c" - ) - file(GLOB LUA_HEADERS "${CMAKE_CURRENT_LIST_DIR}/*.h") + set(LUA_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}") endif() +file(GLOB LUA_SOURCES "${LUA_SOURCE_DIR}/*.c") +foreach(_excl IN LISTS LUA_EXCLUDE_NAMES) + list(REMOVE_ITEM LUA_SOURCES "${LUA_SOURCE_DIR}/${_excl}") +endforeach() +file(GLOB LUA_HEADERS "${LUA_SOURCE_DIR}/*.h") + add_library(lua ${LUA_SOURCES} ${LUA_HEADERS}) target_include_directories(lua PUBLIC From b8ccd3135685ace074ee1eb02986415d87a56e2b Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Tue, 12 May 2026 10:08:16 +0200 Subject: [PATCH 3/5] feat: add MRDOCS_DESCRIBE_KINDS for class hierarchies Add a `MRDOCS_DESCRIBE_KINDS` macro that registers the closed set of most derived classes ("kinds") of a polymorphic base, and apply it to every polymorphic base in MrDocs. Generic code can then dispatch over the closed set (`describe::for_each(describe::describe_kinds{}, ...)`) without the per-base X-macro boilerplate every consumer would otherwise need. The macro and its supporting machinery live in a dedicated `DescribeKinds.hpp` header, so consumers that only need `MRDOCS_DESCRIBE_STRUCT`, `MRDOCS_DESCRIBE_CLASS`, and `MRDOCS_DESCRIBE_ENUM` keep a slimmer include. Each per-base registration lives in a small private include under src/lib/Metadata/ fed by the existing *Nodes.inc X-macro files, so the registered set has one source of truth; the kind information is a compile-time consumer-side concern, so no public header exposes it. docs/mrdocs.yml excludes the reflection helpers from the generated reference. --- docs/mrdocs.yml | 3 +- include/mrdocs/Support/DescribeKinds.hpp | 213 ++++++++++++++++++ .../Metadata/DocComment/Block/BlockKinds.hpp | 46 ++++ .../DocComment/Inline/InlineKinds.hpp | 43 ++++ src/lib/Metadata/NameKinds.hpp | 29 +++ src/lib/Metadata/Symbol/SymbolKinds.hpp | 39 ++++ src/lib/Metadata/TArgKinds.hpp | 30 +++ src/lib/Metadata/TParamKinds.hpp | 30 +++ src/lib/Metadata/TypeKinds.hpp | 36 +++ src/test/Support/DescribeKinds.cpp | 196 ++++++++++++++++ 10 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 include/mrdocs/Support/DescribeKinds.hpp create mode 100644 src/lib/Metadata/DocComment/Block/BlockKinds.hpp create mode 100644 src/lib/Metadata/DocComment/Inline/InlineKinds.hpp create mode 100644 src/lib/Metadata/NameKinds.hpp create mode 100644 src/lib/Metadata/Symbol/SymbolKinds.hpp create mode 100644 src/lib/Metadata/TArgKinds.hpp create mode 100644 src/lib/Metadata/TParamKinds.hpp create mode 100644 src/lib/Metadata/TypeKinds.hpp create mode 100644 src/test/Support/DescribeKinds.cpp diff --git a/docs/mrdocs.yml b/docs/mrdocs.yml index b854a38841..c5442b19cc 100644 --- a/docs/mrdocs.yml +++ b/docs/mrdocs.yml @@ -17,10 +17,11 @@ exclude-symbols: # Reflection facilities. - 'mrdocs::describe' - 'mrdocs::describe::**' - # Symbols injected by MRDOCS_DESCRIBE_STRUCT/ENUM. + # Symbols injected by MRDOCS_DESCRIBE_STRUCT, etc. - '**mrdocs_base_descriptor_fn' - '**mrdocs_member_descriptor_fn' - '**mrdocs_enum_descriptor_fn' + - '**mrdocs_kind_descriptor_fn' multipage: true generator: adoc cmake: '-D MRDOCS_DOCUMENTATION_BUILD=ON' diff --git a/include/mrdocs/Support/DescribeKinds.hpp b/include/mrdocs/Support/DescribeKinds.hpp new file mode 100644 index 0000000000..554287e1e6 --- /dev/null +++ b/include/mrdocs/Support/DescribeKinds.hpp @@ -0,0 +1,213 @@ +// +// 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 +// + +// Kind descriptors for polymorphic bases. +// +// Extends the reflection machinery in Describe.hpp with the +// `MRDOCS_DESCRIBE_KINDS` macro, which registers the closed set of +// concrete derived classes ("kinds") of a polymorphic base. Generic +// code can then iterate the resulting list with `describe::for_each` +// and dispatch over kinds without per-base X-macro boilerplate. +// +// This support lives in its own header so consumers that only need +// `MRDOCS_DESCRIBE_STRUCT`, `MRDOCS_DESCRIBE_CLASS`, and +// `MRDOCS_DESCRIBE_ENUM` keep a slimmer include. + +#ifndef MRDOCS_SUPPORT_DESCRIBEKINDS_HPP +#define MRDOCS_SUPPORT_DESCRIBEKINDS_HPP + +#include +#include + +namespace mrdocs::describe { + +// ------------------------------------------------------------------- +// Kind descriptors +// ------------------------------------------------------------------- + +/** Descriptor for one concrete kind of a polymorphic base. + + Carries the static assertion that `D` actually derives from `C` + and exposes the derived type as the nested `type` alias. + + @tparam C The polymorphic base class. + @tparam D A concrete derived class registered with + @ref MRDOCS_DESCRIBE_KINDS. +*/ +template +struct kind_descriptor +{ + static_assert(std::is_base_of_v, + "A type listed as a kind is not actually derived from C"); + using type = D; +}; + +template +list kind_descriptor_fn_impl(int, T...); + +// ------------------------------------------------------------------- +// Query alias (ADL lookup) +// ------------------------------------------------------------------- + +/** Compile-time list of the kinds registered for a polymorphic base. + + Resolves via ADL to the @ref list produced by + @ref MRDOCS_DESCRIBE_KINDS. Use with @ref for_each to dispatch + over every concrete kind of `T`. + + @tparam T The polymorphic base class. +*/ +template +using describe_kinds = + decltype(mrdocs_kind_descriptor_fn(static_cast(nullptr))); + +// ------------------------------------------------------------------- +// Type trait +// ------------------------------------------------------------------- + +namespace detail { + +template +struct has_describe_kinds_impl : std::false_type {}; + +template +struct has_describe_kinds_impl>> : std::true_type {}; + +} // namespace detail + +/** Trait: whether a polymorphic base has had its kinds registered. + + Evaluates to `std::true_type` when @ref MRDOCS_DESCRIBE_KINDS has + been applied to `T` and `std::false_type` otherwise. Generic code + typically guards `describe::for_each` over @ref describe_kinds + with this trait so it stays well-formed for types that have not + opted in. + + @tparam T A class type that may or may not have its kinds + registered. +*/ +template +using has_describe_kinds = detail::has_describe_kinds_impl; + +} // namespace mrdocs::describe + +// =================================================================== +// MRDOCS_DESCRIBE_KINDS +// =================================================================== + +/** Register a polymorphic base together with the closed set of its + concrete derived classes ("kinds"). + + The macro emits a single descriptor that generic code can iterate + with `describe::for_each`: + + @code + MRDOCS_DESCRIBE_KINDS( + Type, + NamedType, PointerType, ArrayType // ... + ) + + describe::for_each( + describe::describe_kinds{}, + [](auto desc) { + using D = typename decltype(desc)::type; + // ... do something with D ... + }); + @endcode + + Constraints: + + - Every listed `D` must inherit (directly or transitively) from + `C` and must be a complete type at the point of macro + expansion. The natural home for the macro is therefore a + dedicated header that includes every derived class's header + and issues the macro once. + - The list describes a closed flat set of leaf classes, not a + tree. Intermediate bases in a deeper inheritance graph are + not represented. The descriptor lets generic code visit + every concrete kind of `C`, not every level of inheritance + under `C`. + + Query types: + + - `describe::has_describe_kinds` tests whether a base has + been registered. + - `describe::describe_kinds` is the resulting + `list...>`. + + @param C The polymorphic base class. + @param ... The concrete derived classes of `C`. +*/ +// The emitted `mrdocs_kind_descriptor_fn` is only ever read through +// `decltype`; no caller invokes it. Giving it an inline `{ return {}; }` +// body (instead of leaving it as a pure declaration) silences GCC's +// `-Wunused-function`, which fires on internal-linkage declarations +// that are never defined when the macro is invoked in an anonymous +// namespace (the convention in tests with locally-scoped fixtures). +// `inline` keeps the definition ODR-safe across translation units when +// the macro appears in a header. Clang and MSVC stay silent either +// way. +#define MRDOCS_DESCRIBE_KINDS(C, ...) \ + static_assert(std::is_class_v, \ + "MRDOCS_DESCRIBE_KINDS should only be used with " \ + "class types"); \ + [[maybe_unused]] \ + inline decltype( \ + ::mrdocs::describe::kind_descriptor_fn_impl(0 \ + __VA_OPT__(MRDOCS_PP_FOR_EACH( \ + MRDOCS_KIND_ENTRY, C, __VA_ARGS__)) \ + )) mrdocs_kind_descriptor_fn(C**) { return {}; } + +/** Append one kind to a @ref MRDOCS_DESCRIBE_KINDS-style list. + + Used internally by @ref MRDOCS_DESCRIBE_KINDS and exposed so + that the BEGIN/END variant can drive the list from an + `.inc` file. + + @param C The polymorphic base class. + @param D A concrete derived class of `C`. +*/ +#define MRDOCS_KIND_ENTRY(C, D) \ + , ::mrdocs::describe::kind_descriptor{} + +/** Open a @ref MRDOCS_DESCRIBE_KINDS list driven by an `.inc` file. + + Same effect as @ref MRDOCS_DESCRIBE_KINDS, but split so the kind + list can be sourced from an existing X-macro `.inc` file (the + typical pattern in MrDocs). Usage: + + @code + #define INFO(Name) MRDOCS_KIND_ENTRY(Base, Name##Suffix) + MRDOCS_DESCRIBE_KINDS_BEGIN(Base) + #include + MRDOCS_DESCRIBE_KINDS_END(Base) + #undef INFO + @endcode + + @param C The polymorphic base class. +*/ +#define MRDOCS_DESCRIBE_KINDS_BEGIN(C) \ + static_assert(std::is_class_v, \ + "MRDOCS_DESCRIBE_KINDS_BEGIN should only be used " \ + "with class types"); \ + [[maybe_unused]] \ + inline decltype( \ + ::mrdocs::describe::kind_descriptor_fn_impl(0 + +/** Close a @ref MRDOCS_DESCRIBE_KINDS_BEGIN-opened list. + + @param C The polymorphic base class (must match the argument + passed to @ref MRDOCS_DESCRIBE_KINDS_BEGIN). +*/ +#define MRDOCS_DESCRIBE_KINDS_END(C) \ + )) mrdocs_kind_descriptor_fn(C**) { return {}; } + +#endif // MRDOCS_SUPPORT_DESCRIBEKINDS_HPP diff --git a/src/lib/Metadata/DocComment/Block/BlockKinds.hpp b/src/lib/Metadata/DocComment/Block/BlockKinds.hpp new file mode 100644 index 0000000000..d0035f295e --- /dev/null +++ b/src/lib/Metadata/DocComment/Block/BlockKinds.hpp @@ -0,0 +1,46 @@ +// +// 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_METADATA_DOCCOMMENT_BLOCK_BLOCKKINDS_HPP +#define MRDOCS_LIB_METADATA_DOCCOMMENT_BLOCK_BLOCKKINDS_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs::doc { + +#define INFO(Name) MRDOCS_KIND_ENTRY(Block, Name##Block) +MRDOCS_DESCRIBE_KINDS_BEGIN(Block) +#include +MRDOCS_DESCRIBE_KINDS_END(Block) +#undef INFO + +} // namespace mrdocs::doc + +#endif diff --git a/src/lib/Metadata/DocComment/Inline/InlineKinds.hpp b/src/lib/Metadata/DocComment/Inline/InlineKinds.hpp new file mode 100644 index 0000000000..1fd097bc32 --- /dev/null +++ b/src/lib/Metadata/DocComment/Inline/InlineKinds.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_METADATA_DOCCOMMENT_INLINE_INLINEKINDS_HPP +#define MRDOCS_LIB_METADATA_DOCCOMMENT_INLINE_INLINEKINDS_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs::doc { + +#define INFO(Name) MRDOCS_KIND_ENTRY(Inline, Name##Inline) +MRDOCS_DESCRIBE_KINDS_BEGIN(Inline) +#include +MRDOCS_DESCRIBE_KINDS_END(Inline) +#undef INFO + +} // namespace mrdocs::doc + +#endif diff --git a/src/lib/Metadata/NameKinds.hpp b/src/lib/Metadata/NameKinds.hpp new file mode 100644 index 0000000000..2ec245e35c --- /dev/null +++ b/src/lib/Metadata/NameKinds.hpp @@ -0,0 +1,29 @@ +// +// 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_METADATA_NAMEKINDS_HPP +#define MRDOCS_LIB_METADATA_NAMEKINDS_HPP + +#include +#include +#include +#include + +namespace mrdocs { + +#define INFO(Name_) MRDOCS_KIND_ENTRY(Name, Name_##Name) +MRDOCS_DESCRIBE_KINDS_BEGIN(Name) +#include +MRDOCS_DESCRIBE_KINDS_END(Name) +#undef INFO + +} // namespace mrdocs + +#endif diff --git a/src/lib/Metadata/Symbol/SymbolKinds.hpp b/src/lib/Metadata/Symbol/SymbolKinds.hpp new file mode 100644 index 0000000000..a8fba83709 --- /dev/null +++ b/src/lib/Metadata/Symbol/SymbolKinds.hpp @@ -0,0 +1,39 @@ +// +// 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_METADATA_SYMBOL_SYMBOLKINDS_HPP +#define MRDOCS_LIB_METADATA_SYMBOL_SYMBOLKINDS_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs { + +#define INFO(Name) MRDOCS_KIND_ENTRY(Symbol, Name##Symbol) +MRDOCS_DESCRIBE_KINDS_BEGIN(Symbol) +#include +MRDOCS_DESCRIBE_KINDS_END(Symbol) +#undef INFO + +} // namespace mrdocs + +#endif diff --git a/src/lib/Metadata/TArgKinds.hpp b/src/lib/Metadata/TArgKinds.hpp new file mode 100644 index 0000000000..dec431f98a --- /dev/null +++ b/src/lib/Metadata/TArgKinds.hpp @@ -0,0 +1,30 @@ +// +// 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_METADATA_TARGKINDS_HPP +#define MRDOCS_LIB_METADATA_TARGKINDS_HPP + +#include +#include +#include +#include +#include + +namespace mrdocs { + +#define INFO(Name) MRDOCS_KIND_ENTRY(TArg, Name##TArg) +MRDOCS_DESCRIBE_KINDS_BEGIN(TArg) +#include +MRDOCS_DESCRIBE_KINDS_END(TArg) +#undef INFO + +} // namespace mrdocs + +#endif diff --git a/src/lib/Metadata/TParamKinds.hpp b/src/lib/Metadata/TParamKinds.hpp new file mode 100644 index 0000000000..e7b13a24d3 --- /dev/null +++ b/src/lib/Metadata/TParamKinds.hpp @@ -0,0 +1,30 @@ +// +// 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_METADATA_TPARAMKINDS_HPP +#define MRDOCS_LIB_METADATA_TPARAMKINDS_HPP + +#include +#include +#include +#include +#include + +namespace mrdocs { + +#define INFO(Name) MRDOCS_KIND_ENTRY(TParam, Name##TParam) +MRDOCS_DESCRIBE_KINDS_BEGIN(TParam) +#include +MRDOCS_DESCRIBE_KINDS_END(TParam) +#undef INFO + +} // namespace mrdocs + +#endif diff --git a/src/lib/Metadata/TypeKinds.hpp b/src/lib/Metadata/TypeKinds.hpp new file mode 100644 index 0000000000..88a71eb1e3 --- /dev/null +++ b/src/lib/Metadata/TypeKinds.hpp @@ -0,0 +1,36 @@ +// +// 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_METADATA_TYPEKINDS_HPP +#define MRDOCS_LIB_METADATA_TYPEKINDS_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs { + +#define INFO(Name) MRDOCS_KIND_ENTRY(Type, Name##Type) +MRDOCS_DESCRIBE_KINDS_BEGIN(Type) +#include +MRDOCS_DESCRIBE_KINDS_END(Type) +#undef INFO + +} // namespace mrdocs + +#endif diff --git a/src/test/Support/DescribeKinds.cpp b/src/test/Support/DescribeKinds.cpp new file mode 100644 index 0000000000..508e01e491 --- /dev/null +++ b/src/test/Support/DescribeKinds.cpp @@ -0,0 +1,196 @@ +// +// 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 + +namespace mrdocs { +namespace { + +// ------------------------------------------------------------------ +// Fixture types +// ------------------------------------------------------------------ +// +// Toy class hierarchies registered with the variadic and the +// BEGIN/END forms of `MRDOCS_DESCRIBE_KINDS`, plus one base with no +// kinds at all and one type that is never registered. + +// Variadic form. +struct VBase {}; +struct VFoo : VBase {}; +struct VBar : VBase {}; +struct VBaz : VBase {}; + +// BEGIN/END form. +struct BBase {}; +struct BAlpha : BBase {}; +struct BBeta : BBase {}; + +// Empty kind list (the base is registered, but no derived classes). +struct EBase {}; + +// Never registered. +struct NotRegistered {}; + +// Registrations live in the same (anonymous) namespace as the +// fixtures so ADL on the base type can find the descriptor +// function. The function has internal linkage, which is fine +// here: it's only consumed by `decltype` in this TU. + +MRDOCS_DESCRIBE_KINDS(VBase, VFoo, VBar, VBaz) + +MRDOCS_DESCRIBE_KINDS(EBase) + +#define INFO(Name) MRDOCS_KIND_ENTRY(BBase, B##Name) +MRDOCS_DESCRIBE_KINDS_BEGIN(BBase) +INFO(Alpha) +INFO(Beta) +MRDOCS_DESCRIBE_KINDS_END(BBase) +#undef INFO + +// ------------------------------------------------------------------ +// has_describe_kinds +// ------------------------------------------------------------------ + +struct TraitTest +{ + void test_true_for_registered_variadic() + { + BOOST_TEST(describe::has_describe_kinds::value); + } + + void test_true_for_registered_begin_end() + { + BOOST_TEST(describe::has_describe_kinds::value); + } + + void test_true_for_empty_registration() + { + BOOST_TEST(describe::has_describe_kinds::value); + } + + void test_false_for_unregistered_class() + { + BOOST_TEST(!describe::has_describe_kinds::value); + } +}; + +// ------------------------------------------------------------------ +// describe_kinds and for_each +// ------------------------------------------------------------------ + +struct IterationTest +{ + void test_count_variadic() + { + int n = 0; + describe::for_each( + describe::describe_kinds{}, + [&](auto) { ++n; }); + BOOST_TEST(n == 3); + } + + void test_count_begin_end() + { + int n = 0; + describe::for_each( + describe::describe_kinds{}, + [&](auto) { ++n; }); + BOOST_TEST(n == 2); + } + + void test_count_empty() + { + int n = 0; + describe::for_each( + describe::describe_kinds{}, + [&](auto) { ++n; }); + BOOST_TEST(n == 0); + } + + void test_order_preserved_variadic() + { + // Each kind contributes a digit; we verify they arrive in + // the order they were listed in the macro invocation. + std::string seen; + describe::for_each( + describe::describe_kinds{}, + [&](auto descriptor) + { + using D = typename + std::decay_t::type; + if constexpr (std::is_same_v) + { + seen += '0'; + } + else if constexpr (std::is_same_v) + { + seen += '1'; + } + else if constexpr (std::is_same_v) + { + seen += '2'; + } + }); + BOOST_TEST(seen == std::string("012")); + } + + void test_order_preserved_begin_end() + { + std::string seen; + describe::for_each( + describe::describe_kinds{}, + [&](auto descriptor) + { + using D = typename + std::decay_t::type; + if constexpr (std::is_same_v) + { + seen += '0'; + } + else if constexpr (std::is_same_v) + { + seen += '1'; + } + }); + BOOST_TEST(seen == std::string("01")); + } +}; + +// ------------------------------------------------------------------ + +struct DescribeKindsTest +{ + void run() + { + TraitTest trait; + trait.test_true_for_registered_variadic(); + trait.test_true_for_registered_begin_end(); + trait.test_true_for_empty_registration(); + trait.test_false_for_unregistered_class(); + + IterationTest iter; + iter.test_count_variadic(); + iter.test_count_begin_end(); + iter.test_count_empty(); + iter.test_order_preserved_variadic(); + iter.test_order_preserved_begin_end(); + } +}; + +} // namespace + +TEST_SUITE( + DescribeKindsTest, + "clang.mrdocs.Support.DescribeKinds"); + +} // namespace mrdocs From 733a443ee31a092695c9937beffa2fd4603374fc Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Fri, 22 May 2026 18:45:43 +0200 Subject: [PATCH 4/5] feat: support corpus-mutation extensions in Lua and JavaScript Run user-provided scripts under /extensions/.{lua,js} between corpus finalization and the first generator invocation. Each script may define a global `transform_corpus(corpus)`; the host calls it once with a read view of the corpus (the same DOM the generators see) and the script applies changes through a `mrdocs.set(id, field, value)` API. The write surface is a narrow, allowlist-gated setter driven by reflection: `mrdocs.set` dispatches on the normalized C++ member name, so the script-facing fields track the C++ model without a hand-written binding table. It understands strings, booleans, described enums, `Optional`, `vector`, described structs, and `Polymorphic` (the `kind:` selector picks a derived class registered with `MRDOCS_DESCRIBE_KINDS`). The allowlist (`name`, `extraction`, `isCopyFromInherited`, `loc`, `doc`, `returnType`) is generated at build time from src/lib/Extensions/AllowedFields.json, so the runtime gate and the rendered reference table share one source of truth. The extension stack is split across small translation units: AddonDiscovery (collect scripts across addon roots), SetMember (the write surface and the corpus DOM), LuaBinding and JsBinding (the per-language adapters), and RunExtensions (the orchestrator). `CorpusImpl` invokes `runExtensions` after finalization. The shared addonRoots helper used by both the Handlebars layer and the extension layer moved to src/lib/Support/AddonRoots.hpp; the `dom::Array` metatable in src/lib/Support/Lua.cpp is now a Lua-convention 1-indexed sequence so corpus.symbols iterates with ipairs. Golden fixtures under test-files/golden-tests/extensions/ exercise rename, brief rewrite, clearing an optional, a `Polymorphic` return-type write, script ordering across addon roots, and the silent skipping of a script that defines no transform_corpus. A unit test covers the `mrdocs.set` error paths. --- CMakeLists.txt | 26 + .../partials/extensions-allowed-fields.adoc | 32 + include/mrdocs/Metadata/Type/TypeKind.hpp | 12 + include/mrdocs/Support/Lua.hpp | 27 + include/mrdocs/Support/MergeReflectedType.hpp | 12 +- include/mrdocs/Support/TypeTraits.hpp | 10 + src/lib/CorpusImpl.cpp | 8 + src/lib/Extensions/AddonDiscovery.cpp | 51 ++ src/lib/Extensions/AddonDiscovery.hpp | 33 + src/lib/Extensions/AllowedFields.json | 43 ++ src/lib/Extensions/JsBinding.cpp | 97 +++ src/lib/Extensions/JsBinding.hpp | 34 + src/lib/Extensions/LuaBinding.cpp | 252 ++++++ src/lib/Extensions/LuaBinding.hpp | 34 + src/lib/Extensions/RunExtensions.cpp | 58 ++ src/lib/Extensions/RunExtensions.hpp | 47 ++ src/lib/Extensions/SetMember.cpp | 727 ++++++++++++++++++ src/lib/Extensions/SetMember.hpp | 74 ++ src/lib/Gen/hbs/AddonPaths.hpp | 32 +- src/lib/Gen/hbs/Builder.cpp | 2 +- src/lib/Support/AddonRoots.hpp | 53 ++ src/lib/Support/Lua.cpp | 16 + src/test/Extensions/SetMember.cpp | 328 ++++++++ .../js-set-name/addons/extensions/rename.js | 28 + .../extensions/js-set-name/mrdocs.yml | 6 + .../extensions/js-set-name/set_name.adoc | 32 + .../extensions/js-set-name/set_name.cpp | 2 + .../extensions/js-set-name/set_name.html | 52 ++ .../extensions/js-set-name/set_name.xml | 46 ++ .../lua-clear-doc/addons/extensions/clear.lua | 12 + .../extensions/lua-clear-doc/clear_doc.adoc | 29 + .../extensions/lua-clear-doc/clear_doc.cpp | 4 + .../extensions/lua-clear-doc/clear_doc.html | 49 ++ .../extensions/lua-clear-doc/clear_doc.xml | 37 + .../extensions/lua-clear-doc/mrdocs.yml | 6 + .../addons/extensions/empty.lua | 3 + .../addons/extensions/non_transform.lua | 5 + .../lua-empty-script/empty_script.adoc | 32 + .../lua-empty-script/empty_script.cpp | 4 + .../lua-empty-script/empty_script.html | 52 ++ .../lua-empty-script/empty_script.xml | 57 ++ .../extensions/lua-empty-script/mrdocs.yml | 6 + .../addons/primary/extensions/zzz-primary.lua | 14 + .../extensions/aaa-supplemental.lua | 12 + .../lua-extension-ordering/mrdocs.yml | 7 + .../lua-extension-ordering/ordering.adoc | 32 + .../lua-extension-ordering/ordering.cpp | 4 + .../lua-extension-ordering/ordering.html | 52 ++ .../lua-extension-ordering/ordering.xml | 46 ++ .../lua-set-name/addons/extensions/rename.lua | 23 + .../extensions/lua-set-name/mrdocs.yml | 6 + .../extensions/lua-set-name/set_name.adoc | 32 + .../extensions/lua-set-name/set_name.cpp | 2 + .../extensions/lua-set-name/set_name.html | 52 ++ .../extensions/lua-set-name/set_name.xml | 46 ++ .../addons/extensions/replace_return.lua | 41 + .../extensions/lua-set-return-type/mrdocs.yml | 6 + .../lua-set-return-type/set_return_type.adoc | 32 + .../lua-set-return-type/set_return_type.cpp | 2 + .../lua-set-return-type/set_return_type.html | 52 ++ .../lua-set-return-type/set_return_type.xml | 46 ++ util/generate_extension_allowed_fields.py | 123 +++ 62 files changed, 3065 insertions(+), 35 deletions(-) create mode 100644 docs/modules/ROOT/partials/extensions-allowed-fields.adoc create mode 100644 src/lib/Extensions/AddonDiscovery.cpp create mode 100644 src/lib/Extensions/AddonDiscovery.hpp create mode 100644 src/lib/Extensions/AllowedFields.json create mode 100644 src/lib/Extensions/JsBinding.cpp create mode 100644 src/lib/Extensions/JsBinding.hpp create mode 100644 src/lib/Extensions/LuaBinding.cpp create mode 100644 src/lib/Extensions/LuaBinding.hpp create mode 100644 src/lib/Extensions/RunExtensions.cpp create mode 100644 src/lib/Extensions/RunExtensions.hpp create mode 100644 src/lib/Extensions/SetMember.cpp create mode 100644 src/lib/Extensions/SetMember.hpp create mode 100644 src/lib/Support/AddonRoots.hpp create mode 100644 src/test/Extensions/SetMember.cpp create mode 100644 test-files/golden-tests/extensions/js-set-name/addons/extensions/rename.js create mode 100644 test-files/golden-tests/extensions/js-set-name/mrdocs.yml create mode 100644 test-files/golden-tests/extensions/js-set-name/set_name.adoc create mode 100644 test-files/golden-tests/extensions/js-set-name/set_name.cpp create mode 100644 test-files/golden-tests/extensions/js-set-name/set_name.html create mode 100644 test-files/golden-tests/extensions/js-set-name/set_name.xml create mode 100644 test-files/golden-tests/extensions/lua-clear-doc/addons/extensions/clear.lua create mode 100644 test-files/golden-tests/extensions/lua-clear-doc/clear_doc.adoc create mode 100644 test-files/golden-tests/extensions/lua-clear-doc/clear_doc.cpp create mode 100644 test-files/golden-tests/extensions/lua-clear-doc/clear_doc.html create mode 100644 test-files/golden-tests/extensions/lua-clear-doc/clear_doc.xml create mode 100644 test-files/golden-tests/extensions/lua-clear-doc/mrdocs.yml create mode 100644 test-files/golden-tests/extensions/lua-empty-script/addons/extensions/empty.lua create mode 100644 test-files/golden-tests/extensions/lua-empty-script/addons/extensions/non_transform.lua create mode 100644 test-files/golden-tests/extensions/lua-empty-script/empty_script.adoc create mode 100644 test-files/golden-tests/extensions/lua-empty-script/empty_script.cpp create mode 100644 test-files/golden-tests/extensions/lua-empty-script/empty_script.html create mode 100644 test-files/golden-tests/extensions/lua-empty-script/empty_script.xml create mode 100644 test-files/golden-tests/extensions/lua-empty-script/mrdocs.yml create mode 100644 test-files/golden-tests/extensions/lua-extension-ordering/addons/primary/extensions/zzz-primary.lua create mode 100644 test-files/golden-tests/extensions/lua-extension-ordering/addons/supplemental/extensions/aaa-supplemental.lua create mode 100644 test-files/golden-tests/extensions/lua-extension-ordering/mrdocs.yml create mode 100644 test-files/golden-tests/extensions/lua-extension-ordering/ordering.adoc create mode 100644 test-files/golden-tests/extensions/lua-extension-ordering/ordering.cpp create mode 100644 test-files/golden-tests/extensions/lua-extension-ordering/ordering.html create mode 100644 test-files/golden-tests/extensions/lua-extension-ordering/ordering.xml create mode 100644 test-files/golden-tests/extensions/lua-set-name/addons/extensions/rename.lua create mode 100644 test-files/golden-tests/extensions/lua-set-name/mrdocs.yml create mode 100644 test-files/golden-tests/extensions/lua-set-name/set_name.adoc create mode 100644 test-files/golden-tests/extensions/lua-set-name/set_name.cpp create mode 100644 test-files/golden-tests/extensions/lua-set-name/set_name.html create mode 100644 test-files/golden-tests/extensions/lua-set-name/set_name.xml create mode 100644 test-files/golden-tests/extensions/lua-set-return-type/addons/extensions/replace_return.lua create mode 100644 test-files/golden-tests/extensions/lua-set-return-type/mrdocs.yml create mode 100644 test-files/golden-tests/extensions/lua-set-return-type/set_return_type.adoc create mode 100644 test-files/golden-tests/extensions/lua-set-return-type/set_return_type.cpp create mode 100644 test-files/golden-tests/extensions/lua-set-return-type/set_return_type.html create mode 100644 test-files/golden-tests/extensions/lua-set-return-type/set_return_type.xml create mode 100644 util/generate_extension_allowed_fields.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a0a745131..2856e67d42 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -176,6 +176,31 @@ else() ) endif() +# Generate the mrdocs.set allowlist artifacts from +# src/lib/Extensions/AllowedFields.json: a constexpr `kSettableFields[]` +# array consumed by SetMember.cpp at runtime, and an AsciiDoc table +# included by docs/modules/ROOT/pages/extensions.adoc. Same JSON drives +# both, so the runtime allowlist and the rendered reference cannot +# drift. +set(ALLOWED_FIELDS_JSON + "${CMAKE_CURRENT_SOURCE_DIR}/src/lib/Extensions/AllowedFields.json") +set(ALLOWED_FIELDS_SCRIPT + "${CMAKE_CURRENT_SOURCE_DIR}/util/generate_extension_allowed_fields.py") +set(ALLOWED_FIELDS_CPP + "${CMAKE_CURRENT_BINARY_DIR}/include/mrdocs/Extensions/AllowedFields.gen.hpp") +set(ALLOWED_FIELDS_ADOC + "${CMAKE_CURRENT_SOURCE_DIR}/docs/modules/ROOT/partials/extensions-allowed-fields.adoc") +add_custom_command( + OUTPUT ${ALLOWED_FIELDS_CPP} ${ALLOWED_FIELDS_ADOC} + COMMAND ${PYTHON_EXECUTABLE} ${ALLOWED_FIELDS_SCRIPT} + ${ALLOWED_FIELDS_JSON} + ${ALLOWED_FIELDS_CPP} + ${ALLOWED_FIELDS_ADOC} + DEPENDS ${ALLOWED_FIELDS_JSON} ${ALLOWED_FIELDS_SCRIPT} + COMMENT "Generating mrdocs.set allowlist artifacts" + VERBATIM +) + #------------------------------------------------- # # Docs build @@ -313,6 +338,7 @@ list(APPEND LIB_SOURCES ${CMAKE_CURRENT_BINARY_DIR}/include/mrdocs/Version.hpp ${CMAKE_CURRENT_BINARY_DIR}/include/mrdocs/PublicSettings.hpp ${CMAKE_CURRENT_BINARY_DIR}/src/lib/Lib/PublicSettings.cpp + ${ALLOWED_FIELDS_CPP} ) add_library(mrdocs-core ${LIB_SOURCES}) target_compile_features(mrdocs-core PUBLIC cxx_std_23) diff --git a/docs/modules/ROOT/partials/extensions-allowed-fields.adoc b/docs/modules/ROOT/partials/extensions-allowed-fields.adoc new file mode 100644 index 0000000000..d32aa4f734 --- /dev/null +++ b/docs/modules/ROOT/partials/extensions-allowed-fields.adoc @@ -0,0 +1,32 @@ +// Generated by util/generate_extension_allowed_fields.py from +// src/lib/Extensions/AllowedFields.json. DO NOT EDIT - edit +// the JSON and rebuild. + +|=== +|Field |Type |Description + +|`name` +|string +|The unqualified symbol name. + +|`extraction` +|enum +|Extraction mode - one of `regular`, `see-below`, `implementation-defined`, `dependency`. + +|`isCopyFromInherited` +|bool +|Whether the symbol was generated by base-member inheritance. + +|`loc` +|struct +|Source location information. + +|`doc` +|optional struct +|The full doc-comment tree. Pass `null` to clear, or a partial object to overwrite individual fields (brief, returns, params, ...). Brief text is rewritten by passing `{ brief: { children: [{ kind: "text", literal: "..." }] } }` -- `literal` is the DOM key for text inlines; see the Handlebars reference for the rest of the shape. + +|`returnType` +|polymorphic `Type` +|A function's return type. The `kind` selector picks a concrete `Type` variant; remaining keys are forwarded to that variant. `TypeKind` is the one polymorphic base whose `kind` values come from `toString(TypeKind)` (e.g., `lvalue-reference`) rather than from the kebab-case of the enumerator (e.g., `l-value-reference` as seen in the XML writer). + +|=== diff --git a/include/mrdocs/Metadata/Type/TypeKind.hpp b/include/mrdocs/Metadata/Type/TypeKind.hpp index dc1221192b..aa138f4bc6 100644 --- a/include/mrdocs/Metadata/Type/TypeKind.hpp +++ b/include/mrdocs/Metadata/Type/TypeKind.hpp @@ -17,6 +17,18 @@ namespace mrdocs { /** Variants of C++ types captured in metadata. + + @note `TypeKind` is intentionally NOT registered with + `MRDOCS_DESCRIBE_ENUM`. Describing it would make the reflection- + driven XML writer emit a redundant `...` child into + every type element (NamedType, LValueReferenceType, ...), which + would churn every XML golden test for no semantic gain. Code that + needs a string form for a `TypeKind` value calls `toString` below; + the script side of `mrdocs.set` falls back to `toString` for + polymorphic `kind:` matching when the discriminator enum is + undescribed, so script names (`lvalue-reference`, ...) match the + DOM and Handlebars side and differ only from the XML writer's tag + form (`l-value-reference`, ...). */ enum class TypeKind { #define INFO(Type) Type, diff --git a/include/mrdocs/Support/Lua.hpp b/include/mrdocs/Support/Lua.hpp index ea92992d5f..ee5a8f4f00 100644 --- a/include/mrdocs/Support/Lua.hpp +++ b/include/mrdocs/Support/Lua.hpp @@ -121,6 +121,19 @@ class MRDOCS_DECL /** Constructor. */ Context(Context const&) noexcept; + + /** Return the underlying `lua_State*`. + + Exposed as `void*` so callers don't have to drag `lua.h` into + the public API. Cast to `lua_State*` at the use site. The state + is owned by this Context and must not be `lua_close`d by the + caller; use this only when the wrapper does not yet abstract + 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; }; //------------------------------------------------ @@ -210,6 +223,20 @@ class Scope std::string_view key, source_location loc = source_location::current()); + + /** Push a dom::Value onto the Lua stack. + + Primitives (nil, boolean, integer, string) are pushed as their + Lua-native counterparts. Arrays and objects are pushed as + userdata wrapping the underlying dom container, with the same + lazy bindings used elsewhere in the wrapper. + + @param value The DOM value to push. + @return A Value referring to the new stack slot. + */ + MRDOCS_DECL + Value + pushDom(dom::Value const& value); }; //------------------------------------------------ diff --git a/include/mrdocs/Support/MergeReflectedType.hpp b/include/mrdocs/Support/MergeReflectedType.hpp index e8f8712df7..0a6b43a7fa 100644 --- a/include/mrdocs/Support/MergeReflectedType.hpp +++ b/include/mrdocs/Support/MergeReflectedType.hpp @@ -44,11 +44,15 @@ isDefaultEnum(E value) } // Type trait: is this Polymorphic for a given U? +// +// Built alongside (not on top of) the unary `is_polymorphic_v` +// from Support/TypeTraits.hpp, which asks the related question +// "is `T` some Polymorphic<...>?" without naming the inner type. template -inline constexpr bool is_polymorphic_v = false; +inline constexpr bool is_polymorphic_for_v = false; template -inline constexpr bool is_polymorphic_v, U> = true; +inline constexpr bool is_polymorphic_for_v, U> = true; // Type trait: can we call merge(T&, T&&) via ADL? template @@ -135,7 +139,7 @@ mergeByType(T& dst, T&& src) // Polymorphic: take src if dst is in a placeholder // state — either AutoType{} or a blank NamedType with an // empty Identifier. - else if constexpr (is_polymorphic_v) + else if constexpr (is_polymorphic_for_v) { if (isPlaceholderType(dst)) { @@ -144,7 +148,7 @@ mergeByType(T& dst, T&& src) return true; } // Polymorphic: take src if dst has an empty Identifier. - else if constexpr (is_polymorphic_v) + else if constexpr (is_polymorphic_for_v) { if (dst->Identifier.empty()) { diff --git a/include/mrdocs/Support/TypeTraits.hpp b/include/mrdocs/Support/TypeTraits.hpp index 573824e5ce..2658c26d4f 100644 --- a/include/mrdocs/Support/TypeTraits.hpp +++ b/include/mrdocs/Support/TypeTraits.hpp @@ -13,6 +13,7 @@ #define MRDOCS_API_SUPPORT_TYPETRAITS_HPP #include +#include #include #include @@ -39,6 +40,15 @@ struct is_vector> : std::true_type {}; template inline constexpr bool is_vector_v = is_vector::value; +template +struct is_polymorphic : std::false_type {}; + +template +struct is_polymorphic> : std::true_type {}; + +template +inline constexpr bool is_polymorphic_v = is_polymorphic::value; + } // namespace detail /** Return the value as its underlying type. diff --git a/src/lib/CorpusImpl.cpp b/src/lib/CorpusImpl.cpp index 559362e8cb..5877df7a6d 100644 --- a/src/lib/CorpusImpl.cpp +++ b/src/lib/CorpusImpl.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -931,6 +932,13 @@ CorpusImpl::build( // ------------------------------------------ corpus->finalize(); + // ------------------------------------------ + // Run user extension scripts + // ------------------------------------------ + // Extensions fire after finalizers and before any generator runs, + // so any mutations they perform are visible to every output format. + MRDOCS_TRY(runExtensions(*corpus)); + report::info( "Extracted {} declarations in {}", corpus->info_.size(), diff --git a/src/lib/Extensions/AddonDiscovery.cpp b/src/lib/Extensions/AddonDiscovery.cpp new file mode 100644 index 0000000000..de50f45db0 --- /dev/null +++ b/src/lib/Extensions/AddonDiscovery.cpp @@ -0,0 +1,51 @@ +// +// 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 "AddonDiscovery.hpp" + +#include + +#include + +#include + +namespace mrdocs { + +Expected> +collectExtensionScripts(Config const& config) +{ + std::vector scripts; + std::vector const roots = addonRoots(config); + for (std::string const& root : roots) + { + std::string const dir = files::appendPath(root, "extensions"); + if (files::exists(dir)) + { + Expected exp = forEachFile(dir, true, + [&](std::string_view pathName) -> Expected + { + if (pathName.ends_with(".lua") || + pathName.ends_with(".js")) + { + scripts.emplace_back(pathName); + } + return {}; + }); + if (!exp) + { + return Unexpected(exp.error()); + } + } + } + std::sort(scripts.begin(), scripts.end()); + return scripts; +} + +} // mrdocs diff --git a/src/lib/Extensions/AddonDiscovery.hpp b/src/lib/Extensions/AddonDiscovery.hpp new file mode 100644 index 0000000000..897dafa454 --- /dev/null +++ b/src/lib/Extensions/AddonDiscovery.hpp @@ -0,0 +1,33 @@ +// +// 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_EXTENSIONS_ADDONDISCOVERY_HPP +#define MRDOCS_LIB_EXTENSIONS_ADDONDISCOVERY_HPP + +#include +#include +#include +#include + +namespace mrdocs { + +/** Return the extension scripts across every addon root. + + Walks `/extensions/` under each addon root and gathers every + `.lua` and `.js` file, sorted alphabetically by full path. The two + languages are interleaved so behavior doesn't depend on which + language a user happens to write in - only on file names. +*/ +Expected> +collectExtensionScripts(Config const& config); + +} // mrdocs + +#endif diff --git a/src/lib/Extensions/AllowedFields.json b/src/lib/Extensions/AllowedFields.json new file mode 100644 index 0000000000..906983be99 --- /dev/null +++ b/src/lib/Extensions/AllowedFields.json @@ -0,0 +1,43 @@ +{ + "$comment": [ + "Source of truth for the mrdocs.set allowlist. Drives both the", + "C++-side kSettableFields array (via util/generate_extension_allowed_fields.py", + "emitting include/mrdocs/Extensions/AllowedFields.gen.hpp at build", + "time) and the AsciiDoc reference table (via the same script", + "emitting docs/modules/ROOT/partials/extensions-allowed-fields.adoc).", + "Adding a new entry here is the single edit needed to extend the", + "allowlist; both the runtime check and the rendered docs follow." + ], + "fields": [ + { + "name": "name", + "type": "string", + "description": "The unqualified symbol name." + }, + { + "name": "extraction", + "type": "enum", + "description": "Extraction mode - one of `regular`, `see-below`, `implementation-defined`, `dependency`." + }, + { + "name": "isCopyFromInherited", + "type": "bool", + "description": "Whether the symbol was generated by base-member inheritance." + }, + { + "name": "loc", + "type": "struct", + "description": "Source location information." + }, + { + "name": "doc", + "type": "optional struct", + "description": "The full doc-comment tree. Pass `null` to clear, or a partial object to overwrite individual fields (brief, returns, params, ...). Brief text is rewritten by passing `{ brief: { children: [{ kind: \"text\", literal: \"...\" }] } }` -- `literal` is the DOM key for text inlines; see the Handlebars reference for the rest of the shape." + }, + { + "name": "returnType", + "type": "polymorphic `Type`", + "description": "A function's return type. The `kind` selector picks a concrete `Type` variant; remaining keys are forwarded to that variant. `TypeKind` is the one polymorphic base whose `kind` values come from `toString(TypeKind)` (e.g., `lvalue-reference`) rather than from the kebab-case of the enumerator (e.g., `l-value-reference` as seen in the XML writer)." + } + ] +} diff --git a/src/lib/Extensions/JsBinding.cpp b/src/lib/Extensions/JsBinding.cpp new file mode 100644 index 0000000000..9d97d51b04 --- /dev/null +++ b/src/lib/Extensions/JsBinding.cpp @@ -0,0 +1,97 @@ +// +// 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 "JsBinding.hpp" +#include "SetMember.hpp" + +#include + +#include +#include +#include +#include + +#include +#include + +namespace mrdocs { +namespace { + +// The JS wrapper already knows how to expose a `dom::Function` as a +// callable JS value (`setGlobal` -> `toJsValue` -> `makeFunctionProxy`), +// so no escape hatch is needed: we just build the `mrdocs` API as a +// `dom::Object` containing `dom::Function` entries and set it as a +// global. +dom::Object +buildJsMrDocsApi(ExtensionState& state) +{ + // `ExtensionState` is a stack local in `runOneJsExtension`; capturing + // by raw pointer here is safe because the API object, the script + // execution, and the state all live within the same call frame. + ExtensionState* statePtr = &state; + dom::Object api; + api.set("set", dom::Value(dom::makeVariadicInvocable( + [statePtr](dom::Array const& args) -> Expected + { + if (args.size() < 3) + { + return Unexpected(Error( + "mrdocs.set: expected (symbol_id, field, value)")); + } + return setMemberImpl( + *statePtr, args.get(0), args.get(1), args.get(2)); + }))); + return api; +} + +} // (anon) + +Expected +runOneJsExtension(CorpusImpl& corpus, std::string const& scriptPath) +{ + js::Context ctx; + ExtensionState state{ &corpus, {} }; + + DomCorpus domCorpus(corpus); + dom::Value corpusValue = buildCorpusDom(corpus, domCorpus, state); + + js::Scope scope(ctx); + + // Expose `mrdocs.set(...)` (and any future setters) as a global + // object whose entries are `dom::Function`s; the JS wrapper turns + // these into callable proxies via `makeFunctionProxy`. + scope.setGlobal("mrdocs", dom::Value(buildJsMrDocsApi(state))); + + // Run the script (defines globals, including `transform_corpus`). + MRDOCS_TRY(std::string script, files::getFileText(scriptPath)); + if (Expected exp = scope.script(script); !exp) + { + return Unexpected(formatError( + "extension '{}': {}", + scriptPath, exp.error().message())); + } + + Expected fn = scope.getGlobal("transform_corpus"); + if (!fn || !fn->isFunction()) + { + return {}; + } + + Expected result = fn->call(corpusValue); + if (!result) + { + return Unexpected(formatError( + "extension '{}': {}", + scriptPath, result.error().message())); + } + return {}; +} + +} // mrdocs diff --git a/src/lib/Extensions/JsBinding.hpp b/src/lib/Extensions/JsBinding.hpp new file mode 100644 index 0000000000..07d18fdb5b --- /dev/null +++ b/src/lib/Extensions/JsBinding.hpp @@ -0,0 +1,34 @@ +// +// 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_EXTENSIONS_JSBINDING_HPP +#define MRDOCS_LIB_EXTENSIONS_JSBINDING_HPP + +#include +#include +#include + +namespace mrdocs { + +class CorpusImpl; + +/** Run one JavaScript extension script against the corpus. + + Builds a fresh JS context, exposes the `mrdocs` global object, + evaluates the script, and invokes `transform_corpus(corpus)` if + defined. A script that defines no such function is silently + skipped, so an empty `.js` file is valid. +*/ +Expected +runOneJsExtension(CorpusImpl& corpus, std::string const& scriptPath); + +} // mrdocs + +#endif diff --git a/src/lib/Extensions/LuaBinding.cpp b/src/lib/Extensions/LuaBinding.cpp new file mode 100644 index 0000000000..0fd14df942 --- /dev/null +++ b/src/lib/Extensions/LuaBinding.cpp @@ -0,0 +1,252 @@ +// +// 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 "LuaBinding.hpp" +#include "SetMember.hpp" + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} + +namespace mrdocs { +namespace { + +ExtensionState& +upvalueState(lua_State* L) +{ + ExtensionState* p = static_cast( + lua_touserdata(L, lua_upvalueindex(1))); + MRDOCS_ASSERT(p != nullptr); + return *p; +} + +// Pull a Lua stack value at `index` into a `dom::Value`. Tables are +// classified by inspection: any string key turns the table into a +// `dom::Object`; otherwise it's treated as a 1-based array (Lua +// convention) and copied entry-by-entry into a `dom::Array`. +// Unrepresentable Lua types (function, thread, light userdata) become +// `nil` so `assignFromDom` produces a typed error rather than this +// adapter swallowing the argument. +dom::Value +luaValueToDom(lua_State* L, int index); + +dom::Value +luaTableToDom(lua_State* L, int absIdx) +{ + bool hasStringKey = false; + lua_pushnil(L); + while (lua_next(L, absIdx) != 0) + { + if (lua_type(L, -2) == LUA_TSTRING) + { + hasStringKey = true; + lua_pop(L, 2); + break; + } + lua_pop(L, 1); + } + + if (hasStringKey) + { + dom::Object obj; + lua_pushnil(L); + while (lua_next(L, absIdx) != 0) + { + if (lua_type(L, -2) == LUA_TSTRING) + { + std::size_t klen = 0; + char const* kdata = lua_tolstring(L, -2, &klen); + obj.set( + std::string_view(kdata, klen), + luaValueToDom(L, -1)); + } + lua_pop(L, 1); + } + return dom::Value(std::move(obj)); + } + + dom::Array arr; + lua_Unsigned const len = lua_rawlen(L, absIdx); + for (lua_Unsigned i = 1; i <= len; ++i) + { + lua_rawgeti(L, absIdx, static_cast(i)); + arr.push_back(luaValueToDom(L, -1)); + lua_pop(L, 1); + } + return dom::Value(std::move(arr)); +} + +dom::Value +luaValueToDom(lua_State* L, int index) +{ + int const absIdx = lua_absindex(L, index); + switch (lua_type(L, absIdx)) + { + case LUA_TNIL: + // Lua has only one nullary value (nil); map it to DOM `Null` + // (not `Undefined`) so scripts can pass nil through + // `mrdocs.set` to clear an `Optional` field, matching the + // JS side and the docs. + return dom::Value(nullptr); + case LUA_TBOOLEAN: + return dom::Value(lua_toboolean(L, absIdx) != 0); + case LUA_TNUMBER: + if (lua_isinteger(L, absIdx)) + { + return dom::Value(static_cast( + lua_tointeger(L, absIdx))); + } + return dom::Value(static_cast( + lua_tonumber(L, absIdx))); + case LUA_TSTRING: + { + std::size_t len = 0; + char const* data = lua_tolstring(L, absIdx, &len); + return dom::Value(std::string(data, len)); + } + case LUA_TTABLE: + return luaTableToDom(L, absIdx); + default: + return dom::Value(); + } +} + +// Lua adapter for `setMemberImpl`. On failure the script aborts via +// `luaL_error`; the host turns that into an `Unexpected` when +// `lua_pcall` returns non-OK. +int +luaSet(lua_State* L) +{ + ExtensionState& state = upvalueState(L); + + if (lua_type(L, 1) != LUA_TSTRING || + lua_type(L, 2) != LUA_TSTRING) + { + return luaL_error(L, + "mrdocs.set: expected (string symbol_id, string field, value)"); + } + + std::size_t idLen = 0; + char const* idData = lua_tolstring(L, 1, &idLen); + std::size_t fieldLen = 0; + char const* fieldData = lua_tolstring(L, 2, &fieldLen); + + Expected result = setMemberImpl( + state, + dom::Value(std::string(idData, idLen)), + dom::Value(std::string(fieldData, fieldLen)), + luaValueToDom(L, 3)); + if (!result) + { + return luaL_error(L, "%s", result.error().message().c_str()); + } + + return 0; +} + +// Build the `mrdocs` global table and populate it with the setters. +// +// We register C closures directly on the raw `lua_State*` (via the +// `Context::nativeState()` escape hatch) because the wrapper does not +// yet abstract "set a global to a native function with carried state." +// The closure carries the `ExtensionState` pointer as its single +// upvalue. +void +registerLuaMrDocsApi(lua_State* L, ExtensionState& state) +{ + lua_newtable(L); + + lua_pushlightuserdata(L, &state); + lua_pushcclosure(L, &luaSet, 1); + lua_setfield(L, -2, "set"); + + lua_setglobal(L, "mrdocs"); +} + +} // (anon) + +Expected +runOneLuaExtension(CorpusImpl& corpus, std::string const& scriptPath) +{ + lua::Context ctx; + ExtensionState state{ &corpus, {} }; + + // Build the corpus DOM and the `id` -> `Symbol*` map in one pass. + DomCorpus domCorpus(corpus); + dom::Value corpusValue = buildCorpusDom(corpus, domCorpus, state); + + // Register the `mrdocs` global before loading the script so utility + // code at chunk top-level can reference it if it wants to. + registerLuaMrDocsApi( + static_cast(ctx.nativeState()), state); + + // Load the chunk and execute it (defines globals, including + // `transform_corpus` if the script uses the `function name(...)` + // shape rather than a returned function). + lua::Scope scope(ctx); + MRDOCS_TRY(std::string script, files::getFileText(scriptPath)); + MRDOCS_TRY(lua::Function chunk, scope.loadChunk(script, scriptPath)); + + Expected chunkResult = chunk.call(); + if (!chunkResult) + { + return Unexpected(chunkResult.error()); + } + + // Resolve `transform_corpus`. Prefer the chunk's return value (the + // `return function(...) ... end` idiom); fall back to a same-named + // global (the `function name(...)` idiom). If neither yields a + // function, the extension has nothing to do - silently skip (an + // empty extension is valid). + // + // We can't pre-declare a `lua::Value` and assign into it because + // `lua::Value`'s user-defined move ctor implicitly deletes copy + // assignment, so we run the call inline in each branch instead. + auto callTransform = + [&](lua::Function&& fn) -> Expected + { + Expected result = fn.call(corpusValue); + if (!result) + { + return Unexpected(formatError( + "extension '{}': {}", + scriptPath, result.error().message())); + } + return {}; + }; + + if (chunkResult->isFunction()) + { + return callTransform(lua::Function(std::move(*chunkResult))); + } + + Expected global = scope.getGlobal("transform_corpus"); + if (!global || !global->isFunction()) + { + return {}; + } + return callTransform(lua::Function(std::move(*global))); +} + +} // mrdocs diff --git a/src/lib/Extensions/LuaBinding.hpp b/src/lib/Extensions/LuaBinding.hpp new file mode 100644 index 0000000000..2059dae6fa --- /dev/null +++ b/src/lib/Extensions/LuaBinding.hpp @@ -0,0 +1,34 @@ +// +// 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_EXTENSIONS_LUABINDING_HPP +#define MRDOCS_LIB_EXTENSIONS_LUABINDING_HPP + +#include +#include +#include + +namespace mrdocs { + +class CorpusImpl; + +/** Run one Lua extension script against the corpus. + + Builds a fresh Lua context, exposes the `mrdocs` global, evaluates + the script, and invokes `transform_corpus(corpus)` if defined. + A script that defines no such function is silently skipped, so an + empty `.lua` file is valid. +*/ +Expected +runOneLuaExtension(CorpusImpl& corpus, std::string const& scriptPath); + +} // mrdocs + +#endif diff --git a/src/lib/Extensions/RunExtensions.cpp b/src/lib/Extensions/RunExtensions.cpp new file mode 100644 index 0000000000..7b0948d4c3 --- /dev/null +++ b/src/lib/Extensions/RunExtensions.cpp @@ -0,0 +1,58 @@ +// +// 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 "RunExtensions.hpp" + +#include "AddonDiscovery.hpp" +#include "JsBinding.hpp" +#include "LuaBinding.hpp" + +#include + +#include + +#include +#include + +namespace mrdocs { +namespace { + +Expected +runOneExtension(CorpusImpl& corpus, std::string const& scriptPath) +{ + if (scriptPath.ends_with(".lua")) + { + return runOneLuaExtension(corpus, scriptPath); + } + if (scriptPath.ends_with(".js")) + { + return runOneJsExtension(corpus, scriptPath); + } + // collectExtensionScripts only emits .lua / .js paths, so reaching + // here would mean an internal mismatch. + return Unexpected(formatError( + "extension '{}': unsupported file extension", scriptPath)); +} + +} // (anon) + +Expected +runExtensions(CorpusImpl& corpus) +{ + MRDOCS_TRY(std::vector scripts, + collectExtensionScripts(corpus.config)); + for (std::string const& path : scripts) + { + MRDOCS_TRY(runOneExtension(corpus, path)); + } + return {}; +} + +} // mrdocs diff --git a/src/lib/Extensions/RunExtensions.hpp b/src/lib/Extensions/RunExtensions.hpp new file mode 100644 index 0000000000..470b3f21c9 --- /dev/null +++ b/src/lib/Extensions/RunExtensions.hpp @@ -0,0 +1,47 @@ +// +// 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_EXTENSIONS_RUNEXTENSIONS_HPP +#define MRDOCS_LIB_EXTENSIONS_RUNEXTENSIONS_HPP + +#include +#include + +namespace mrdocs { + +class CorpusImpl; + +/** Run user-provided extension scripts against the corpus. + + Extensions live in /extensions/.{lua,js} for each + addon root declared in the configuration (primary `addons` plus + `addons-supplemental`). Each script may export a function named + `transform_corpus(corpus)`; the function is invoked once with a flat + DOM view of the corpus that the script can read, and may mutate the + corpus by calling functions on the pre-registered `mrdocs` global + table or object: + + - `mrdocs.set(symbol_id, field, value)` - assign a new value to + one of the allowlisted fields of a symbol. The setter validates + its arguments and raises an error on misuse. + + Any uncaught error inside a script aborts the build. Scripts are run + in alphabetical order by file path, with the two languages + interleaved so behavior doesn't depend on which language a user + chose. Extensions intentionally fire after all finalizers and + before any generator runs, so mutations are visible to every + output format. +*/ +Expected +runExtensions(CorpusImpl& corpus); + +} // mrdocs + +#endif diff --git a/src/lib/Extensions/SetMember.cpp b/src/lib/Extensions/SetMember.cpp new file mode 100644 index 0000000000..93b09f0936 --- /dev/null +++ b/src/lib/Extensions/SetMember.cpp @@ -0,0 +1,727 @@ +// +// 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 "SetMember.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs { + +dom::Value +buildCorpusDom( + CorpusImpl& corpus, + DomCorpus const& domCorpus, + ExtensionState& state) +{ + dom::Array symbols; + for (Symbol const& sym : corpus) + { + std::string idStr = toBase16Str(sym.id); + state.byId[idStr] = corpus.find(sym.id); + dom::Value symValue = domCorpus.get(sym.id); + // Add a flat base16 id alongside the reflection-driven `id` + // (which is the recursive Symbol object so templates and + // scripts agree on its shape and can navigate `symbol.id.name` + // identically). Scripts pass `_id` back to `mrdocs.set` to + // identify the symbol to act on; the recursive `id` stays + // available for navigation. + symValue.getObject().set("_id", idStr); + symbols.emplace_back(std::move(symValue)); + } + dom::Object corpusObj; + corpusObj.set("symbols", std::move(symbols)); + return dom::Value(std::move(corpusObj)); +} + +namespace { + +// ===================================================================== +// Allowlist +// ===================================================================== +// +// `mrdocs.set(symbol_id, field, value)` dispatches through reflection, +// but the user-facing surface is a curated allowlist: only the fields +// listed in `kSettableFields` can be written, regardless of what +// described members the underlying symbol type happens to expose. The +// allowlist starts strict and grows as concrete needs surface; this +// keeps scripts from quietly breaking corpus invariants (changing a +// symbol's `kind`, re-parenting it, mutating structural collections, +// ...). +// +// `kSettableFields` is generated from +// `src/lib/Extensions/AllowedFields.json` at build time; the same JSON +// drives the AsciiDoc reference table in `extensions.adoc`, so the +// runtime allowlist and the rendered docs cannot drift. Add a new +// entry by editing the JSON. +// +// `access` is intentionally absent for now: `AccessKind` is not yet +// registered with `MRDOCS_DESCRIBE_ENUM` on this branch, so the +// reflection-driven setter has no way to convert a kebab-case +// enumerator string into the right enum value, and writing to +// `access` would hit the generic "cannot yet write" error path. It +// can be added back to the JSON once `AccessKind` is described (the +// change landing on `feat/schema_generation`). + +bool +isSettableField(std::string_view fieldName) +{ + return std::find( + std::begin(kSettableFields), + std::end(kSettableFields), + fieldName) != std::end(kSettableFields); +} + +// Compute the script-facing kebab name for `Derived`'s discriminator +// exactly once per process. Used by `isKindKebabName` to avoid +// default-constructing the derived class on every polymorphic write. +// +// The discriminator is read from the inherited `Kind` field of a +// default-constructed instance, the one convention shared by every +// polymorphic base (`Symbol::Kind`, `Inline::Kind`, `Name::Kind`, ...). +// A static `kind_id` wouldn't work for hierarchies such as `Name`, +// where the derived classes set the base's `Kind` from their +// constructor and don't expose a compile-time discriminator. +// +// Two address schemes coexist: +// +// - Most hierarchies expose their discriminator through +// `MRDOCS_DESCRIBE_ENUM`, and the script-facing name is the +// kebab-case of the enumerator (e.g., `namespace-alias`). This is +// also what the DOM exposes, so script names and DOM names agree. +// +// - `TypeKind` is left undescribed (adding the description would +// force a redundant `...` into every type element of +// every XML golden). Its variants are instead reached via the +// `toString(TypeKind)` overload that DOM serialization already +// uses, so script names (`lvalue-reference`, ...) match the DOM +// and Handlebars side and differ only from the XML writer's tag +// form (`l-value-reference`, ...). +// +// Non-default-constructible derived classes remain unaddressable; +// the `setMember` allowlist controls which fields scripts can reach +// today, so this has never mattered in practice. The cache returns +// the empty string for those, which compares unequal to every +// non-empty input. +template +std::string +computeKindKebabName() +{ + if constexpr (!std::is_default_constructible_v) + { + return {}; + } + else + { + Derived instance; + using KindType = std::decay_t; + if constexpr (describe::has_describe_enumerators::value) + { + std::string result; + describe::for_each( + describe::describe_enumerators{}, + [&](auto enumDesc) + { + if (!result.empty()) + { + return; + } + if (enumDesc.value == instance.Kind) + { + result = toKebabCase(enumDesc.name); + } + }); + return result; + } + else if constexpr (requires { toString(instance.Kind); }) + { + // Fallback for hierarchies (like `TypeKind`) whose + // discriminator deliberately skips `MRDOCS_DESCRIBE_ENUM`. + return std::string(toString(instance.Kind)); + } + else + { + return {}; + } + } +} + +// Test whether the script-facing name of `Derived`'s discriminator +// matches `name`. Uses a per-`Derived` cached string so the cost is +// one comparison per polymorphic write per registered kind, not one +// constructor call plus an enumerator scan. +template +bool +isKindKebabName(std::string_view name) +{ + static std::string const cached = computeKindKebabName(); + return !cached.empty() && cached == name; +} + +// ===================================================================== +// assignFromDom overload set +// ===================================================================== +// +// `assignFromDom` decodes a DOM value into one allowlisted member. It +// is an overload set: each shape of member (string, enum, +// `Optional`, ...) is a separate overload constrained by a +// `requires` clause, so dispatch happens through overload resolution +// rather than a central `if constexpr` chain. The primary template +// below catches any T not handled by a constrained overload and reports +// a clean error. +// +// All overloads are forward-declared first so the recursive shapes +// (`Optional`, `vector`) find every other overload at template- +// instantiation time, regardless of definition order. + +template +Expected +assignFromDom(T& dest, std::string_view fieldName, dom::Value const& src); + +Expected +assignFromDom( + std::string& dest, std::string_view fieldName, dom::Value const& src); + +Expected +assignFromDom( + bool& dest, std::string_view fieldName, dom::Value const& src); + +template + requires (std::is_enum_v && describe::has_describe_enumerators::value) +Expected +assignFromDom(T& dest, std::string_view fieldName, dom::Value const& src); + +template + requires detail::is_optional_v +Expected +assignFromDom(T& dest, std::string_view fieldName, dom::Value const& src); + +template + requires detail::is_vector_v +Expected +assignFromDom(T& dest, std::string_view fieldName, dom::Value const& src); + +template + requires detail::is_polymorphic_v +Expected +assignFromDom(T& dest, std::string_view fieldName, dom::Value const& src); + +template + requires describe::has_describe_members::value +Expected +assignFromDom(T& dest, std::string_view fieldName, dom::Value const& src); + +// Forward-declared too: the described-struct overload above calls +// `trySetMember` from inside a lambda, and Clang's two-phase lookup +// (stricter than MSVC) requires the unqualified name to be visible at +// template-definition time, not just at instantiation. +template +std::optional> +trySetMember(T& obj, std::string_view fieldName, dom::Value const& src); + +// Build a `Polymorphic` from a DOM object whose `kind:` field +// names a derived class registered with `MRDOCS_DESCRIBE_KINDS`, +// then hand the constructed value to `place`. The placement callback +// (instead of an `Expected` return) avoids instantiating +// `Expected>`, which triggers a circular concept +// evaluation under MSVC: `Polymorphic`'s converting constructor +// constrains on `copy_constructible` for `U = Expected>`, +// and `Expected`'s noexcept clause in turn looks at `Polymorphic`'s +// constructors. Routing the value through a callback breaks the cycle +// and serves both the `assignFromDom` overload and the vector branch +// without further plumbing. +// +// No upstream Microsoft Developer Community ticket has been filed for +// this; please link one here if it ever surfaces. +template +Expected +buildPolymorphic( + dom::Value const& src, std::string_view fieldName, Place&& place); + +// Catch-all for any T not matched by a constrained overload above. +template +Expected +assignFromDom(T&, std::string_view fieldName, dom::Value const&) +{ + return Unexpected(formatError( + "mrdocs.set: field '{}' has a type the generic setter cannot yet write", + fieldName)); +} + +Expected +assignFromDom( + std::string& dest, std::string_view fieldName, dom::Value const& src) +{ + if (!src.isString()) + { + return Unexpected(formatError( + "mrdocs.set: field '{}' expects a string", fieldName)); + } + dest = std::string(src.getString()); + return {}; +} + +Expected +assignFromDom( + bool& dest, std::string_view fieldName, dom::Value const& src) +{ + if (!src.isBoolean()) + { + return Unexpected(formatError( + "mrdocs.set: field '{}' expects a boolean", fieldName)); + } + dest = src.getBool(); + return {}; +} + +// Described enums round-trip through kebab-case enumerator names, +// matching the read direction. +template + requires (std::is_enum_v && describe::has_describe_enumerators::value) +Expected +assignFromDom(T& dest, std::string_view fieldName, dom::Value const& src) +{ + if (!src.isString()) + { + return Unexpected(formatError( + "mrdocs.set: field '{}' expects an enumerator name", + fieldName)); + } + std::string_view const candidate = src.getString(); + bool found = false; + describe::for_each( + describe::describe_enumerators{}, + [&](auto const& D) + { + if (found) + { + return; + } + if (toKebabCase(D.name) == candidate) + { + dest = D.value; + found = true; + } + }); + if (!found) + { + return Unexpected(formatError( + "mrdocs.set: field '{}' has no enumerator named '{}'", + fieldName, candidate)); + } + return {}; +} + +// `null` clears the optional; any other value emplaces the inner T and +// recurses. Inner types that aren't default-constructible from this +// translation unit only accept `null`. +template + requires detail::is_optional_v +Expected +assignFromDom(T& dest, std::string_view fieldName, dom::Value const& src) +{ + if (src.isNull()) + { + dest.reset(); + return {}; + } + using Inner = std::remove_reference_t; + if constexpr (std::is_default_constructible_v) + { + if (!dest.has_value()) + { + dest.emplace(); + } + return assignFromDom(*dest, fieldName, src); + } + else + { + return Unexpected(formatError( + "mrdocs.set: field '{}' wraps a type the generic setter cannot construct (only `null` is accepted)", + fieldName)); + } +} + +// Vectors are cleared and rebuilt rather than appended to, so the +// script's array is authoritative. `Polymorphic` elements (which +// aren't default-constructible by design) go through `buildPolymorphic` +// with `push_back` as the placement callback; everything else gets a +// default-constructed element mutated in place by `assignFromDom`. +template + requires detail::is_vector_v +Expected +assignFromDom(T& dest, std::string_view fieldName, dom::Value const& src) +{ + using Element = typename T::value_type; + if (!src.isArray()) + { + return Unexpected(formatError( + "mrdocs.set: field '{}' expects an array", fieldName)); + } + dom::Array const arr = src.getArray(); + std::size_t const n = arr.size(); + T fresh; + fresh.reserve(n); + if constexpr (detail::is_polymorphic_v) + { + for (std::size_t i = 0; i < n; ++i) + { + Expected r = buildPolymorphic( + arr.get(i), fieldName, + [&](Element&& v) { fresh.push_back(std::move(v)); }); + if (!r) + { + return Unexpected(r.error()); + } + } + } + else if constexpr (std::is_default_constructible_v) + { + for (std::size_t i = 0; i < n; ++i) + { + Element elem{}; + Expected r = assignFromDom(elem, fieldName, arr.get(i)); + if (!r) + { + return r; + } + fresh.push_back(std::move(elem)); + } + } + else + { + return Unexpected(formatError( + "mrdocs.set: field '{}' contains a type the generic setter cannot construct", + fieldName)); + } + dest = std::move(fresh); + return {}; +} + +// Polymorphic values are written as a DOM object whose `kind:` field +// picks the concrete derived class; the build logic lives in +// `buildPolymorphic` so the vector overload can reuse it. +template + requires detail::is_polymorphic_v +Expected +assignFromDom(T& dest, std::string_view fieldName, dom::Value const& src) +{ + return buildPolymorphic( + src, fieldName, + [&](T&& v) { dest = std::move(v); }); +} + +// Described structs accept a partial DOM object: each key is looked up +// against the struct's described members (and bases) and assigned +// through `trySetMember`. Unknown keys are an error so typos don't +// silently no-op. +template + requires describe::has_describe_members::value +Expected +assignFromDom(T& dest, std::string_view fieldName, dom::Value const& src) +{ + if (!src.isObject()) + { + return Unexpected(formatError( + "mrdocs.set: field '{}' expects an object", fieldName)); + } + dom::Object const obj = src.getObject(); + Expected outerResult; + obj.visit( + [&](dom::String key, dom::Value value) -> bool + { + std::optional> inner = + trySetMember(dest, key, value); + if (!inner.has_value()) + { + outerResult = Unexpected(formatError( + "mrdocs.set: field '{}': unknown sub-field '{}'", + fieldName, key)); + return false; + } + if (!inner.value()) + { + outerResult = Unexpected(inner.value().error()); + return false; + } + return true; + }); + return outerResult; +} + +// Walk one type's described members; if `fieldName` matches one, try +// to assign and return the result. Bases are walked recursively so a +// derived symbol can set a base-class field by name. `std::nullopt` +// means "no member of that name was found at this level." +template +std::optional> +trySetMember(T& obj, std::string_view fieldName, dom::Value const& src) +{ + std::optional> outcome; + if constexpr (describe::has_describe_members::value) + { + describe::for_each( + describe::describe_members{}, + [&](auto const& descriptor) + { + if (outcome.has_value()) + { + return; + } + using Descriptor = std::decay_t; + std::string const normalized = + detail::normalizeMemberName(Descriptor::name); + if (normalized != fieldName) + { + return; + } + outcome = assignFromDom( + obj.*Descriptor::pointer, fieldName, src); + }); + } + if (outcome.has_value()) + { + return outcome; + } + if constexpr (describe::has_describe_bases::value) + { + describe::for_each( + describe::describe_bases{}, + [&](auto const& descriptor) + { + if (outcome.has_value()) + { + return; + } + using BaseType = + typename std::decay_t::type; + outcome = trySetMember( + static_cast(obj), fieldName, src); + }); + } + return outcome; +} + +// Apply each key in `obj` other than `kind` to a default-constructed +// derived instance, dispatching via `trySetMember`. Used by +// `buildPolymorphic` once the right derived class has been picked. +// The traversal mirrors the described-struct overload of +// `assignFromDom`, but skips the `kind` discriminator and annotates +// the error message with the kind tag. +template +Expected +applyDerivedFields( + Derived& instance, + dom::Object const& obj, + std::string_view fieldName, + std::string_view kindStr) +{ + Expected result; + obj.visit( + [&](dom::String key, dom::Value value) -> bool + { + if (key == "kind") + { + return true; + } + std::optional> outcome = + trySetMember(instance, key, value); + if (!outcome.has_value()) + { + result = Unexpected(formatError( + "mrdocs.set: field '{}': unknown sub-field '{}' for kind '{}'", + fieldName, key, kindStr)); + return false; + } + if (!outcome.value()) + { + result = Unexpected(outcome.value().error()); + return false; + } + return true; + }); + return result; +} + +// Build a `Polymorphic` once the matching derived class has been +// picked, then hand it to `place`. Default-constructible derivatives +// go through `applyDerivedFields`; the rest are unreachable from +// scripts and produce a clean error. +template +Expected +tryBuildDerived( + dom::Object const& obj, + std::string_view fieldName, + std::string_view kindStr, + Place& place) +{ + if constexpr (std::is_default_constructible_v) + { + Derived instance; + Expected r = applyDerivedFields( + instance, obj, fieldName, kindStr); + if (!r) + { + return Unexpected(r.error()); + } + place(Poly(std::in_place_type, std::move(instance))); + return {}; + } + else + { + return Unexpected(formatError( + "mrdocs.set: field '{}': derived kind '{}' is not default-constructible", + fieldName, kindStr)); + } +} + +// Build a `Polymorphic` from a DOM object. See the forward +// declaration above for the rationale (callback instead of +// `Expected` return). +template +Expected +buildPolymorphic( + dom::Value const& src, std::string_view fieldName, Place&& place) +{ + using Base = typename Poly::value_type; + if (!src.isObject()) + { + return Unexpected(formatError( + "mrdocs.set: field '{}' expects an object describing a polymorphic value", + fieldName)); + } + dom::Object const obj = src.getObject(); + dom::Value kindV = obj.get("kind"); + if (!kindV.isString()) + { + return Unexpected(formatError( + "mrdocs.set: field '{}' expects an object with a string `kind` field", + fieldName)); + } + std::string_view const kindStr = kindV.getString(); + + if constexpr (!describe::has_describe_kinds::value) + { + return Unexpected(formatError( + "mrdocs.set: field '{}' is a polymorphic base with no described kinds", + fieldName)); + } + else + { + Expected result = Unexpected(formatError( + "mrdocs.set: field '{}' has no derived class with kind '{}'", + fieldName, kindStr)); + bool matched = false; + describe::for_each( + describe::describe_kinds{}, + [&](auto descriptor) + { + if (matched) + { + return; + } + using Descriptor = std::decay_t; + using Derived = typename Descriptor::type; + if (!isKindKebabName(kindStr)) + { + return; + } + matched = true; + result = tryBuildDerived( + obj, fieldName, kindStr, place); + }); + return result; + } +} + +} // (anon) + +// Language-agnostic generic setter. Refuses anything outside the +// allowlist up front, then dispatches reflection through `trySetMember` +// on the dynamic symbol type. +Expected +setMemberImpl( + ExtensionState& state, + dom::Value const& idArg, + dom::Value const& fieldArg, + dom::Value const& valueArg) +{ + if (!idArg.isString()) + { + return Unexpected(Error( + "mrdocs.set: argument 1 (symbol id) must be a string")); + } + if (!fieldArg.isString()) + { + return Unexpected(Error( + "mrdocs.set: argument 2 (field name) must be a string")); + } + + std::string_view const idView = idArg.getString(); + std::string_view const fieldView = fieldArg.getString(); + + if (!isSettableField(fieldView)) + { + return Unexpected(formatError( + "mrdocs.set: field '{}' is not user-settable from an extension", + fieldView)); + } + + auto it = state.byId.find(std::string(idView)); + if (it == state.byId.end()) + { + return Unexpected(formatError( + "mrdocs.set: unknown symbol id '{}'", idView)); + } + Symbol* sym = it->second; + MRDOCS_ASSERT(sym != nullptr); + + std::optional> outcome; + visit(*sym, [&](DerivedSymbolTy& derived) + { + outcome = trySetMember(derived, fieldView, valueArg); + }); + if (!outcome.has_value()) + { + return Unexpected(formatError( + "mrdocs.set: field '{}' is allowlisted but missing on this symbol", + fieldView)); + } + if (!outcome.value()) + { + return Unexpected(outcome.value().error()); + } + return dom::Value(); +} + +} // mrdocs diff --git a/src/lib/Extensions/SetMember.hpp b/src/lib/Extensions/SetMember.hpp new file mode 100644 index 0000000000..041b5709b1 --- /dev/null +++ b/src/lib/Extensions/SetMember.hpp @@ -0,0 +1,74 @@ +// +// 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_EXTENSIONS_SETMEMBER_HPP +#define MRDOCS_LIB_EXTENSIONS_SETMEMBER_HPP + +#include +#include +#include + +#include +#include + +namespace mrdocs { + +class CorpusImpl; +class DomCorpus; +struct Symbol; + +/** Per-script state threaded through every `mrdocs.*` callback. + + Each binding captures it differently (Lua via a light-userdata + upvalue, JS via a raw pointer captured in a `dom::Function` + lambda). Holds the live corpus and a string -> `Symbol*` index that + avoids re-decoding the `SymbolID` encoding on every setter call. +*/ +struct ExtensionState +{ + CorpusImpl* corpus = nullptr; + std::unordered_map byId; +}; + +/** Expose the canonical DOM of every symbol to scripts. + + Returns a DOM value whose `symbols` field is the array of + per-symbol lazy objects, matching what the Handlebars generators + see. As a side effect, populates `state.byId` so subsequent + `setMemberImpl` calls can route back to the live C++ object by + the same base16 `id` string the DOM exposes. +*/ +dom::Value +buildCorpusDom( + CorpusImpl& corpus, + DomCorpus const& domCorpus, + ExtensionState& state); + +/** Language-agnostic implementation of `mrdocs.set`. + + Validates `(symbol_id, field, value)` against the allowlist and + dispatches reflection through the dynamic symbol type. Returns + the rich `Error` for every failure path: unknown symbol id, + off-allowlist field, type mismatch, unknown enum name, unknown + polymorphic kind, unknown sub-field, missing kind, and so on. + + The success value is `dom::Value()` (nil); callers that want a + meaningful return type can ignore it. +*/ +Expected +setMemberImpl( + ExtensionState& state, + dom::Value const& idArg, + dom::Value const& fieldArg, + dom::Value const& valueArg); + +} // mrdocs + +#endif diff --git a/src/lib/Gen/hbs/AddonPaths.hpp b/src/lib/Gen/hbs/AddonPaths.hpp index cdf5ca42af..c0b2d4e373 100644 --- a/src/lib/Gen/hbs/AddonPaths.hpp +++ b/src/lib/Gen/hbs/AddonPaths.hpp @@ -12,6 +12,7 @@ #define MRDOCS_LIB_GEN_HBS_ADDONPATHS_HPP #include +#include #include #include #include @@ -20,35 +21,6 @@ namespace mrdocs::hbs::addon_paths { -/** Returns the list of addon root directories from the configuration. - - This function collects all valid addon root paths by checking - the primary addons directory and any supplemental addon directories - specified in the configuration. - - @param config The configuration containing addon path settings. - @return A vector of existing addon root directory paths. The primary - addons directory (if it exists) appears first, followed by - any existing supplemental addon directories in their - configured order. -*/ -inline std::vector -addonRoots(Config const& config) -{ - std::vector roots; - roots.reserve(1 + config->addonsSupplemental.size()); - - if (files::exists(config->addons)) - roots.push_back(config->addons); - - for (auto const& supplemental : config->addonsSupplemental) - { - if (files::exists(supplemental)) - roots.push_back(supplemental); - } - return roots; -} - /** Returns directories containing Handlebars partial templates. For each addon root, this function looks for partial templates in: @@ -161,7 +133,7 @@ findFile( std::string_view subdir, std::string_view filename) { - auto roots = addonRoots(config); + auto roots = mrdocs::addonRoots(config); for (auto it = roots.rbegin(); it != roots.rend(); ++it) { std::string candidate = files::appendPath(*it, "generator", generator, subdir, filename); diff --git a/src/lib/Gen/hbs/Builder.cpp b/src/lib/Gen/hbs/Builder.cpp index c21f57eb8d..87d963fd4a 100644 --- a/src/lib/Gen/hbs/Builder.cpp +++ b/src/lib/Gen/hbs/Builder.cpp @@ -454,7 +454,7 @@ Builder( namespace fs = std::filesystem; auto const& config = domCorpus->config; - auto const roots = addon_paths::addonRoots(config); + auto const roots = addonRoots(config); auto const partialDirs = addon_paths::partialDirs(roots, domCorpus.fileExtension); auto const helperDirs = addon_paths::helperDirs(roots, domCorpus.fileExtension); auto const layoutDirs = addon_paths::layoutDirs(roots, domCorpus.fileExtension); diff --git a/src/lib/Support/AddonRoots.hpp b/src/lib/Support/AddonRoots.hpp new file mode 100644 index 0000000000..af9c6104c1 --- /dev/null +++ b/src/lib/Support/AddonRoots.hpp @@ -0,0 +1,53 @@ +// +// 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) 2025 Alan de Freitas (alandefreitas@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +#ifndef MRDOCS_LIB_SUPPORT_ADDONROOTS_HPP +#define MRDOCS_LIB_SUPPORT_ADDONROOTS_HPP + +#include +#include + +#include +#include + +namespace mrdocs { + +/** Return the existing addon roots in load order. + + Primary `addons` first, then each entry of `addons-supplemental` + in the order it appears. Missing paths are skipped silently. + + Used by both the Handlebars generator (to locate templates, + partials, and helpers) and the extension stack (to locate + corpus-mutation scripts). +*/ +inline std::vector +addonRoots(Config const& config) +{ + std::vector roots; + roots.reserve(1 + config->addonsSupplemental.size()); + if (files::exists(config->addons)) + { + roots.push_back(config->addons); + } + for (std::string const& supplemental : config->addonsSupplemental) + { + if (files::exists(supplemental)) + { + roots.push_back(supplemental); + } + } + return roots; +} + +} // mrdocs + +#endif diff --git a/src/lib/Support/Lua.cpp b/src/lib/Support/Lua.cpp index 5d065ada98..985f229464 100644 --- a/src/lib/Support/Lua.cpp +++ b/src/lib/Support/Lua.cpp @@ -97,6 +97,13 @@ Context:: Context( Context const& other) noexcept = default; +void* +Context:: +nativeState() const noexcept +{ + return impl_->L; +} + void Scope:: reset() @@ -738,6 +745,15 @@ getGlobal( return A.construct(-1, *this); } +Value +Scope:: +pushDom(dom::Value const& value) +{ + Access A(*this); + domValue_push(A, value); + return A.construct(-1, *this); +} + //------------------------------------------------ // // Param diff --git a/src/test/Extensions/SetMember.cpp b/src/test/Extensions/SetMember.cpp new file mode 100644 index 0000000000..5626a5b693 --- /dev/null +++ b/src/test/Extensions/SetMember.cpp @@ -0,0 +1,328 @@ +// +// 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 +#include +#include + +namespace mrdocs { +namespace { + +// ------------------------------------------------------------------ +// Fixture +// ------------------------------------------------------------------ +// +// `setMemberImpl` only dereferences `state.byId`; it never touches +// `state.corpus`. The tests therefore leave `corpus` as nullptr and +// populate `byId` with hand-built symbols. That keeps the harness +// independent of `CorpusImpl`'s construction machinery (compilation +// database, AST visitor, finalizers, ...) and lets us exercise just +// the error reporting paths of the setter. + +bool +contains(std::string const& haystack, std::string_view needle) +{ + return haystack.find(needle) != std::string::npos; +} + +// All test cases run against the same per-test fixture: one function +// (returnType is reachable) and one namespace (returnType is missing). +struct Fixture +{ + FunctionSymbol fn{SymbolID("12345678901234567890")}; + NamespaceSymbol ns{SymbolID("abcdefghijklmnopqrst")}; + ExtensionState state{nullptr, {}}; + + Fixture() + { + state.byId["fn"] = &fn; + state.byId["ns"] = &ns; + } +}; + +// Helper: call `setMemberImpl` and assert that it failed with an +// error message containing `needle`. +void +expectError( + ExtensionState& state, + dom::Value const& idArg, + dom::Value const& fieldArg, + dom::Value const& valueArg, + std::string_view needle) +{ + Expected r = + setMemberImpl(state, idArg, fieldArg, valueArg); + BOOST_TEST(!r); + if (!r) + { + bool ok = contains(r.error().message(), needle); + if (!ok) + { + std::fprintf(stderr, + "SetMemberTest: needle '%.*s' not in: '%s'\n", + static_cast(needle.size()), needle.data(), + r.error().message().c_str()); + } + BOOST_TEST(ok); + } +} + +// ------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------ + +struct SetMemberTest +{ + // --- Argument-shape errors --- + + void test_id_not_string() + { + Fixture f; + expectError(f.state, + dom::Value(42), + dom::Value("name"), + dom::Value("hi"), + "argument 1"); + } + + void test_field_not_string() + { + Fixture f; + expectError(f.state, + dom::Value("fn"), + dom::Value(42), + dom::Value("hi"), + "argument 2"); + } + + // --- Allowlist + lookup errors --- + + void test_unknown_id() + { + Fixture f; + expectError(f.state, + dom::Value("nope"), + dom::Value("name"), + dom::Value("hi"), + "unknown symbol id"); + } + + void test_off_allowlist_field() + { + Fixture f; + expectError(f.state, + dom::Value("fn"), + dom::Value("Bases"), + dom::Value("hi"), + "not user-settable"); + } + + void test_allowlisted_but_missing_on_kind() + { + // `returnType` is in the allowlist but does not exist on + // `NamespaceSymbol`. + Fixture f; + dom::Object retObj; + retObj.set("kind", "named"); + expectError(f.state, + dom::Value("ns"), + dom::Value("returnType"), + dom::Value(std::move(retObj)), + "missing on this symbol"); + } + + // --- Type-mismatch errors --- + + void test_string_field_wants_string() + { + // `name` is a string field; passing a boolean must fail. + Fixture f; + expectError(f.state, + dom::Value("fn"), + dom::Value("name"), + dom::Value(true), + "expects a string"); + } + + void test_bool_field_wants_bool() + { + // `isCopyFromInherited` is a bool field; passing a string + // must fail. + Fixture f; + expectError(f.state, + dom::Value("fn"), + dom::Value("isCopyFromInherited"), + dom::Value("yes"), + "expects a boolean"); + } + + void test_enum_wants_enumerator_name() + { + // `extraction` is an enum field; passing a boolean must fail + // before any enumerator lookup. + Fixture f; + expectError(f.state, + dom::Value("fn"), + dom::Value("extraction"), + dom::Value(true), + "expects an enumerator name"); + } + + void test_unknown_enum_name() + { + // `extraction` is an enum field; "neon" is not one of its + // enumerators. + Fixture f; + expectError(f.state, + dom::Value("fn"), + dom::Value("extraction"), + dom::Value("neon"), + "no enumerator named"); + } + + // --- Polymorphic-write errors --- + + void test_polymorphic_not_object() + { + // `returnType` is `Polymorphic`; passing a bare + // string must fail with "expects an object". + Fixture f; + expectError(f.state, + dom::Value("fn"), + dom::Value("returnType"), + dom::Value("named"), + "polymorphic value"); + } + + void test_polymorphic_missing_kind() + { + // `returnType` requires a `kind` field selecting the + // concrete derived class. + Fixture f; + dom::Object retObj; + expectError(f.state, + dom::Value("fn"), + dom::Value("returnType"), + dom::Value(std::move(retObj)), + "kind"); + } + + void test_polymorphic_unknown_kind() + { + // `returnType` with a kind that is not registered for + // `TypeBase`. + Fixture f; + dom::Object retObj; + retObj.set("kind", "nonsense-kind"); + expectError(f.state, + dom::Value("fn"), + dom::Value("returnType"), + dom::Value(std::move(retObj)), + "no derived class with kind"); + } + + void test_polymorphic_unknown_subfield() + { + // `returnType` with a valid kind but an unknown sub-field. + Fixture f; + dom::Object retObj; + retObj.set("kind", "named"); + retObj.set("definitely-not-a-real-field", "x"); + expectError(f.state, + dom::Value("fn"), + dom::Value("returnType"), + dom::Value(std::move(retObj)), + "unknown sub-field"); + } + + // --- Struct-write errors --- + + void test_struct_unknown_subfield() + { + // `doc` is `Optional`; passing an object with + // an unknown key must fail. + Fixture f; + dom::Object docObj; + docObj.set("definitely-not-a-real-field", "x"); + expectError(f.state, + dom::Value("fn"), + dom::Value("doc"), + dom::Value(std::move(docObj)), + "unknown sub-field"); + } + + // --- Optional null reset (happy path, but adjacent to errors) --- + + void test_optional_null_clears() + { + // `doc = null` must succeed (it clears the optional). + Fixture f; + Expected r = setMemberImpl( + f.state, + dom::Value("fn"), + dom::Value("doc"), + dom::Value(nullptr)); + if (!r) + { + std::fprintf(stderr, + "SetMemberTest::test_optional_null_clears: %s\n", + r.error().message().c_str()); + } + BOOST_TEST(r); + } + + void run() + { + // Argument shape + test_id_not_string(); + test_field_not_string(); + + // Allowlist + lookup + test_unknown_id(); + test_off_allowlist_field(); + test_allowlisted_but_missing_on_kind(); + + // Type mismatch + test_string_field_wants_string(); + test_bool_field_wants_bool(); + test_enum_wants_enumerator_name(); + test_unknown_enum_name(); + + // Polymorphic + test_polymorphic_not_object(); + test_polymorphic_missing_kind(); + test_polymorphic_unknown_kind(); + test_polymorphic_unknown_subfield(); + + // Struct + test_struct_unknown_subfield(); + + // Optional null (happy) + test_optional_null_clears(); + } +}; + +} // (anon) + +TEST_SUITE( + SetMemberTest, + "clang.mrdocs.Extensions.SetMember"); + +} // mrdocs diff --git a/test-files/golden-tests/extensions/js-set-name/addons/extensions/rename.js b/test-files/golden-tests/extensions/js-set-name/addons/extensions/rename.js new file mode 100644 index 0000000000..c0ce7a9fb5 --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-name/addons/extensions/rename.js @@ -0,0 +1,28 @@ +// Mutate every function from a JavaScript extension. Exercises two +// shapes of the generic setter: a scalar string assignment (`name`) +// and a polymorphic-aware nested object whose leaves are +// `Polymorphic` values selected by a kebab-case `kind` tag. +// +// Mirrors the lua-set-name fixture but exercises the JS path: the +// `mrdocs` global is exposed as a JavaScript object whose `set` +// entry is a native function backed by a dom::Function in C++. + +function transform_corpus(corpus) +{ + for (var i = 0; i < corpus.symbols.length; ++i) + { + var sym = corpus.symbols[i]; + if (sym.kind === "function") + { + mrdocs.set(sym._id, "name", "renamed_" + sym.name); + mrdocs.set(sym._id, "doc", { + brief: { + children: [ + { kind: "text", + literal: "Brief rewritten by JS extension" } + ] + } + }); + } + } +} diff --git a/test-files/golden-tests/extensions/js-set-name/mrdocs.yml b/test-files/golden-tests/extensions/js-set-name/mrdocs.yml new file mode 100644 index 0000000000..cde6450530 --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-name/mrdocs.yml @@ -0,0 +1,6 @@ +addons-supplemental: + - addons +generator: xml +multipage: false +warn-if-undocumented: false +source-root: . diff --git a/test-files/golden-tests/extensions/js-set-name/set_name.adoc b/test-files/golden-tests/extensions/js-set-name/set_name.adoc new file mode 100644 index 0000000000..94eb403241 --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-name/set_name.adoc @@ -0,0 +1,32 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#renamed_target_function[`renamed_target_function`] +| Brief rewritten by JS extension +|=== + +[#renamed_target_function] +== renamed_target_function + +Brief rewritten by JS extension + +=== Synopsis + +Declared in `<set_name.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +void +renamed_target_function(); +---- + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/extensions/js-set-name/set_name.cpp b/test-files/golden-tests/extensions/js-set-name/set_name.cpp new file mode 100644 index 0000000000..0f3c96280d --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-name/set_name.cpp @@ -0,0 +1,2 @@ +/// A function whose name is rewritten by the JS extension. +void target_function(); diff --git a/test-files/golden-tests/extensions/js-set-name/set_name.html b/test-files/golden-tests/extensions/js-set-name/set_name.html new file mode 100644 index 0000000000..1787fe9643 --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-name/set_name.html @@ -0,0 +1,52 @@ + + +Reference + + + +
+

Reference

+
+
+

+Global Namespace

+
+

+Functions

+ + + + + + + + + + +
NameDescription
renamed_target_function Brief rewritten by JS extension
+ +
+
+
+

+renamed_target_function

+
+

Brief rewritten by JS extension

+
+
+
+

+Synopsis

+
+Declared in <set_name.cpp>
+
void
+renamed_target_function();
+
+
+ +
+ + + \ No newline at end of file diff --git a/test-files/golden-tests/extensions/js-set-name/set_name.xml b/test-files/golden-tests/extensions/js-set-name/set_name.xml new file mode 100644 index 0000000000..184cf92f63 --- /dev/null +++ b/test-files/golden-tests/extensions/js-set-name/set_name.xml @@ -0,0 +1,46 @@ + + + + + + namespace + //////////////////////////8= + regular + + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + + + + renamed_target_function + + + set_name.cpp + set_name.cpp + 2 + 1 + 1 + + + function + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + regular + //////////////////////////8= + + + brief + + text + Brief rewritten by JS extension + + + + + + identifier + void + + + normal + + diff --git a/test-files/golden-tests/extensions/lua-clear-doc/addons/extensions/clear.lua b/test-files/golden-tests/extensions/lua-clear-doc/addons/extensions/clear.lua new file mode 100644 index 0000000000..79fefeef8b --- /dev/null +++ b/test-files/golden-tests/extensions/lua-clear-doc/addons/extensions/clear.lua @@ -0,0 +1,12 @@ +-- Exercise the documented "Pass `null` to clear an optional field" +-- behavior: setting `doc` to `nil` from Lua must clear the symbol's +-- doc-comment so the rendered output contains no doc-comment block +-- for it. + +function transform_corpus(corpus) + for _, sym in ipairs(corpus.symbols) do + if sym.kind == "function" then + mrdocs.set(sym._id, "doc", nil) + end + end +end diff --git a/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.adoc b/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.adoc new file mode 100644 index 0000000000..b454f8454a --- /dev/null +++ b/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.adoc @@ -0,0 +1,29 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols=1] +|=== +| Name +| link:#target_function[`target_function`] +|=== + +[#target_function] +== target_function + +=== Synopsis + +Declared in `<clear_doc.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +void +target_function(); +---- + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.cpp b/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.cpp new file mode 100644 index 0000000000..236d6322fa --- /dev/null +++ b/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.cpp @@ -0,0 +1,4 @@ +/// A function whose documentation comment is cleared by the +/// extension. The rendered output must not contain a doc-comment +/// block for this function. +void target_function(); diff --git a/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.html b/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.html new file mode 100644 index 0000000000..0beb94e12e --- /dev/null +++ b/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.html @@ -0,0 +1,49 @@ + + +Reference + + + +
+

Reference

+
+
+

+Global Namespace

+
+

+Functions

+ + + + + + + + + + +
Name
target_function
+ +
+
+
+

+target_function

+
+
+

+Synopsis

+
+Declared in <clear_doc.cpp>
+
void
+target_function();
+
+
+ +
+ + + \ No newline at end of file diff --git a/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.xml b/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.xml new file mode 100644 index 0000000000..dbffcf2f59 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-clear-doc/clear_doc.xml @@ -0,0 +1,37 @@ + + + + + + namespace + //////////////////////////8= + regular + + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + + + + target_function + + + clear_doc.cpp + clear_doc.cpp + 4 + 1 + 1 + + + function + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + regular + //////////////////////////8= + + + identifier + void + + + normal + + diff --git a/test-files/golden-tests/extensions/lua-clear-doc/mrdocs.yml b/test-files/golden-tests/extensions/lua-clear-doc/mrdocs.yml new file mode 100644 index 0000000000..cde6450530 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-clear-doc/mrdocs.yml @@ -0,0 +1,6 @@ +addons-supplemental: + - addons +generator: xml +multipage: false +warn-if-undocumented: false +source-root: . diff --git a/test-files/golden-tests/extensions/lua-empty-script/addons/extensions/empty.lua b/test-files/golden-tests/extensions/lua-empty-script/addons/extensions/empty.lua new file mode 100644 index 0000000000..7509709033 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-empty-script/addons/extensions/empty.lua @@ -0,0 +1,3 @@ +-- Intentionally empty: no `transform_corpus` defined. The docs +-- promise that MrDocs silently skips such scripts so a file can be +-- empty during development without breaking the build. diff --git a/test-files/golden-tests/extensions/lua-empty-script/addons/extensions/non_transform.lua b/test-files/golden-tests/extensions/lua-empty-script/addons/extensions/non_transform.lua new file mode 100644 index 0000000000..9eb4744ce1 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-empty-script/addons/extensions/non_transform.lua @@ -0,0 +1,5 @@ +-- A file that defines globals but no `transform_corpus`. The docs +-- say MrDocs silently skips such scripts: the global below should +-- have no effect on the rendered output. + +unrelated_helper = function(x) return x + 1 end diff --git a/test-files/golden-tests/extensions/lua-empty-script/empty_script.adoc b/test-files/golden-tests/extensions/lua-empty-script/empty_script.adoc new file mode 100644 index 0000000000..f30994ae9c --- /dev/null +++ b/test-files/golden-tests/extensions/lua-empty-script/empty_script.adoc @@ -0,0 +1,32 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#unchanged_function[`unchanged_function`] +| A function untouched by any extension ‐ the test verifies that loading an extension file that does NOT define `transform_corpus` completes the build cleanly and leaves the corpus alone. +|=== + +[#unchanged_function] +== unchanged_function + +A function untouched by any extension ‐ the test verifies that loading an extension file that does NOT define `transform_corpus` completes the build cleanly and leaves the corpus alone. + +=== Synopsis + +Declared in `<empty_script.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +void +unchanged_function(); +---- + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/extensions/lua-empty-script/empty_script.cpp b/test-files/golden-tests/extensions/lua-empty-script/empty_script.cpp new file mode 100644 index 0000000000..61458e30de --- /dev/null +++ b/test-files/golden-tests/extensions/lua-empty-script/empty_script.cpp @@ -0,0 +1,4 @@ +/// A function untouched by any extension - the test verifies that +/// loading an extension file that does NOT define `transform_corpus` +/// completes the build cleanly and leaves the corpus alone. +void unchanged_function(); diff --git a/test-files/golden-tests/extensions/lua-empty-script/empty_script.html b/test-files/golden-tests/extensions/lua-empty-script/empty_script.html new file mode 100644 index 0000000000..9b478dd3bd --- /dev/null +++ b/test-files/golden-tests/extensions/lua-empty-script/empty_script.html @@ -0,0 +1,52 @@ + + +Reference + + + +
+

Reference

+
+
+

+Global Namespace

+
+

+Functions

+ + + + + + + + + + +
NameDescription
unchanged_function A function untouched by any extension - the test verifies that loading an extension file that does NOT define transform_corpus completes the build cleanly and leaves the corpus alone.
+ +
+
+
+

+unchanged_function

+
+

A function untouched by any extension - the test verifies that loading an extension file that does NOT define transform_corpus completes the build cleanly and leaves the corpus alone.

+
+
+
+

+Synopsis

+
+Declared in <empty_script.cpp>
+
void
+unchanged_function();
+
+
+ +
+ + + \ No newline at end of file diff --git a/test-files/golden-tests/extensions/lua-empty-script/empty_script.xml b/test-files/golden-tests/extensions/lua-empty-script/empty_script.xml new file mode 100644 index 0000000000..2dff972b68 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-empty-script/empty_script.xml @@ -0,0 +1,57 @@ + + + + + + namespace + //////////////////////////8= + regular + + h47phbZIVEFdp8eGsRoxrdB6IFM= + + + + unchanged_function + + + empty_script.cpp + empty_script.cpp + 4 + 1 + 1 + + + function + h47phbZIVEFdp8eGsRoxrdB6IFM= + regular + //////////////////////////8= + + + brief + + text + A function untouched by any extension - the test verifies that loading an extension file that does NOT define + + + code + + text + transform_corpus + + + + text + completes the build cleanly and leaves the corpus alone. + + + + + + identifier + void + + + normal + + diff --git a/test-files/golden-tests/extensions/lua-empty-script/mrdocs.yml b/test-files/golden-tests/extensions/lua-empty-script/mrdocs.yml new file mode 100644 index 0000000000..cde6450530 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-empty-script/mrdocs.yml @@ -0,0 +1,6 @@ +addons-supplemental: + - addons +generator: xml +multipage: false +warn-if-undocumented: false +source-root: . diff --git a/test-files/golden-tests/extensions/lua-extension-ordering/addons/primary/extensions/zzz-primary.lua b/test-files/golden-tests/extensions/lua-extension-ordering/addons/primary/extensions/zzz-primary.lua new file mode 100644 index 0000000000..ef130c1b91 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-extension-ordering/addons/primary/extensions/zzz-primary.lua @@ -0,0 +1,14 @@ +-- Even though this file's basename ("zzz-primary.lua") sorts AFTER +-- the supplemental root's ("aaa-supplemental.lua"), the docs promise +-- that scripts run in alphabetical order by FULL PATH. The primary +-- root sorts before the supplemental root ("primary" < "supplemental"), +-- so this script runs FIRST; its rename is overwritten by the +-- supplemental's. + +function transform_corpus(corpus) + for _, sym in ipairs(corpus.symbols) do + if sym.kind == "function" then + mrdocs.set(sym._id, "name", "from_primary") + end + end +end diff --git a/test-files/golden-tests/extensions/lua-extension-ordering/addons/supplemental/extensions/aaa-supplemental.lua b/test-files/golden-tests/extensions/lua-extension-ordering/addons/supplemental/extensions/aaa-supplemental.lua new file mode 100644 index 0000000000..902202fc35 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-extension-ordering/addons/supplemental/extensions/aaa-supplemental.lua @@ -0,0 +1,12 @@ +-- Second-running script (lives in the supplemental root, which +-- sorts after the primary root in full-path order). Its rename +-- overwrites the primary root's, so this is the name that must +-- appear in the rendered output. + +function transform_corpus(corpus) + for _, sym in ipairs(corpus.symbols) do + if sym.kind == "function" then + mrdocs.set(sym._id, "name", "from_supplemental") + end + end +end diff --git a/test-files/golden-tests/extensions/lua-extension-ordering/mrdocs.yml b/test-files/golden-tests/extensions/lua-extension-ordering/mrdocs.yml new file mode 100644 index 0000000000..ab37212425 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-extension-ordering/mrdocs.yml @@ -0,0 +1,7 @@ +addons-supplemental: + - addons/primary + - addons/supplemental +generator: xml +multipage: false +warn-if-undocumented: false +source-root: . diff --git a/test-files/golden-tests/extensions/lua-extension-ordering/ordering.adoc b/test-files/golden-tests/extensions/lua-extension-ordering/ordering.adoc new file mode 100644 index 0000000000..260b4eee4b --- /dev/null +++ b/test-files/golden-tests/extensions/lua-extension-ordering/ordering.adoc @@ -0,0 +1,32 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#from_supplemental[`from_supplemental`] +| A function whose name is rewritten twice, once by each of two scripts living in two different addon roots. The script in the alphabetically‐later root must win. +|=== + +[#from_supplemental] +== from_supplemental + +A function whose name is rewritten twice, once by each of two scripts living in two different addon roots. The script in the alphabetically‐later root must win. + +=== Synopsis + +Declared in `<ordering.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +void +from_supplemental(); +---- + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/extensions/lua-extension-ordering/ordering.cpp b/test-files/golden-tests/extensions/lua-extension-ordering/ordering.cpp new file mode 100644 index 0000000000..f2cd36a3da --- /dev/null +++ b/test-files/golden-tests/extensions/lua-extension-ordering/ordering.cpp @@ -0,0 +1,4 @@ +/// A function whose name is rewritten twice, once by each of two +/// scripts living in two different addon roots. The script in the +/// alphabetically-later root must win. +void target_function(); diff --git a/test-files/golden-tests/extensions/lua-extension-ordering/ordering.html b/test-files/golden-tests/extensions/lua-extension-ordering/ordering.html new file mode 100644 index 0000000000..1938d10724 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-extension-ordering/ordering.html @@ -0,0 +1,52 @@ + + +Reference + + + +
+

Reference

+
+
+

+Global Namespace

+
+

+Functions

+ + + + + + + + + + +
NameDescription
from_supplemental A function whose name is rewritten twice, once by each of two scripts living in two different addon roots. The script in the alphabetically-later root must win.
+ +
+
+
+

+from_supplemental

+
+

A function whose name is rewritten twice, once by each of two scripts living in two different addon roots. The script in the alphabetically-later root must win.

+
+
+
+

+Synopsis

+
+Declared in <ordering.cpp>
+
void
+from_supplemental();
+
+
+ +
+ + + \ No newline at end of file diff --git a/test-files/golden-tests/extensions/lua-extension-ordering/ordering.xml b/test-files/golden-tests/extensions/lua-extension-ordering/ordering.xml new file mode 100644 index 0000000000..501663eca2 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-extension-ordering/ordering.xml @@ -0,0 +1,46 @@ + + + + + + namespace + //////////////////////////8= + regular + + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + + + + from_supplemental + + + ordering.cpp + ordering.cpp + 4 + 1 + 1 + + + function + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + regular + //////////////////////////8= + + + brief + + text + A function whose name is rewritten twice, once by each of two scripts living in two different addon roots. The script in the alphabetically-later root must win. + + + + + + identifier + void + + + normal + + diff --git a/test-files/golden-tests/extensions/lua-set-name/addons/extensions/rename.lua b/test-files/golden-tests/extensions/lua-set-name/addons/extensions/rename.lua new file mode 100644 index 0000000000..071d9d0d7d --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-name/addons/extensions/rename.lua @@ -0,0 +1,23 @@ +-- Mutate every function from a Lua extension. Exercises two shapes +-- of the generic setter: a scalar string assignment (`name`) and a +-- polymorphic-aware nested object whose leaves are +-- `Polymorphic` values selected by a kebab-case `kind` tag. +-- +-- corpus.symbols is a regular Lua sequence: 1-indexed, with `#` and +-- `ipairs`/`pairs` support. + +function transform_corpus(corpus) + for _, sym in ipairs(corpus.symbols) do + if sym.kind == "function" then + mrdocs.set(sym._id, "name", "renamed_" .. sym.name) + mrdocs.set(sym._id, "doc", { + brief = { + children = { + { kind = "text", + literal = "Brief rewritten by Lua extension" } + } + } + }) + end + end +end diff --git a/test-files/golden-tests/extensions/lua-set-name/mrdocs.yml b/test-files/golden-tests/extensions/lua-set-name/mrdocs.yml new file mode 100644 index 0000000000..cde6450530 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-name/mrdocs.yml @@ -0,0 +1,6 @@ +addons-supplemental: + - addons +generator: xml +multipage: false +warn-if-undocumented: false +source-root: . diff --git a/test-files/golden-tests/extensions/lua-set-name/set_name.adoc b/test-files/golden-tests/extensions/lua-set-name/set_name.adoc new file mode 100644 index 0000000000..2772ee369a --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-name/set_name.adoc @@ -0,0 +1,32 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#renamed_target_function[`renamed_target_function`] +| Brief rewritten by Lua extension +|=== + +[#renamed_target_function] +== renamed_target_function + +Brief rewritten by Lua extension + +=== Synopsis + +Declared in `<set_name.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +void +renamed_target_function(); +---- + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/extensions/lua-set-name/set_name.cpp b/test-files/golden-tests/extensions/lua-set-name/set_name.cpp new file mode 100644 index 0000000000..fe1ac9032f --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-name/set_name.cpp @@ -0,0 +1,2 @@ +/// A function whose name is rewritten by the Lua extension. +void target_function(); diff --git a/test-files/golden-tests/extensions/lua-set-name/set_name.html b/test-files/golden-tests/extensions/lua-set-name/set_name.html new file mode 100644 index 0000000000..59dc460739 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-name/set_name.html @@ -0,0 +1,52 @@ + + +Reference + + + +
+

Reference

+
+
+

+Global Namespace

+
+

+Functions

+ + + + + + + + + + +
NameDescription
renamed_target_function Brief rewritten by Lua extension
+ +
+
+
+

+renamed_target_function

+
+

Brief rewritten by Lua extension

+
+
+
+

+Synopsis

+
+Declared in <set_name.cpp>
+
void
+renamed_target_function();
+
+
+ +
+ + + \ No newline at end of file diff --git a/test-files/golden-tests/extensions/lua-set-name/set_name.xml b/test-files/golden-tests/extensions/lua-set-name/set_name.xml new file mode 100644 index 0000000000..547dc80a1c --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-name/set_name.xml @@ -0,0 +1,46 @@ + + + + + + namespace + //////////////////////////8= + regular + + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + + + + renamed_target_function + + + set_name.cpp + set_name.cpp + 2 + 1 + 1 + + + function + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + regular + //////////////////////////8= + + + brief + + text + Brief rewritten by Lua extension + + + + + + identifier + void + + + normal + + diff --git a/test-files/golden-tests/extensions/lua-set-return-type/addons/extensions/replace_return.lua b/test-files/golden-tests/extensions/lua-set-return-type/addons/extensions/replace_return.lua new file mode 100644 index 0000000000..bf8e7c8001 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-return-type/addons/extensions/replace_return.lua @@ -0,0 +1,41 @@ +-- Replace a function's return type from a Lua extension. +-- +-- The motivating use case is rendering coroutine-returning functions +-- in terms of a concept rather than the underlying coroutine type. +-- For example, a library might want every function returning +-- `capy::task` to advertise its return type as the `Awaitable` +-- concept (cross-linked to the concept's documentation page): +-- +-- local awaitable_id = nil +-- for _, s in ipairs(corpus.symbols) do +-- if s.kind == "concept" and s.name == "Awaitable" then +-- awaitable_id = s._id +-- break +-- end +-- end +-- ... +-- mrdocs.set(fn._id, "returnType", { +-- kind = "named", +-- name = { +-- kind = "identifier", +-- identifier = "Awaitable", +-- id = awaitable_id -- cross-links the type to the concept +-- } +-- }) +-- +-- This fixture omits the lookup and uses a bare identifier so the +-- test is self-contained. + +function transform_corpus(corpus) + for _, sym in ipairs(corpus.symbols) do + if sym.kind == "function" and sym.name == "target_function" then + mrdocs.set(sym._id, "returnType", { + kind = "named", + name = { + kind = "identifier", + identifier = "Awaitable" + } + }) + end + end +end diff --git a/test-files/golden-tests/extensions/lua-set-return-type/mrdocs.yml b/test-files/golden-tests/extensions/lua-set-return-type/mrdocs.yml new file mode 100644 index 0000000000..cde6450530 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-return-type/mrdocs.yml @@ -0,0 +1,6 @@ +addons-supplemental: + - addons +generator: xml +multipage: false +warn-if-undocumented: false +source-root: . diff --git a/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.adoc b/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.adoc new file mode 100644 index 0000000000..7f7feb369f --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.adoc @@ -0,0 +1,32 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#target_function[`target_function`] +| A function whose return type is replaced by the Lua extension. +|=== + +[#target_function] +== target_function + +A function whose return type is replaced by the Lua extension. + +=== Synopsis + +Declared in `<set_return_type.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +Awaitable +target_function(); +---- + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.cpp b/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.cpp new file mode 100644 index 0000000000..84ed614d0a --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.cpp @@ -0,0 +1,2 @@ +/// A function whose return type is replaced by the Lua extension. +int target_function(); diff --git a/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.html b/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.html new file mode 100644 index 0000000000..48a3809edf --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.html @@ -0,0 +1,52 @@ + + +Reference + + + +
+

Reference

+
+
+

+Global Namespace

+
+

+Functions

+ + + + + + + + + + +
NameDescription
target_function A function whose return type is replaced by the Lua extension.
+ +
+
+
+

+target_function

+
+

A function whose return type is replaced by the Lua extension.

+
+
+
+

+Synopsis

+
+Declared in <set_return_type.cpp>
+
Awaitable
+target_function();
+
+
+ +
+ + + \ No newline at end of file diff --git a/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.xml b/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.xml new file mode 100644 index 0000000000..8b31f65d77 --- /dev/null +++ b/test-files/golden-tests/extensions/lua-set-return-type/set_return_type.xml @@ -0,0 +1,46 @@ + + + + + + namespace + //////////////////////////8= + regular + + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + + + + target_function + + + set_return_type.cpp + set_return_type.cpp + 2 + 1 + 1 + + + function + HuxRZuBJaL6YnoJa2a7IFMcNqvo= + regular + //////////////////////////8= + + + brief + + text + A function whose return type is replaced by the Lua extension. + + + + + + identifier + Awaitable + + + normal + + diff --git a/util/generate_extension_allowed_fields.py b/util/generate_extension_allowed_fields.py new file mode 100644 index 0000000000..5a82a74786 --- /dev/null +++ b/util/generate_extension_allowed_fields.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# +# 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 +# +"""Generate the `mrdocs.set` allowlist artifacts from JSON. + +Reads `src/lib/Extensions/AllowedFields.json` and writes two outputs +from the single source of truth: + + - A C++ include (`AllowedFields.gen.hpp`) with a constexpr + `kSettableFields[]` array, consumed by `SetMember.cpp` to gate + `mrdocs.set` calls at runtime. + + - An AsciiDoc partial (`extensions-allowed-fields.adoc`) included + by `extensions.adoc` to render the reference table for users. + +The same JSON drives both, so the runtime allowlist and the rendered +docs cannot drift. Adding a new entry is one edit to the JSON. + +Run automatically by CMake whenever the JSON or this script changes. +""" + +import json +import sys +from pathlib import Path + + +def emit_cpp_include(fields: list[dict], output_path: Path) -> None: + lines: list[str] = [ + "//", + "// 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", + "//", + "// Generated by util/generate_extension_allowed_fields.py from", + "// src/lib/Extensions/AllowedFields.json. DO NOT EDIT - edit", + "// the JSON and rebuild.", + "//", + "", + "#ifndef MRDOCS_API_EXTENSIONS_ALLOWEDFIELDS_GEN_HPP", + "#define MRDOCS_API_EXTENSIONS_ALLOWEDFIELDS_GEN_HPP", + "", + "#include ", + "", + "namespace mrdocs {", + "", + "/** Names of fields scripts may set through `mrdocs.set`.", + "", + " Matched against the normalized member name (camelCase", + " first letter lowered). See", + " `src/lib/Extensions/AllowedFields.json` for the type", + " and human-facing description of each entry.", + "*/", + "inline constexpr std::string_view kSettableFields[] = {", + ] + for f in fields: + lines.append(f' "{f["name"]}",') + lines.append("};") + lines.append("") + lines.append("} // mrdocs") + lines.append("") + lines.append("#endif") + lines.append("") + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text("\n".join(lines), encoding="utf-8") + + +def emit_adoc_table(fields: list[dict], output_path: Path) -> None: + lines: list[str] = [ + "// Generated by util/generate_extension_allowed_fields.py from", + "// src/lib/Extensions/AllowedFields.json. DO NOT EDIT - edit", + "// the JSON and rebuild.", + "", + "|===", + "|Field |Type |Description", + "", + ] + for f in fields: + lines.append(f"|`{f['name']}`") + lines.append(f"|{f['type']}") + lines.append(f"|{f['description']}") + lines.append("") + lines.append("|===") + lines.append("") + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text("\n".join(lines), encoding="utf-8") + + +def main() -> int: + if len(sys.argv) != 4: + print( + "usage: generate_extension_allowed_fields.py " + " ", + file=sys.stderr, + ) + return 2 + + json_path = Path(sys.argv[1]) + cpp_path = Path(sys.argv[2]) + adoc_path = Path(sys.argv[3]) + + data = json.loads(json_path.read_text(encoding="utf-8")) + fields = data["fields"] + if not fields: + print( + "generate_extension_allowed_fields.py: at least one field required", + file=sys.stderr, + ) + return 1 + + emit_cpp_include(fields, cpp_path) + emit_adoc_table(fields, adoc_path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From b00938b7682e6d7e8c58b8bb561cefcc09418ebe Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Wed, 20 May 2026 17:46:35 +0200 Subject: [PATCH 5/5] docs: document scripting extensions Add end-to-end documentation for the Lua and JavaScript scripting surface. extensions.adoc documents the corpus-mutation model: a worked example (synthesizing briefs from a naming convention), the file layout, the `transform_corpus` hook, the `corpus` argument, the `mrdocs.set` API and its allowlist (the table is generated from AllowedFields.json), the lifecycle, an "Invariants and operations" section spelling out the corpus invariants and what scripts can and cannot do today, a "Stability" section stating the extensibility contract, and a "Design rationale" section comparing the alternatives considered for the write surface. addons.adoc is a new shared page for the addon concept (lookup paths, `addons` vs `addons-supplemental`, override vs layering) used by both the helper and the extension layers. generators.adoc gains a `[#custom-helpers]` section covering the JS and Lua Handlebars helpers and the layering model. nav.adoc lists the new addons page. --- docs/modules/ROOT/nav.adoc | 2 + docs/modules/ROOT/pages/addons.adoc | 58 +++ docs/modules/ROOT/pages/extensions.adoc | 449 ++++++++++++++++++++++++ docs/modules/ROOT/pages/generators.adoc | 59 +++- 4 files changed, 561 insertions(+), 7 deletions(-) create mode 100644 docs/modules/ROOT/pages/addons.adoc create mode 100644 docs/modules/ROOT/pages/extensions.adoc diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 5b459928ba..ca7c2c9649 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -4,7 +4,9 @@ * xref:usage.adoc[Getting Started] * xref:config-file.adoc[] * xref:commands.adoc[Documenting the Code] +* xref:addons.adoc[] * xref:generators.adoc[] +* xref:extensions.adoc[] * xref:design-notes.adoc[] * xref:reference:index.adoc[Library Reference] * Contribute diff --git a/docs/modules/ROOT/pages/addons.adoc b/docs/modules/ROOT/pages/addons.adoc new file mode 100644 index 0000000000..0f970a919b --- /dev/null +++ b/docs/modules/ROOT/pages/addons.adoc @@ -0,0 +1,58 @@ += Addons + +Mr.Docs is customised through *addons*: directory trees that hold custom Handlebars templates, helper scripts and extension scripts. +This page describes addons as a whole; what goes in each subdirectory is covered on xref:generators.adoc[Generators] and xref:extensions.adoc[Extensions]. + +== Default and replacement addons + +Mr.Docs ships a built-in addon at `share/mrdocs/addons/`. +It contains the default Handlebars templates, helpers and any baseline scripts. + +The `addons` configuration option replaces it: + +[source,yaml] +---- +addons: /path/to/custom/addons +---- + +The replacement is wholesale: your addon should provide everything Mr.Docs needs for the generators you intend to use. + +== Supplemental addons + +The more common pattern is to *layer* a small addon on top of the built-in one rather than replace it entirely. +That is what `addons-supplemental` is for: + +[source,yaml] +---- +addons-supplemental: + - addons + - /path/to/another +---- + +Each supplemental addon is layered on top of the base addon in the order listed. + +== Layout + +[source,text] +---- +/ +|-- generator/ +| |-- common/{partials,helpers}/ # shared across output formats +| `-- /{layouts,partials,helpers}/ # per-format (html, adoc, ...) +`-- extensions/ + |-- *.js + `-- *.lua +---- + +* `generator/` -- Handlebars templates and helpers for the output stage. Detailed in xref:generators.adoc[Generators]. +* `extensions/` -- scripts that run after corpus construction and can mutate symbols before any generator sees them. Detailed in xref:extensions.adoc[Extensions]. + +== Discovery + +Mr.Docs visits addons in this order: + +. The base addon (the built-in `share/mrdocs/addons/` or whatever `addons` points at). +. Each entry of `addons-supplemental`, in the order listed. + +For templates and helpers, the rule is *last wins*: a file in a supplemental addon overrides a same-named file in earlier addons. +For extensions, every script across every addon is collected and run in alphabetical order by full path; there is no override semantic. diff --git a/docs/modules/ROOT/pages/extensions.adoc b/docs/modules/ROOT/pages/extensions.adoc new file mode 100644 index 0000000000..3f2d05f3c4 --- /dev/null +++ b/docs/modules/ROOT/pages/extensions.adoc @@ -0,0 +1,449 @@ += Extensions + +Extensions let you transform the corpus of extracted symbols before any generator runs. +A typical use case is rewriting metadata across many symbols at once: backfilling briefs from a naming convention, tagging symbols by group, or marking generated code as "see below" in the output. + +Extensions are user-supplied scripts written in JavaScript or Lua. +They run between extraction (turning C++ source into a corpus of symbols) and rendering (turning the corpus into output files), so any change they make is visible to every generator. + +== A worked example: brief from naming convention + +Many codebases follow a convention like "any function whose name +starts with `is_` is a predicate returning `true` if its name +(in plain English) holds." That convention encodes information the +documentation could repeat verbatim, so writing the brief on every +declaration is busy-work. + +An extension walks the corpus, picks every function whose name +starts with `is_` and writes a brief synthesised from the rest of +the identifier: + +[source,lua] +---- +function transform_corpus(corpus) + for _, sym in ipairs(corpus.symbols) do + if sym.kind == "function" + and sym.name:sub(1, 3) == "is_" then + local subject = sym.name:sub(4):gsub("_", " ") + mrdocs.set(sym._id, "doc", { + brief = { + children = { + { kind = "text", + literal = "Returns true if " + .. subject .. "." } + } + } + }) + end + end +end +---- + +Same shape, JavaScript: + +[source,javascript] +---- +function transform_corpus(corpus) { + for (var i = 0; i < corpus.symbols.length; ++i) { + var sym = corpus.symbols[i]; + if (sym.kind === "function" && + sym.name.startsWith("is_")) { + var subject = sym.name.slice(3).replace(/_/g, " "); + mrdocs.set(sym._id, "doc", { + brief: { + children: [ + { kind: "text", + literal: "Returns true if " + subject + "." } + ] + } + }); + } + } +} +---- + +After this runs, every `is_foo_bar` function shows up in the +generated docs with the brief "Returns true if foo bar." -- no +edits to the C++ source. The same shape generalises: + +* deprecation-by-suffix (every `..._legacy` function gains a + `deprecated` block); +* "see-below" marking for symbols matching a glob; +* tagging symbols by group via a synthetic field; +* rewriting return types to advertise a concept rather than the + concrete coroutine type they actually return (see the + `lua-set-return-type` golden fixture for a working example). + +== File layout + +Extension scripts live under `/extensions/`, with the `.lua` or `.js` extension. +See xref:addons.adoc[Addons] for where addon roots come from (`addons`, `addons-supplemental`) and how scripts across multiple roots are aggregated -- scripts run in alphabetical order by full path, with the two languages interleaved. + +== The `transform_corpus` hook + +A script extends Mr.Docs by exposing a function named `transform_corpus(corpus)`. +Mr.Docs calls it once with a flat read-only view of the corpus. +The script inspects symbols and calls mutation functions on the pre-registered `mrdocs` object to apply changes. + +A script that does not define `transform_corpus` is silently ignored, so an extension file can be empty during development without breaking the build. + +[source,javascript] +---- +// /extensions/rename.js +function transform_corpus(corpus) { + for (var i = 0; i < corpus.symbols.length; ++i) { + var sym = corpus.symbols[i]; + if (sym.kind === "function") { + mrdocs.set(sym._id, "name", "renamed_" + sym.name); + } + } +} +---- + +[source,lua] +---- +-- /extensions/rename.lua +function transform_corpus(corpus) + for _, sym in ipairs(corpus.symbols) do + if sym.kind == "function" then + mrdocs.set(sym._id, "name", "renamed_" .. sym.name) + end + end +end +---- + +== The `corpus` argument + +The `corpus` argument has a single field today: + +* `corpus.symbols` -- an array containing every symbol Mr.Docs extracted. + +That is the entire shape of `corpus`. +There is no `corpus.namespaces`, no `corpus.config`, no `corpus.lookup`; scripts that need such queries walk `corpus.symbols` and filter. + +Each entry in `corpus.symbols` is the same lazy DOM view that Mr.Docs's built-in Handlebars generators see, with every described member of the underlying symbol type. +The fields you'll reach for most often are `_id` (the flat base16 string you pass back to `mrdocs.set` to identify the symbol to act on), `kind`, and `name`; deeper navigation (`id`, `doc`, `loc`, `params`, `bases`, ...) works exactly as in templates -- in particular `symbol.id` is the recursive Symbol object, identical to what templates see, so `symbol.id.name` reads the same way in either context. +For the full set, see the Handlebars/templates documentation. + +In JavaScript, iterate with `corpus.symbols.length` and `corpus.symbols[i]`, or with `for (var s of corpus.symbols)`. + +In Lua, `corpus.symbols` behaves like a regular Lua sequence: it is 1-indexed, `#corpus.symbols` is its length, and `ipairs` and `pairs` work as expected. + +== The `mrdocs` API + +Mutations go through the pre-registered `mrdocs` global. + +=== `mrdocs.set(symbol_id, field, value)` + +Assign one allowlisted field of a symbol. The function dispatches through reflection, but the set of fields scripts may write is intentionally curated: extensions cannot, for example, change a symbol's `kind`, re-parent it, or rewrite its structural collections, because doing so would break invariants the rest of the corpus relies on. + +* `symbol_id`: the `_id` string read from a symbol in `corpus.symbols`. +* `field`: one of the allowlisted names below (camelCase, matching the read view). +* `value`: the new value. Supported value types are strings, booleans, enumerator names (kebab-case strings), `null` (to clear an optional field), arrays (the array replaces the existing `vector` field wholesale -- there is no in-place edit of individual elements), objects (assigned key-wise to a described struct field), and objects with a `kind` selector for a polymorphic base (the `kind` picks the concrete derived class registered through `MRDOCS_DESCRIBE_KINDS`, and remaining keys are forwarded to that class). + +The currently allowlisted fields are listed below. This table is +generated at build time from `src/lib/Extensions/AllowedFields.json`, +which also feeds the runtime allowlist consumed by `mrdocs.set`, so the +table and the gate cannot drift. + +include::partial$extensions-allowed-fields.adoc[] + +The setter validates its arguments and raises an error on misuse: unknown symbol id, field not on the allowlist, type mismatch (for example, a non-string passed to `name`), an enumerator name that does not exist on the field's enum, or a `kind` tag that does not name a derived class registered for the polymorphic base. An uncaught error inside an extension aborts the build with the script's path and the error message. + +The allowlist grows as concrete use cases come up. The type machinery covers strings, booleans, described enums, `Optional`, `vector`, described structs, and `Polymorphic` for any base whose hierarchy was registered with `MRDOCS_DESCRIBE_KINDS`. + +== Lifecycle + +Extensions run between corpus finalization and the first generator invocation. +The order is: + +. Mr.Docs walks the source files and extracts a corpus of symbols. +. Built-in finalizers post-process the corpus (for example, sorting members and resolving inheritance). +. Extensions run, in alphabetical order by full path. +. The selected generator renders the (possibly mutated) corpus. + +Because step 3 happens before step 4, an extension that mutates a symbol is visible to every output format, not just one. + +== Invariants and operations + +The extension surface today is deliberately narrow. This section +spells out what that means: which corpus properties the rest of +Mr.Docs depends on, what scripts can do today without violating +them, and where the boundary will move next. + +=== Corpus invariants + +The corpus passed to extensions has already been finalized. The +finalizers and the generators that read the corpus afterwards rely +on a handful of structural properties: + +* *Every `SymbolID` is unique* and identifies exactly one symbol. + Lookups (cross-references, `@ref`, derived-class lists, ...) + resolve through these IDs and must find a live symbol on the + other end. +* *A symbol's kind is fixed* (a `FunctionSymbol` stays a function, + a `RecordSymbol` stays a record). Generators dispatch on kind to + pick the right partial; finalizers (overload merging, base-class + inheritance, ...) assume their kind-typed inputs. +* *Parent/child links are consistent in both directions.* If + symbol `X` lists `Y` as a member of one of its tranches, `Y.Parent` + must point at `X`; if `Y.Parent` is `X`, then `X` must list `Y`. +* *Structural collections are coherent with the records they + describe.* The `RecordInterface` of a class lists the same members + the class actually has; the `Specializations` and `DeductionGuides` + back-pointers populated by finalizers reference real symbols of + the right kind. + +Breaking any of these is what the docs mean by "corpus invariant +violation". A generator hitting an inconsistent corpus does not +necessarily crash; it might silently render the wrong cross-link or +omit a member. The intent of the extension surface is to make those +classes of bug *unreachable from a script*. + +=== Within-symbol guarantees (what's safe today) + +`mrdocs.set` is the entire write surface, and it guards every +invariant above: + +* *Symbol kind never changes.* `kind` is not in the allowlist; + attempting to write it fails with "field is not user-settable". +* *Symbol identity never changes.* `id` is not in the allowlist; + the symbol you address by `_id` is always the one you mutate. +* *Parent/child links and structural collections never change.* + `Parent`, the `RecordInterface`, base and derived lists, member + tranches, `Specializations`, `DeductionGuides`, and similar fields + are all off the allowlist by design. +* *Kind never escapes its hierarchy on polymorphic writes.* The + `Polymorphic` write path requires a `kind:` selector that names + a derived class registered for the polymorphic base; passing an + unrelated kind is a clean error, not an unsafe cast. +* *Off-shape writes are rejected.* Setting a string field with a + boolean, an enum with an unknown enumerator name, or a struct + field with an unknown sub-field all return errors before any + mutation happens. + +The fields that *are* writable today are leaf, presentation-layer +properties: a symbol's `name`, `extraction`, doc-comment +tree, source location, return type, and the "inherited from base" +flag. None of them can break a finalizer invariant. + +=== Useful patterns today + +Even with that narrow surface, the things scripts can do today +cover a lot of practical ground: + +* *Backfill documentation by convention.* Rewrite `doc.brief` + (or `doc.params`, `doc.returns`, ...) for every symbol matching + a pattern -- see the "worked example" above. +* *Rename for presentation.* Change a symbol's display `name` + without renaming it in the source. Useful for cleaning up + internal-prefixed identifiers in the rendered docs. +* *Hide symbols from output.* Set `extraction` to `dependency` or + `implementation-defined` to drop a symbol from the regular + output (or push it into a "see-below" section, depending on the + generator). +* *Tag symbols by group.* Stamp a uniform `extraction` value on a + set of symbols matching some predicate, then let the generator's + partials use that tag to lay them out. +* *Rewrite return types* to expose a concept rather than the + concrete coroutine/transport type. The `lua-set-return-type` + fixture shows this end to end. + +What scripts *cannot* do today (and why): + +* *Add symbols.* New symbols would need a fresh `SymbolID`, a real + parent, a place in the parent's tranches, and possibly cross-links + back from other symbols. Each of those is invariant-bearing and + there is no syntax for declaring them from a script yet. +* *Remove symbols.* Outright removal would leave dangling + cross-references (every base list, derived list, `Specializations`, + `@ref` and so on that targets the removed symbol). The closest + thing today is making `extraction` non-`regular`, which keeps + the symbol in the corpus but suppresses it from the rendered + output. +* *Merge symbols.* Re-ID, re-parent, re-link -- not yet expressible. +* *Move symbols across parents.* Same as merge: it would require + rewriting structural collections on both sides. +* *Change kinds.* A symbol cannot be turned from a function into a + variable; the rest of the pipeline assumes the kind it had at + finalization. + +=== Per-operation outlook + +The table below records the current status and the rough plan for +each structural operation. None of the "later" entries are part of +this release. + +|=== +|Operation |Status today |Why not yet / how it might land + +|Mutate writable field +|Supported via `mrdocs.set` +|This is the entire current surface. + +|Add a custom data tag +|Not yet +|A separate "tags" bag on each symbol -- written by scripts, never + read by the C++ core -- is the lowest-risk extension since it + carries no invariant. Templates would read it through the same DOM + they already use. This is the next planned addition. + +|Hide a symbol +|Supported via `extraction = non-regular` +|Works today; no new mechanism needed. + +|Remove a symbol +|Not supported +|Requires sweeping every cross-reference to it (base lists, + derived lists, `Specializations`, `Overloads`, ...). The corpus + would have to be re-finalized post-script to re-validate; not + yet wired. + +|Add a symbol +|Not supported +|Needs an ID-allocation API, a parent slot, and a kind-checked + builder. The custom-data-bag pattern covers many of the cases + where users currently ask for "add a symbol" without the + structural risk. + +|Merge symbols +|Not supported +|Strict superset of add+remove. Would require re-ID, re-link, and + re-finalization. + +|Change a symbol's kind +|Not supported +|The kind is encoded in the C++ type of the symbol; scripts cannot + reach across kinds. The standard answer is "create a new symbol + of the right kind", which is the unsupported case above. + +|Create a new C++-side data type +|Not supported by extensions +|For example, niebloid support would today be added on the C++ + side. The extension layer is not a metaprogramming layer over + the corpus shape; it is a curated mutation surface over an + already-extracted shape. +|=== + +The shape of the trade-off the project is sitting at today: *no +structural changes from scripts*. That is the strict end of the +spectrum. The other end -- "anything goes, re-validate after" -- +needs a post-extension finalization phase that re-establishes every +invariant; that machinery does not exist yet. The custom-data-bag +addition is the natural next step because it loosens the surface +without weakening any invariant. + +=== Enabling and disabling extensions + +There is no per-script enable/disable flag in the configuration +today. Any `.lua` or `.js` file found under an addon's `extensions/` +directory runs unconditionally on every Mr.Docs invocation that +includes that addon root. + +If you want a script to stop running, the practical options today +are: + +* Move the file out of the `extensions/` directory. +* Rename it so it no longer matches `*.lua` / `*.js`. +* Use a different `addons-supplemental` list in the configuration + for the build where you don't want the script to run. + +A finer-grained enable/disable mechanism (a config key listing +which extensions to load, or a script-side opt-out) is on the same +roadmap as the registration-based extension API; see issue +https://github.com/cppalliance/mrdocs/issues/1210[#1210]. + +== Stability + +The script surface and the C++ types it reflects evolve together. +The contract scripts can rely on: + +* *New C++ fields appear in the script DOM automatically* once they + are described with `MRDOCS_DESCRIBE_STRUCT`. Scripts that only + read them gain visibility on the next Mr.Docs release without any + script-side change. +* *New writable fields require an allowlist addition.* The + `mrdocs.set` allowlist is curated by Mr.Docs maintainers; a C++ + field becoming writable is a deliberate decision, not an automatic + consequence of being described. The list grows as concrete use + cases come up. +* *New `mrdocs.*` functions are additive.* A future + `mrdocs.transformFoo(...)` does not break scripts that don't call + it. +* *Breakage only on allowlisted rename or removal.* Renaming a C++ + field that is in the allowlist (e.g., `returnType` -> `result`) + would break scripts that wrote to it. The allowlist gives Mr.Docs + a precise list of fields to keep stable or alias when refactoring. + +In other words: read access tracks the C++ types automatically; +write access only changes by deliberate maintainer decision. + +== Design rationale + +The shape of this extension API is intentional. Three choices stand out +and have alternatives worth naming. + +=== One gated setter, not many helpers + +`mrdocs.set` is a single reflection-driven function dispatched by field +name and gated by an allowlist. Four shapes were considered: + +. *(A) Domain-specific helpers* -- `mrdocs.rename`, `mrdocs.deprecate`, + one function per intent. Stable and readable, but every helper is + hand-written, has to be kept in sync with the C++ types, and the + surface grows linearly with use cases. +. *(B) Stable script-side schema independent of C++ names.* A + parallel schema where script-facing field names are decoupled from + C++ identifiers. Scripts don't break when C++ members are renamed; + the cost is an extra schema to author and maintain alongside the + C++ types, plus a translation layer. +. *(C) Direct DOM manipulation.* Mutable corpus, scripts assign + fields directly via `sym.field = value`. The most "natural" shape + for script authors, but there is no allowlist gate -- nothing + stops a script from breaking corpus invariants by changing a + symbol's `kind`, re-parenting it, or mutating structural + collections. +. *(D) Reflection-driven generic setter -- what this ships.* One + function (`mrdocs.set(id, field, value)`); dispatch by field name; + an allowlist controls reachable fields; reflective sub-dispatch + handles nested and polymorphic values. The surface stays in sync + with the C++ types for free, since the contract is "the normalized + C++ member name is the script API name." + +(D) was picked because it auto-tracks C++ changes (read side stays +in sync without code), keeps the write surface narrow (allowlist), +and doesn't require a parallel schema (cost of (B)) or hand-written +domain helpers (cost of (A)). The trade-off is real: renaming an +allowlisted C++ member would break scripts -- aliases can mitigate +that when the time comes (see also the Stability section above). + +=== Reserved function name, not registration + +A script announces itself by defining a function named +`transform_corpus`. Mr.Docs calls it on every loaded extension. The +classic alternative is _registration_: the script body calls into +Mr.Docs to attach handlers (Darktable's Lua plugins, GDB's Python API, +LLVM passes, ...). + +The reserved-name pattern is the simplest thing that works for the +current use cases. Its limitation is that an extension can only do one +thing: there is no obvious place to attach helpers, secondary callbacks, +or per-symbol hooks without inventing additional reserved names. + +We're shipping the reserved-name pattern deliberately, and tracking the +larger design question -- when (and how) to climb to a +registration-based API -- in +https://github.com/cppalliance/mrdocs/issues/1210[issue #1210]. + +=== Asymmetry with the C++ side + +The read view scripts see (`corpus.symbols[i].name`, `symbol.doc.brief`, +...) is the same DOM the Handlebars generators already render from. It +is not custom for scripts. Only the write surface (`mrdocs.set`) is +new, and it is deliberately narrow: dynamically typed, sandboxed, and +routed through a single gated function rather than allowing direct +assignment. + +The asymmetry is by design: C++ wants strong types and refactor safety; +scripts want a small, well-defined surface that survives refactors of +the underlying types. diff --git a/docs/modules/ROOT/pages/generators.adoc b/docs/modules/ROOT/pages/generators.adoc index d3c0f92e90..38c56bd26b 100644 --- a/docs/modules/ROOT/pages/generators.adoc +++ b/docs/modules/ROOT/pages/generators.adoc @@ -77,13 +77,8 @@ Mr.Docs attempts to support various alternatives for customizing the output form The `adoc` and `html` generators use https://handlebarsjs.com/[Handlebars,window=_blank] templates. These templates can be customized to change the output format and style of the documentation. -The templates used to generate the documentation are located in the `share/mrdocs/addons/generator` directory. -Users can create a copy of these files and provide their own `addons` directory via the `addons` option in the configuration file or via the command line. - -[source,yaml] ----- -addons: /path/to/custom/addons ----- +The templates used to generate the documentation live in the built-in addon under `share/mrdocs/addons/generator`. +You can replace or supplement that addon with your own; xref:addons.adoc[Addons] covers the lookup paths (`addons`, `addons-supplemental`) and how files from multiple addons combine. Each symbol goes through a main layout template in the `/generator//layouts/single-symbol..hbs` directory. This template is a simple entry point that renders the partial relative to the symbol kind. @@ -111,6 +106,56 @@ The layout template can include other partial templates to render the symbol dat The Document Object Model (DOM) for each symbol includes all information about the symbol.One advantage of custom templates over post-processing XML files is the ability to access symbols as a graph.If symbol `A` refers to symbol `B`, some properties of symbol `B` are likely to be required in the documentation of `A`.All templates and generators can access a reference to `B` by searching the symbol tree or simply by accessing the elements `A` refers to.All references to other symbols are resolved in the templates. +[#custom-helpers] +=== Custom Helpers + +Beyond the built-in helpers, an addon can register its own Handlebars helpers in JavaScript or Lua. +Drop a script alongside your partials and Mr.Docs picks it up automatically: + +* `/generator/common/helpers/*.{js,lua}`: helpers available to every output format. +* `/generator//helpers/*.{js,lua}`: helpers available only to the matching format (`html`, `adoc`, ...). A format-specific helper overrides a common one with the same name. + +The file's stem (with the `.js` or `.lua` extension stripped) becomes the helper name. +Templates invoke it the same way they invoke a built-in helper: + +[source,handlebars] +---- +{{my_helper symbol.name}} +---- + +==== Helper resolution + +Each script is expected to expose a single function, the helper: + +* Return the function from the chunk (recommended): ++ +[source,javascript] +---- +return function(name) { + return "[" + name + "]"; +}; +---- ++ +[source,lua] +---- +return function(name) + return "[" .. name .. "]" +end +---- + +* Or define a global with the same stem as the file (for example, `my_helper.lua` defining `function my_helper(name) ... end`). + +==== Utility files + +A file whose stem starts with an underscore (for example, `_utils.js`) is loaded first and is *not* registered as a helper. +Use these files to define globals that several helpers share, so a single utility script can set up state once instead of every helper duplicating it. + +==== Arguments and the Handlebars options object + +Mr.Docs strips Handlebars' trailing options object before forwarding arguments to the helper. +Helpers receive the positional arguments only and don't have to filter the options out themselves. +This also avoids expensive marshalling of symbol contexts, which contain circular references. + == Stylesheet Options The HTML and AsciiDoc generators ship a bundled stylesheet that is inlined by default. You can replace or layer styles with the following options (available in config files and on the CLI):