From e842d428f8125d1fad745e25e52e86b3fad8d753 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Fri, 18 Sep 2026 12:37:45 +0200 Subject: [PATCH 1/5] fix: a private member can be marked implementation-defined The filters that drop whole kinds of symbol, such as private members, anonymous namespaces and file-level statics, ran before the ones that match names, so `@implementationdefined` on a private member was read too late to matter: the member stayed a dependency, and a public signature naming it printed the name instead of the placeholder. We now read the command written on the declaration when one of those filters rejects it. A glob, or a mode inherited from a parent, still doesn't override those filters, since it doesn't speak for that one declaration. I've added tests. Note that a private member marked `@seebelow` is still dropped: it would get a page, and the only way to reach that page would be the list of private members, which `extract-private: false` hides. --- src/mrdocs/AST/ASTVisitor.cpp | 29 ++++- .../private-implementation-defined-glob.cpp | 13 ++ .../private-implementation-defined-glob.xml | 93 +++++++++++++ .../private-implementation-defined-glob.yml | 5 + .../private-implementation-defined.cpp | 15 +++ .../private-implementation-defined.xml | 123 ++++++++++++++++++ .../filters/symbol-type/private-see-below.cpp | 15 +++ .../filters/symbol-type/private-see-below.xml | 93 +++++++++++++ .../filters/symbol-type/private-see-below.yml | 4 + 9 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.cpp create mode 100644 tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.xml create mode 100644 tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.yml create mode 100644 tests/golden/fixtures/filters/symbol-type/private-implementation-defined.cpp create mode 100644 tests/golden/fixtures/filters/symbol-type/private-implementation-defined.xml create mode 100644 tests/golden/fixtures/filters/symbol-type/private-see-below.cpp create mode 100644 tests/golden/fixtures/filters/symbol-type/private-see-below.xml create mode 100644 tests/golden/fixtures/filters/symbol-type/private-see-below.yml diff --git a/src/mrdocs/AST/ASTVisitor.cpp b/src/mrdocs/AST/ASTVisitor.cpp index 1e9fc460597..b2f13015fe1 100644 --- a/src/mrdocs/AST/ASTVisitor.cpp +++ b/src/mrdocs/AST/ASTVisitor.cpp @@ -3338,7 +3338,34 @@ checkFilters( // This filters symbols supported by MrDocs and // symbol types whitelisted in the configuration, // such as private members and anonymous namespaces. - MRDOCS_CHECK_OR(checkTypeFilters(D, access), ExtractionMode::Dependency); + if (!checkTypeFilters(D, access)) + { + // Consider the case of a public function returning + // a private nested type marked + // `@implementationdefined`. Dropping the type here + // (for being private) would cause the type name to + // be printed normally in the function signature, + // with no trace of "implementation-defined". So, + // make the command decide. Only a command on the + // declaration itself does: a glob or a mode inherited + // from a parent is not about that one declaration. + // (An implicit declaration has no doc-comment to carry + // the command.) A `@seebelow` private member would get + // a page, and the only way to reach it would be the + // list of private members that `extract-private` hides, + // so a private member can only be marked + // `@implementationdefined`. + // + // See tests/golden/fixtures/filters/symbol-type/private-implementation-defined.cpp. + MRDOCS_CHECK_OR(!D->isImplicit(), ExtractionMode::Dependency); + std::optional const command = + checkDocCommentExtractionFlag(D); + MRDOCS_CHECK_OR( + command == ExtractionMode::ImplementationDefined || + (command == ExtractionMode::SeeBelow && + access != clang::AS_private), + ExtractionMode::Dependency); + } // Check if this symbol should be extracted according // to its qualified name. This checks if it matches diff --git a/tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.cpp b/tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.cpp new file mode 100644 index 00000000000..a7ec8d3e192 --- /dev/null +++ b/tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.cpp @@ -0,0 +1,13 @@ +class widget +{ + /** A tag the caller can hold but not name. + */ + struct token {}; + +public: + /** Acquire the widget. + + @return A token that releases the widget when destroyed. + */ + token acquire(); +}; diff --git a/tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.xml b/tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.xml new file mode 100644 index 00000000000..3afcd64f1e5 --- /dev/null +++ b/tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.xml @@ -0,0 +1,93 @@ + + + + index + namespace + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + regular + + 3bKFAp8KD2cGZRzLRFok293VGkSJ + + + + widget + widget + + + private-implementation-defined-glob.cpp + private-implementation-defined-glob.cpp + 1 + 1 + + + record + 3bKFAp8KD2cGZRzLRFok293VGkSJ + regular + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + class + + + 47p71Rou7FKcaaaXvvRN9q7nNBK1 + + + 4SqFwBZt8U1g2unjcrqmJoi1Lb6q + + + + + acquire + acquire + + + + private-implementation-defined-glob.cpp + private-implementation-defined-glob.cpp + 12 + 5 + + + + + function + 47p71Rou7FKcaaaXvvRN9q7nNBK1 + public + regular + 3bKFAp8KD2cGZRzLRFok293VGkSJ + + + brief + + + text + Acquire the widget. + + + + + + returns + + + text + A token that releases the widget when destroyed. + + + + + + + + named + + + identifier + 4SqFwBZt8U1g2unjcrqmJoi1Lb6q + token + + + + + normal + + + diff --git a/tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.yml b/tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.yml new file mode 100644 index 00000000000..02f21ea21af --- /dev/null +++ b/tests/golden/fixtures/filters/symbol-type/private-implementation-defined-glob.yml @@ -0,0 +1,5 @@ +# The glob matches a private member, but only a command on the member +# itself overrides the filter that drops private members, so the member +# stays a dependency. +implementation-defined: + - 'widget::token' diff --git a/tests/golden/fixtures/filters/symbol-type/private-implementation-defined.cpp b/tests/golden/fixtures/filters/symbol-type/private-implementation-defined.cpp new file mode 100644 index 00000000000..eb516d64fd9 --- /dev/null +++ b/tests/golden/fixtures/filters/symbol-type/private-implementation-defined.cpp @@ -0,0 +1,15 @@ +class widget +{ + /** A tag the caller can hold but not name. + + @implementationdefined + */ + struct token {}; + +public: + /** Acquire the widget. + + @return A token that releases the widget when destroyed. + */ + token acquire(); +}; diff --git a/tests/golden/fixtures/filters/symbol-type/private-implementation-defined.xml b/tests/golden/fixtures/filters/symbol-type/private-implementation-defined.xml new file mode 100644 index 00000000000..63324b00c47 --- /dev/null +++ b/tests/golden/fixtures/filters/symbol-type/private-implementation-defined.xml @@ -0,0 +1,123 @@ + + + + index + namespace + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + regular + + 3bKFAp8KD2cGZRzLRFok293VGkSJ + + + + widget + widget + + + private-implementation-defined.cpp + private-implementation-defined.cpp + 1 + 1 + + + record + 3bKFAp8KD2cGZRzLRFok293VGkSJ + regular + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + class + + + 47p71Rou7FKcaaaXvvRN9q7nNBK1 + + + 4SqFwBZt8U1g2unjcrqmJoi1Lb6q + + + + + acquire + acquire + + + + private-implementation-defined.cpp + private-implementation-defined.cpp + 14 + 5 + + + + + function + 47p71Rou7FKcaaaXvvRN9q7nNBK1 + public + regular + 3bKFAp8KD2cGZRzLRFok293VGkSJ + + + brief + + + text + Acquire the widget. + + + + + + returns + + + text + A token that releases the widget when destroyed. + + + + + + + + named + + + identifier + 4SqFwBZt8U1g2unjcrqmJoi1Lb6q + token + + + + + normal + + + + token + token + + + private-implementation-defined.cpp + private-implementation-defined.cpp + 7 + 5 + + + + record + 4SqFwBZt8U1g2unjcrqmJoi1Lb6q + private + implementation-defined + 3bKFAp8KD2cGZRzLRFok293VGkSJ + + + brief + + + text + A tag the caller can hold but not name. + + + + + struct + + diff --git a/tests/golden/fixtures/filters/symbol-type/private-see-below.cpp b/tests/golden/fixtures/filters/symbol-type/private-see-below.cpp new file mode 100644 index 00000000000..12cd0c82bbf --- /dev/null +++ b/tests/golden/fixtures/filters/symbol-type/private-see-below.cpp @@ -0,0 +1,15 @@ +class widget +{ + /** A tag the caller can hold but not name. + + @seebelow + */ + struct token {}; + +public: + /** Acquire the widget. + + @return A token that releases the widget when destroyed. + */ + token acquire(); +}; diff --git a/tests/golden/fixtures/filters/symbol-type/private-see-below.xml b/tests/golden/fixtures/filters/symbol-type/private-see-below.xml new file mode 100644 index 00000000000..b6e1c9ad796 --- /dev/null +++ b/tests/golden/fixtures/filters/symbol-type/private-see-below.xml @@ -0,0 +1,93 @@ + + + + index + namespace + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + regular + + 3bKFAp8KD2cGZRzLRFok293VGkSJ + + + + widget + widget + + + private-see-below.cpp + private-see-below.cpp + 1 + 1 + + + record + 3bKFAp8KD2cGZRzLRFok293VGkSJ + regular + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + class + + + 47p71Rou7FKcaaaXvvRN9q7nNBK1 + + + 4SqFwBZt8U1g2unjcrqmJoi1Lb6q + + + + + acquire + acquire + + + + private-see-below.cpp + private-see-below.cpp + 14 + 5 + + + + + function + 47p71Rou7FKcaaaXvvRN9q7nNBK1 + public + regular + 3bKFAp8KD2cGZRzLRFok293VGkSJ + + + brief + + + text + Acquire the widget. + + + + + + returns + + + text + A token that releases the widget when destroyed. + + + + + + + + named + + + identifier + 4SqFwBZt8U1g2unjcrqmJoi1Lb6q + token + + + + + normal + + + diff --git a/tests/golden/fixtures/filters/symbol-type/private-see-below.yml b/tests/golden/fixtures/filters/symbol-type/private-see-below.yml new file mode 100644 index 00000000000..f95ab289ba3 --- /dev/null +++ b/tests/golden/fixtures/filters/symbol-type/private-see-below.yml @@ -0,0 +1,4 @@ +# A private member marked `@seebelow` would get a page reachable only +# through the private members that `extract-private: false` hides, so +# the command is ignored and the member stays a dependency. +extract-private: false From c756cef01ff10437066816b054da2369f0320e17 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Fri, 18 Sep 2026 12:48:53 +0200 Subject: [PATCH 2/5] fix: an excluded file keeps the commands written inside it A file excluded with `exclude` or `exclude-patterns` lost every symbol in it, commands and all, so `@implementationdefined` on a type in an excluded header did nothing: a function returning that type, for instance, was documented with the real name of the type instead of the placeholder. We now let the command win, as it says how the symbol should appear when something else names it, which is more specific than excluding a file. Only a command written on the declaration counts, though: a glob, or a mode inherited from a parent, doesn't speak for that declaration, so a symbol it covers in an excluded file is still dropped. I've added tests. Note that a file which was never in `input` is another matter: a command in a third-party header speaks for that library, not for the project being documented, so a symbol from such a header is still dropped. --- src/mrdocs/AST/ASTVisitor.cpp | 100 ++++++++++++------ src/mrdocs/AST/ASTVisitor.hpp | 24 +++++ ...uded-directory-implementation-defined.adoc | 37 +++++++ ...luded-directory-implementation-defined.cpp | 7 ++ ...luded-directory-implementation-defined.yml | 6 ++ .../excluded-directory-see-below-glob.adoc | 37 +++++++ .../excluded-directory-see-below-glob.cpp | 7 ++ .../excluded-directory-see-below-glob.yml | 9 ++ .../file/excluded-directory/handle.hpp | 7 ++ .../filters/file/excluded-directory/token.hpp | 9 ++ .../file/excluded-implementation-defined.adoc | 37 +++++++ .../file/excluded-implementation-defined.cpp | 7 ++ .../file/excluded-implementation-defined.yml | 7 ++ .../fixtures/filters/file/excluded-token.hpp | 9 ++ .../outside-input-implementation-defined.adoc | 37 +++++++ .../outside-input-implementation-defined.cpp | 7 ++ .../outside-input-implementation-defined.yml | 7 ++ .../filters/file/outside-input/token.hpp | 9 ++ 18 files changed, 331 insertions(+), 32 deletions(-) create mode 100644 tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.adoc create mode 100644 tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.cpp create mode 100644 tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.yml create mode 100644 tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.adoc create mode 100644 tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.cpp create mode 100644 tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.yml create mode 100644 tests/golden/fixtures/filters/file/excluded-directory/handle.hpp create mode 100644 tests/golden/fixtures/filters/file/excluded-directory/token.hpp create mode 100644 tests/golden/fixtures/filters/file/excluded-implementation-defined.adoc create mode 100644 tests/golden/fixtures/filters/file/excluded-implementation-defined.cpp create mode 100644 tests/golden/fixtures/filters/file/excluded-implementation-defined.yml create mode 100644 tests/golden/fixtures/filters/file/excluded-token.hpp create mode 100644 tests/golden/fixtures/filters/file/outside-input-implementation-defined.adoc create mode 100644 tests/golden/fixtures/filters/file/outside-input-implementation-defined.cpp create mode 100644 tests/golden/fixtures/filters/file/outside-input-implementation-defined.yml create mode 100644 tests/golden/fixtures/filters/file/outside-input/token.hpp diff --git a/src/mrdocs/AST/ASTVisitor.cpp b/src/mrdocs/AST/ASTVisitor.cpp index b2f13015fe1..1644b8a4685 100644 --- a/src/mrdocs/AST/ASTVisitor.cpp +++ b/src/mrdocs/AST/ASTVisitor.cpp @@ -3380,7 +3380,25 @@ checkFilters( // to its location. This checks if it's in one of the // input directories, if it matches the file patterns, // and it's not in an excluded file. - MRDOCS_CHECK_OR(checkFileFilters(D), ExtractionMode::Dependency); + // + // An `@implementationdefined` or `@seebelow` command on a symbol in + // an excluded file overrides the file exclusion, because it is a + // more specific statement. A glob or a mode inherited from a parent + // does not, and neither does a command in a third-party header, + // which speaks for that library. + // See tests/golden/fixtures/filters/file/excluded-implementation-defined.cpp + bool const commanded = + (Cat == ExtractionMode::ImplementationDefined || + Cat == ExtractionMode::SeeBelow) && + checkDocCommentExtractionFlag(D).has_value(); + if (commanded) + { + MRDOCS_CHECK_OR(checkInputFilters(D), ExtractionMode::Dependency); + } + else + { + MRDOCS_CHECK_OR(checkFileFilters(D), ExtractionMode::Dependency); + } return Cat; } @@ -3453,6 +3471,13 @@ checkFileFilters(clang::Decl const* D) bool ASTVisitor:: checkFileFilters(std::string_view const symbolPath) const +{ + return isInputFile(symbolPath) && !isExcludedFile(symbolPath); +} + +bool +ASTVisitor:: +isInputFile(std::string_view const filePath) const { // Inclusion is generous: a file counts as being inside an input // directory when its path matches as written or by its real @@ -3466,71 +3491,82 @@ checkFileFilters(std::string_view const symbolPath) const std::ranges::any_of(config_.input, [&](std::string const& inputDir) { - return files::isResolvedSubpathOf(symbolPath, inputDir); + return files::isResolvedSubpathOf(filePath, inputDir); }), false); } else { - // Resolve the symbol's parent lazily: the filesystem lookup only + // Resolve the file's parent lazily: the filesystem lookup only // happens when a literal match fails, so a tree with no symlinks // pays no extra cost. - std::string_view const symbolParentDir = files::getParentDir(symbolPath); - Optional symbolParentDirReal; + std::string_view const fileParentDir = files::getParentDir(filePath); + Optional fileParentDirReal; auto parentDirReal = [&]() -> std::string const& { - if (!symbolParentDirReal) + if (!fileParentDirReal) { - symbolParentDirReal = files::makeRealPath(symbolParentDir); + fileParentDirReal = files::makeRealPath(fileParentDir); } - return *symbolParentDirReal; + return *fileParentDirReal; }; MRDOCS_CHECK_OR( config_.input.empty() || std::ranges::any_of(config_.input, [&](std::string const& inputDir) { - return inputDir == symbolParentDir + return inputDir == fileParentDir || files::makeRealPath(inputDir) == parentDirReal(); }), false); } - // Exclusion is strict: match the written path only, so a symlinked alias - // of an excluded file is not excluded. - MRDOCS_CHECK_OR( - config_.exclude.empty() || - std::ranges::none_of(config_.exclude, - [&](std::string const& excludeDir) - { - return files::isSubpathOf(symbolPath, excludeDir); - }), - false); - - // Don't extract declarations that fail the exclude pattern filter - MRDOCS_CHECK_OR( - config_.excludePatterns.empty() || - std::ranges::none_of(config_.excludePatterns, - [&](PathGlobPattern const& pattern) - { - return pattern.match(symbolPath); - }), - false); - // Don't extract declarations that fail the file pattern filter MRDOCS_CHECK_OR( config_.filePatterns.empty() || std::ranges::any_of(config_.filePatterns, - [symbolFilename = files::getFileName(symbolPath)] + [fileName = files::getFileName(filePath)] (PathGlobPattern const& pattern) { - return pattern.match(symbolFilename); + return pattern.match(fileName); }), false); return true; } +bool +ASTVisitor:: +isExcludedFile(std::string_view const filePath) const +{ + // Exclusion is strict: match the written path only, so a symlinked alias + // of an excluded file is not excluded. + bool const inExcludedDirectory = + std::ranges::any_of(config_.exclude, + [&](std::string const& excludeDir) + { + return files::isSubpathOf(filePath, excludeDir); + }); + bool const matchesExcludePattern = + std::ranges::any_of(config_.excludePatterns, + [&](PathGlobPattern const& pattern) + { + return pattern.match(filePath); + }); + return inExcludedDirectory || matchesExcludePattern; +} + +bool +ASTVisitor:: +checkInputFilters(clang::Decl const* D) +{ + MRDOCS_SYMBOL_TRACE(D, context_); + + FileInfo const* fileInfo = findFileInfo(D); + MRDOCS_CHECK_OR(fileInfo, false); + return isInputFile(fileInfo->full_path); +} + std::optional ASTVisitor:: checkDocCommentExtractionFlag(clang::Decl const* D) diff --git a/src/mrdocs/AST/ASTVisitor.hpp b/src/mrdocs/AST/ASTVisitor.hpp index eddf5389802..f13a7dfd259 100644 --- a/src/mrdocs/AST/ASTVisitor.hpp +++ b/src/mrdocs/AST/ASTVisitor.hpp @@ -1173,6 +1173,30 @@ class ASTVisitor bool checkFileFilters(std::string_view symbolPath) const; + /* Check the input filters for a declaration + + True when the file the declaration comes from is one of the + inputs, whatever the exclusions say. + */ + bool + checkInputFilters(clang::Decl const* D); + + /* Check whether a file is one of the inputs + + True when the file lies in an input directory and matches the + file patterns, whatever the exclusions say. + */ + bool + isInputFile(std::string_view filePath) const; + + /* Check whether the configuration excludes a file + + True when the file lies in an excluded directory or matches one + of the exclude patterns. + */ + bool + isExcludedFile(std::string_view filePath) const; + /* Check all symbol filters for a declaration @param D The declaration to check diff --git a/tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.adoc b/tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.adoc new file mode 100644 index 00000000000..be252dceef6 --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.adoc @@ -0,0 +1,37 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#acquire[`acquire`] +| Acquire the widget. +|=== + + +[#acquire] +== acquire + +Acquire the widget. + +=== Synopsis + +Declared in `<excluded‐directory‐implementation‐defined.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +/* implementation-defined */ +acquire(); +---- + +=== Return Value + +A token that releases the widget when destroyed. + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.cpp b/tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.cpp new file mode 100644 index 00000000000..99616f667c8 --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.cpp @@ -0,0 +1,7 @@ +#include "excluded-directory/token.hpp" + +/** Acquire the widget. + + @return A token that releases the widget when destroyed. +*/ +detail::token acquire(); diff --git a/tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.yml b/tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.yml new file mode 100644 index 00000000000..211a0eda52d --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-directory-implementation-defined.yml @@ -0,0 +1,6 @@ +# The directory of the header is excluded, but the mark inside the header +# still decides how the type renders where the public function mentions +# it, as it does for a header excluded by a pattern. +exclude: + - excluded-directory +generator: adoc diff --git a/tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.adoc b/tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.adoc new file mode 100644 index 00000000000..2ca53a2326c --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.adoc @@ -0,0 +1,37 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#open[`open`] +| Open the widget. +|=== + + +[#open] +== open + +Open the widget. + +=== Synopsis + +Declared in `<excluded‐directory‐see‐below‐glob.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +detail::handle +open(); +---- + +=== Return Value + +A handle that closes the widget when destroyed. + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.cpp b/tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.cpp new file mode 100644 index 00000000000..2464cfc1d48 --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.cpp @@ -0,0 +1,7 @@ +#include "excluded-directory/handle.hpp" + +/** Open the widget. + + @return A handle that closes the widget when destroyed. +*/ +detail::handle open(); diff --git a/tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.yml b/tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.yml new file mode 100644 index 00000000000..913e04825de --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-directory-see-below-glob.yml @@ -0,0 +1,9 @@ +# The glob matches the type in the excluded directory, but only a command +# written on the declaration overrides the exclusion of its file, so the +# type stays a dependency: it has no page, and the signature prints its +# name. +see-below: + - 'detail::**' +exclude: + - excluded-directory +generator: adoc diff --git a/tests/golden/fixtures/filters/file/excluded-directory/handle.hpp b/tests/golden/fixtures/filters/file/excluded-directory/handle.hpp new file mode 100644 index 00000000000..afd4e9eaeeb --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-directory/handle.hpp @@ -0,0 +1,7 @@ +namespace detail { + +/** A handle the caller can hold but not name. +*/ +struct handle {}; + +} // namespace detail diff --git a/tests/golden/fixtures/filters/file/excluded-directory/token.hpp b/tests/golden/fixtures/filters/file/excluded-directory/token.hpp new file mode 100644 index 00000000000..5deca7dc72d --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-directory/token.hpp @@ -0,0 +1,9 @@ +namespace detail { + +/** A tag the caller can hold but not name. + + @implementationdefined +*/ +struct token {}; + +} // namespace detail diff --git a/tests/golden/fixtures/filters/file/excluded-implementation-defined.adoc b/tests/golden/fixtures/filters/file/excluded-implementation-defined.adoc new file mode 100644 index 00000000000..68fb8a1e884 --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-implementation-defined.adoc @@ -0,0 +1,37 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#acquire[`acquire`] +| Acquire the widget. +|=== + + +[#acquire] +== acquire + +Acquire the widget. + +=== Synopsis + +Declared in `<excluded‐implementation‐defined.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +/* implementation-defined */ +acquire(); +---- + +=== Return Value + +A token that releases the widget when destroyed. + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/tests/golden/fixtures/filters/file/excluded-implementation-defined.cpp b/tests/golden/fixtures/filters/file/excluded-implementation-defined.cpp new file mode 100644 index 00000000000..2d3162d2329 --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-implementation-defined.cpp @@ -0,0 +1,7 @@ +#include "excluded-token.hpp" + +/** Acquire the widget. + + @return A token that releases the widget when destroyed. +*/ +detail::token acquire(); diff --git a/tests/golden/fixtures/filters/file/excluded-implementation-defined.yml b/tests/golden/fixtures/filters/file/excluded-implementation-defined.yml new file mode 100644 index 00000000000..894be7a37e0 --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-implementation-defined.yml @@ -0,0 +1,7 @@ +# The header is excluded, but the mark inside it still decides how the +# type renders where the public function mentions it. The rendered page +# is what shows that: at namespace scope the excluded parent is pruned, +# so the XML tree never reaches the marked type. +exclude-patterns: + - excluded-token.hpp +generator: adoc diff --git a/tests/golden/fixtures/filters/file/excluded-token.hpp b/tests/golden/fixtures/filters/file/excluded-token.hpp new file mode 100644 index 00000000000..5deca7dc72d --- /dev/null +++ b/tests/golden/fixtures/filters/file/excluded-token.hpp @@ -0,0 +1,9 @@ +namespace detail { + +/** A tag the caller can hold but not name. + + @implementationdefined +*/ +struct token {}; + +} // namespace detail diff --git a/tests/golden/fixtures/filters/file/outside-input-implementation-defined.adoc b/tests/golden/fixtures/filters/file/outside-input-implementation-defined.adoc new file mode 100644 index 00000000000..eb0a07159cd --- /dev/null +++ b/tests/golden/fixtures/filters/file/outside-input-implementation-defined.adoc @@ -0,0 +1,37 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Functions + +[cols="1,4"] +|=== +| Name| Description +| link:#acquire[`acquire`] +| Acquire the widget. +|=== + + +[#acquire] +== acquire + +Acquire the widget. + +=== Synopsis + +Declared in `<outside‐input‐implementation‐defined.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +vendor::token +acquire(); +---- + +=== Return Value + +A token that releases the widget when destroyed. + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/tests/golden/fixtures/filters/file/outside-input-implementation-defined.cpp b/tests/golden/fixtures/filters/file/outside-input-implementation-defined.cpp new file mode 100644 index 00000000000..0885ba19f1a --- /dev/null +++ b/tests/golden/fixtures/filters/file/outside-input-implementation-defined.cpp @@ -0,0 +1,7 @@ +#include "outside-input/token.hpp" + +/** Acquire the widget. + + @return A token that releases the widget when destroyed. +*/ +vendor::token acquire(); diff --git a/tests/golden/fixtures/filters/file/outside-input-implementation-defined.yml b/tests/golden/fixtures/filters/file/outside-input-implementation-defined.yml new file mode 100644 index 00000000000..9d3290f08cc --- /dev/null +++ b/tests/golden/fixtures/filters/file/outside-input-implementation-defined.yml @@ -0,0 +1,7 @@ +# The header is in a subdirectory, which is not an input when `recursive` +# is off. A mark in a file outside the inputs speaks for another library, +# so the type is still dropped, and the signature prints its name. +input: + - '.' +recursive: false +generator: adoc diff --git a/tests/golden/fixtures/filters/file/outside-input/token.hpp b/tests/golden/fixtures/filters/file/outside-input/token.hpp new file mode 100644 index 00000000000..2e34165029a --- /dev/null +++ b/tests/golden/fixtures/filters/file/outside-input/token.hpp @@ -0,0 +1,9 @@ +namespace vendor { + +/** A tag the caller can hold but not name. + + @implementationdefined +*/ +struct token {}; + +} // namespace vendor From ae6d1c64e70bb72edf1598381787fda358fcdfa1 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Wed, 23 Sep 2026 08:54:19 +0200 Subject: [PATCH 3/5] fix: dom::Function::impl doesn't name a private alias The documented accessor `dom::Function::impl` returned a reference to `impl_type`, a private alias. The alias has no page, so a reader of the signature met a name that led nowhere. This makes the alias public and documents it, as `dom::Array` and `dom::Object` already do. --- libs/dom/include/mrdocs/Dom/Function.hpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/libs/dom/include/mrdocs/Dom/Function.hpp b/libs/dom/include/mrdocs/Dom/Function.hpp index b3fce66be6d..6f52bd08981 100644 --- a/libs/dom/include/mrdocs/Dom/Function.hpp +++ b/libs/dom/include/mrdocs/Dom/Function.hpp @@ -193,8 +193,7 @@ class DefaultFunctionImpl; class MRDOCS_DOM_DECL Function { - using impl_type = std::shared_ptr; - impl_type impl_; + std::shared_ptr impl_; explicit Function( @@ -205,6 +204,10 @@ class MRDOCS_DOM_DECL } public: + /** The implementation type. + */ + using impl_type = std::shared_ptr; + /** Destructor. */ ~Function(); From 97851a06ab8c26948ccc8a4e9233510819dc68ff Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Fri, 18 Sep 2026 16:14:34 +0200 Subject: [PATCH 4/5] feat: warn when a documented declaration names a filtered symbol A symbol removed by the filters has no page, so a declaration naming it sends the reader nowhere. We now report such names in return types, parameters, public bases, aliases, variables and data members, and in the type arguments of a template. A filtered template named with arguments, such as `detail::impl`, is reported as well. The report goes through the same list as the other `warn-*` diagnostics, so it's printed under the same file, line and column header and counts toward `max-errors`. Only symbols declared in the input files are reported, since a symbol from anywhere else belongs to another library. A member reached through a specialization of its enclosing template, such as `inner` in `outer::inner`, isn't reported either, since the primary template documents it. --- src/mrdocs/AST/ASTVisitor.cpp | 65 +-------- src/mrdocs/AST/ASTVisitor.hpp | 8 -- .../Finalizers/DocCommentFinalizer.cpp | 134 ++++++++++++++++++ .../Finalizers/DocCommentFinalizer.hpp | 33 +++++ src/mrdocs/Support/InputFiles.cpp | 80 +++++++++++ src/mrdocs/Support/InputFiles.hpp | 35 +++++ tests/CMakeLists.txt | 62 ++++++++ .../filtered-in-public-api/input.cpp | 66 +++++++++ .../filtered-in-public-api/mrdocs.yml | 8 ++ .../filtered-not-reported/input.cpp | 54 +++++++ .../filtered-not-reported/mrdocs.yml | 7 + .../filtered-outside-inputs/api/input.cpp | 7 + .../excluded-input.yml | 12 ++ .../filtered-outside-inputs/mrdocs.yml | 8 ++ .../vendor/vendored.hpp | 5 + 15 files changed, 514 insertions(+), 70 deletions(-) create mode 100644 src/mrdocs/Support/InputFiles.cpp create mode 100644 src/mrdocs/Support/InputFiles.hpp create mode 100644 tests/diagnostics/filtered-in-public-api/input.cpp create mode 100644 tests/diagnostics/filtered-in-public-api/mrdocs.yml create mode 100644 tests/diagnostics/filtered-not-reported/input.cpp create mode 100644 tests/diagnostics/filtered-not-reported/mrdocs.yml create mode 100644 tests/diagnostics/filtered-outside-inputs/api/input.cpp create mode 100644 tests/diagnostics/filtered-outside-inputs/excluded-input.yml create mode 100644 tests/diagnostics/filtered-outside-inputs/mrdocs.yml create mode 100644 tests/diagnostics/filtered-outside-inputs/vendor/vendored.hpp diff --git a/src/mrdocs/AST/ASTVisitor.cpp b/src/mrdocs/AST/ASTVisitor.cpp index 1644b8a4685..16d2698a181 100644 --- a/src/mrdocs/AST/ASTVisitor.cpp +++ b/src/mrdocs/AST/ASTVisitor.cpp @@ -23,6 +23,7 @@ #include "TypeBuilder.hpp" #include #include +#include #include #include #include @@ -3472,67 +3473,7 @@ bool ASTVisitor:: checkFileFilters(std::string_view const symbolPath) const { - return isInputFile(symbolPath) && !isExcludedFile(symbolPath); -} - -bool -ASTVisitor:: -isInputFile(std::string_view const filePath) const -{ - // Inclusion is generous: a file counts as being inside an input - // directory when its path matches as written or by its real - // (symlink-resolved) location. This recognizes files reached through a - // symlinked directory without dropping anything that - // already matched as written. - if (config_.recursive) - { - MRDOCS_CHECK_OR( - config_.input.empty() || - std::ranges::any_of(config_.input, - [&](std::string const& inputDir) - { - return files::isResolvedSubpathOf(filePath, inputDir); - }), - false); - } - else - { - // Resolve the file's parent lazily: the filesystem lookup only - // happens when a literal match fails, so a tree with no symlinks - // pays no extra cost. - std::string_view const fileParentDir = files::getParentDir(filePath); - Optional fileParentDirReal; - auto parentDirReal = [&]() -> std::string const& - { - if (!fileParentDirReal) - { - fileParentDirReal = files::makeRealPath(fileParentDir); - } - return *fileParentDirReal; - }; - MRDOCS_CHECK_OR( - config_.input.empty() || - std::ranges::any_of(config_.input, - [&](std::string const& inputDir) - { - return inputDir == fileParentDir - || files::makeRealPath(inputDir) == parentDirReal(); - }), - false); - } - - // Don't extract declarations that fail the file pattern filter - MRDOCS_CHECK_OR( - config_.filePatterns.empty() || - std::ranges::any_of(config_.filePatterns, - [fileName = files::getFileName(filePath)] - (PathGlobPattern const& pattern) - { - return pattern.match(fileName); - }), - false); - - return true; + return isInputFile(config_, symbolPath) && !isExcludedFile(symbolPath); } bool @@ -3564,7 +3505,7 @@ checkInputFilters(clang::Decl const* D) FileInfo const* fileInfo = findFileInfo(D); MRDOCS_CHECK_OR(fileInfo, false); - return isInputFile(fileInfo->full_path); + return isInputFile(config_, fileInfo->full_path); } std::optional diff --git a/src/mrdocs/AST/ASTVisitor.hpp b/src/mrdocs/AST/ASTVisitor.hpp index f13a7dfd259..0e099a06898 100644 --- a/src/mrdocs/AST/ASTVisitor.hpp +++ b/src/mrdocs/AST/ASTVisitor.hpp @@ -1181,14 +1181,6 @@ class ASTVisitor bool checkInputFilters(clang::Decl const* D); - /* Check whether a file is one of the inputs - - True when the file lies in an input directory and matches the - file patterns, whatever the exclusions say. - */ - bool - isInputFile(std::string_view filePath) const; - /* Check whether the configuration excludes a file True when the file lies in an excluded directory or matches one diff --git a/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp b/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp index 7a64ffd34c4..566d769e3ae 100644 --- a/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp +++ b/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -1574,6 +1575,7 @@ DocCommentFinalizer::emitWarnings() warnDocErrors(); warnNoParamDocs(); warnUnnamedParams(); + warnFilteredInPublicApi(); auto const level = !config_.warnAsError ? report::Level::warn : @@ -2128,4 +2130,136 @@ warnUnnamedParams(FunctionSymbol const& I) } } +namespace { + +bool +isFilteredProjectSymbol(Symbol const& I, Config const& config) +{ + MRDOCS_CHECK_OR(I.Extraction == ExtractionMode::Dependency, false); + Optional const loc = getPrimaryLocation(I); + MRDOCS_CHECK_OR(loc, false); + return isInputFile(config, loc->FullPath); +} + +} // (anon) + +void +DocCommentFinalizer:: +warnFilteredInPublicApi() +{ + for (std::unique_ptr const& I : corpus_.info_) + { + MRDOCS_ASSERT(I); + if (I->Extraction == ExtractionMode::Regular) + { + warnFilteredInPublicApi(*I); + } + } +} + +void +DocCommentFinalizer:: +warnFilteredInPublicApi(Symbol const& I) +{ + visit(I, [&](SymbolTy const& sym) + { + if constexpr (SymbolTy::isFunction()) + { + warnIfFiltered(sym, sym.ReturnType); + for (Param const& param : sym.Params) + { + warnIfFiltered(sym, param.Type); + } + } + else if constexpr (SymbolTy::isRecord()) + { + for (BaseInfo const& base : sym.Bases) + { + if (base.Access == AccessKind::Public) + { + warnIfFiltered(sym, base.Type); + } + } + } + else if constexpr (SymbolTy::isTypedef() || SymbolTy::isVariable()) + { + warnIfFiltered(sym, sym.Type); + } + }); +} + +void +DocCommentFinalizer:: +warnIfFiltered(Symbol const& referrer, Polymorphic const& type) +{ + MRDOCS_CHECK_OR(type); + MRDOCS_ASSERT(!type.valueless_after_move()); + + // The specifiers around the type say nothing about what is named: + // `detail::token const&` names the same symbol as `detail::token`. + Polymorphic const& named = innermostType(type); + MRDOCS_CHECK_OR(named); + MRDOCS_CHECK_OR(named->isNamed()); + MRDOCS_CHECK_OR(named->asNamed().Name); + warnIfFiltered(referrer, *named->asNamed().Name); +} + +void +DocCommentFinalizer:: +warnIfFiltered(Symbol const& referrer, Name const& name) +{ + // The `id` of a specialization, such as `detail::impl`, is its + // primary template, which is reported like any other name. A member + // reached through a specialization of its enclosing template, such + // as `inner` in `outer::inner`, is not: the primary template + // documents it. + bool reachedThroughSpecialization = false; + for (Name const* current = &name; + current; + current = current->Prefix ? &**current->Prefix : nullptr) + { + if (current->isSpecialization()) + { + reachedThroughSpecialization |= current != &name; + warnIfFiltered(referrer, current->asSpecialization().TemplateArgs); + } + } + if (!reachedThroughSpecialization) + { + warnIfFiltered(referrer, name.id); + } +} + +void +DocCommentFinalizer:: +warnIfFiltered( + Symbol const& referrer, + std::vector> const& args) +{ + for (Polymorphic const& arg : args) + { + if (!arg.valueless_after_move() && arg->Kind == TArgKind::Type) + { + warnIfFiltered(referrer, arg->asType().Type); + } + } +} + +void +DocCommentFinalizer:: +warnIfFiltered(Symbol const& referrer, SymbolID const& id) +{ + Symbol const* target = + id != SymbolID::invalid ? corpus_.find(id) : nullptr; + Optional const loc = getPrimaryLocation(referrer); + if (target && loc && isFilteredProjectSymbol(*target, config_)) + { + this->warn( + *loc, + "'{}' names '{}', which the filters exclude", + corpus_.Corpus::qualifiedName(referrer), + corpus_.Corpus::qualifiedName(*target)); + } +} + } // mrdocs diff --git a/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.hpp b/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.hpp index 35b7ca90005..d49971b5cab 100644 --- a/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.hpp +++ b/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.hpp @@ -375,6 +375,39 @@ class DocCommentFinalizer { void warnUnnamedParams(FunctionSymbol const& I); + + /* Report the filtered symbols named by the documented declarations + + The filters leave a symbol without a page, so a signature naming + it sends the reader nowhere. The author can widen the filters, + change the signature, or mark the symbol `@implementationdefined`, + which prints a placeholder in place of the name and ends the + report. + + Only the project's own symbols are reported, which is to say those + declared in one of the inputs. A symbol from anywhere else, such as + a standard library installed under `source-root`, belongs to + someone else and gives the author nothing to answer. + */ + void + warnFilteredInPublicApi(); + + void + warnFilteredInPublicApi(Symbol const& I); + + void + warnIfFiltered(Symbol const& referrer, Polymorphic const& type); + + void + warnIfFiltered(Symbol const& referrer, Name const& name); + + void + warnIfFiltered( + Symbol const& referrer, + std::vector> const& args); + + void + warnIfFiltered(Symbol const& referrer, SymbolID const& id); }; } // mrdocs diff --git a/src/mrdocs/Support/InputFiles.cpp b/src/mrdocs/Support/InputFiles.cpp new file mode 100644 index 00000000000..2a07333dd34 --- /dev/null +++ b/src/mrdocs/Support/InputFiles.cpp @@ -0,0 +1,80 @@ +// +// 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 "InputFiles.hpp" +#include +#include +#include +#include +#include +#include + +namespace mrdocs { + +bool +isInputFile(Config const& config, std::string_view const filePath) +{ + // Inclusion is generous: a file counts as being inside an input + // directory when its path matches as written or by its real + // (symlink-resolved) location. This recognizes files reached through a + // symlinked directory without dropping anything that + // already matched as written. + if (config.recursive) + { + MRDOCS_CHECK_OR( + config.input.empty() || + std::ranges::any_of(config.input, + [&](std::string const& inputDir) + { + return files::isResolvedSubpathOf(filePath, inputDir); + }), + false); + } + else + { + // Resolve the file's parent lazily: the filesystem lookup only + // happens when a literal match fails, so a tree with no symlinks + // pays no extra cost. + std::string_view const fileParentDir = files::getParentDir(filePath); + Optional fileParentDirReal; + auto parentDirReal = [&]() -> std::string const& + { + if (!fileParentDirReal) + { + fileParentDirReal = files::makeRealPath(fileParentDir); + } + return *fileParentDirReal; + }; + MRDOCS_CHECK_OR( + config.input.empty() || + std::ranges::any_of(config.input, + [&](std::string const& inputDir) + { + return inputDir == fileParentDir + || files::makeRealPath(inputDir) == parentDirReal(); + }), + false); + } + + // Don't extract declarations that fail the file pattern filter + MRDOCS_CHECK_OR( + config.filePatterns.empty() || + std::ranges::any_of(config.filePatterns, + [fileName = files::getFileName(filePath)] + (PathGlobPattern const& pattern) + { + return pattern.match(fileName); + }), + false); + + return true; +} + +} // mrdocs diff --git a/src/mrdocs/Support/InputFiles.hpp b/src/mrdocs/Support/InputFiles.hpp new file mode 100644 index 00000000000..24be5cedad5 --- /dev/null +++ b/src/mrdocs/Support/InputFiles.hpp @@ -0,0 +1,35 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +#ifndef MRDOCS_LIB_SUPPORT_INPUTFILES_HPP +#define MRDOCS_LIB_SUPPORT_INPUTFILES_HPP + +#include +#include + +namespace mrdocs { + +/** Determine whether a file is one of the inputs. + + A file is one of the inputs when it lies in an `input` directory and + matches the `file-patterns`. The exclusions play no part: an excluded + file is still one of the author's own, where a file outside the inputs + belongs to someone else, even when it sits under `source-root`. + + @param config The configuration naming the inputs. + @param filePath The absolute path of the file. + @return Whether the file is one of the inputs. +*/ +bool +isInputFile(Config const& config, std::string_view filePath); + +} // mrdocs + +#endif // MRDOCS_LIB_SUPPORT_INPUTFILES_HPP diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 33af7d5dd9b..151586f8930 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -28,3 +28,65 @@ add_test(NAME mrdocs-self-doc --log-level=debug $<$:--warn-as-error=true> ) + +# A warning is not a generated file, so the golden harness cannot see it. +# The input names a filtered symbol in each position the warning covers, +# and nothing else in it emits a warning: with the warning on, the run +# fails under --warn-as-error, and with it off the run is clean. +set(MRDOCS_FILTERED_API_DIR "${CMAKE_CURRENT_SOURCE_DIR}/diagnostics/filtered-in-public-api") +set(MRDOCS_FILTERED_API_COMMAND + mrdocs + "--config=${MRDOCS_FILTERED_API_DIR}/mrdocs.yml" + ${MRDOCS_BUILTIN_DIR_ARGS} + --generator=noop + --warn-as-error) +add_test(NAME mrdocs-warn-filtered-in-public-api + COMMAND ${MRDOCS_FILTERED_API_COMMAND}) +set_tests_properties(mrdocs-warn-filtered-in-public-api PROPERTIES WILL_FAIL TRUE) + +add_test(NAME mrdocs-ignore-filtered-outside-inputs + COMMAND mrdocs + "--config=${CMAKE_CURRENT_SOURCE_DIR}/diagnostics/filtered-outside-inputs/mrdocs.yml" + ${MRDOCS_BUILTIN_DIR_ARGS} + --generator=noop + --warn-as-error) + +# The failure above is satisfied by any one warning, so each position is +# also checked on its own, as the declaration in the input that names a +# filtered symbol there and the symbol it names. +function(mrdocs_expect_filtered_in_public_api position referrer target) + add_test(NAME mrdocs-warn-filtered-${position} + COMMAND ${MRDOCS_FILTERED_API_COMMAND}) + set_tests_properties(mrdocs-warn-filtered-${position} PROPERTIES + PASS_REGULAR_EXPRESSION "'${referrer}' names '${target}'") +endfunction() +mrdocs_expect_filtered_in_public_api(base-class T detail::my_shame) +mrdocs_expect_filtered_in_public_api(return-type publicFunction detail::my_shame) +mrdocs_expect_filtered_in_public_api(parameter takeShame detail::my_shame) +mrdocs_expect_filtered_in_public_api(alias shame_alias detail::my_shame) +mrdocs_expect_filtered_in_public_api(variable shameVariable detail::my_shame) +mrdocs_expect_filtered_in_public_api(data-member holder::member detail::my_shame) +mrdocs_expect_filtered_in_public_api(template-argument boxedShame detail::my_shame) +mrdocs_expect_filtered_in_public_api(specialization makeImpl detail::impl) + +# The positions where a filtered symbol is named but must not be reported: +# a private base, a symbol marked `@implementationdefined`, a +# specialization of a documented template and a member reached through +# one. +add_test(NAME mrdocs-unreported-filtered-in-public-api + COMMAND mrdocs + "--config=${CMAKE_CURRENT_SOURCE_DIR}/diagnostics/filtered-not-reported/mrdocs.yml" + ${MRDOCS_BUILTIN_DIR_ARGS} + --generator=noop + --warn-as-error) + +# The twin of mrdocs-ignore-filtered-outside-inputs: the same input, with +# the vendor directory among the inputs but excluded, is reported. +add_test(NAME mrdocs-warn-filtered-excluded-input + COMMAND mrdocs + "--config=${CMAKE_CURRENT_SOURCE_DIR}/diagnostics/filtered-outside-inputs/excluded-input.yml" + ${MRDOCS_BUILTIN_DIR_ARGS} + --generator=noop + --warn-as-error) +set_tests_properties(mrdocs-warn-filtered-excluded-input PROPERTIES + PASS_REGULAR_EXPRESSION "'makeWidget' names 'vendor::widget'") diff --git a/tests/diagnostics/filtered-in-public-api/input.cpp b/tests/diagnostics/filtered-in-public-api/input.cpp new file mode 100644 index 00000000000..94725e343b5 --- /dev/null +++ b/tests/diagnostics/filtered-in-public-api/input.cpp @@ -0,0 +1,66 @@ +namespace detail { + +/** A helper the filters keep out of the documentation. +*/ +struct my_shame {}; + +/** A template the filters keep out of the documentation. + + @tparam U The type the template is about. +*/ +template +struct impl {}; + +} // namespace detail + +/** A documented template, to give a filtered type as an argument. + + @tparam U The type held. +*/ +template +struct box {}; + +/** A public class deriving from a filtered base. +*/ +struct T : detail::my_shame {}; + +/** A public function returning a filtered type. + + @return Something the reader cannot follow. +*/ +detail::my_shame publicFunction(); + +/** A public function taking a filtered type. + + @param shame Something the reader cannot follow. +*/ +void takeShame(detail::my_shame const& shame); + +/** An alias of a filtered type. +*/ +using shame_alias = detail::my_shame; + +/** A variable of a filtered type. +*/ +extern detail::my_shame shameVariable; + +/** A class with a data member of a filtered type. +*/ +struct holder +{ + /** A data member of a filtered type. + */ + detail::my_shame member; +}; + +/** A public function returning a documented template of a filtered type. + + @return Something the reader cannot follow. +*/ +box boxedShame(); + +/** A public function returning a specialization of a filtered template. + + @return Something the reader cannot follow. +*/ +detail::impl makeImpl(); diff --git a/tests/diagnostics/filtered-in-public-api/mrdocs.yml b/tests/diagnostics/filtered-in-public-api/mrdocs.yml new file mode 100644 index 00000000000..e692b0212cc --- /dev/null +++ b/tests/diagnostics/filtered-in-public-api/mrdocs.yml @@ -0,0 +1,8 @@ +# The situation the warning is about: `detail` is filtered out, and the +# documented API names a symbol from it anyway, in each of the positions +# the warning covers. +source-root: . +input: + - . +exclude-symbols: + - 'detail::**' diff --git a/tests/diagnostics/filtered-not-reported/input.cpp b/tests/diagnostics/filtered-not-reported/input.cpp new file mode 100644 index 00000000000..bc6590ce4bc --- /dev/null +++ b/tests/diagnostics/filtered-not-reported/input.cpp @@ -0,0 +1,54 @@ +namespace detail { + +/** A tag the caller can hold but not name. + + @implementationdefined +*/ +struct token {}; + +/** A helper the filters keep out of the documentation. +*/ +struct hidden {}; + +} // namespace detail + +/** A documented template. + + @tparam U The type held. +*/ +template +struct box {}; + +/** A documented template with a member. + + @tparam U The type the template is about. +*/ +template +struct outer +{ + /** A member of the template. + */ + struct inner {}; +}; + +/** A public class deriving privately from a filtered base. +*/ +struct derived : private detail::hidden {}; + +/** A public function returning a filtered type marked as such. + + @return A token that releases the widget when destroyed. +*/ +detail::token acquire(); + +/** A public function returning a specialization of a documented template. + + @return A box of `int`. +*/ +box makeBox(); + +/** A public function returning a member of a specialization. + + @return A member the primary template documents. +*/ +outer::inner makeInner(); diff --git a/tests/diagnostics/filtered-not-reported/mrdocs.yml b/tests/diagnostics/filtered-not-reported/mrdocs.yml new file mode 100644 index 00000000000..54c17505127 --- /dev/null +++ b/tests/diagnostics/filtered-not-reported/mrdocs.yml @@ -0,0 +1,7 @@ +# The documented API names filtered symbols only where the warning must +# stay quiet, so a run with the warning on is clean. +source-root: . +input: + - . +exclude-symbols: + - 'detail::**' diff --git a/tests/diagnostics/filtered-outside-inputs/api/input.cpp b/tests/diagnostics/filtered-outside-inputs/api/input.cpp new file mode 100644 index 00000000000..5e170e3789b --- /dev/null +++ b/tests/diagnostics/filtered-outside-inputs/api/input.cpp @@ -0,0 +1,7 @@ +#include + +/** A public function returning a type from outside the inputs. + + @return A value of a type another library documents. +*/ +vendor::widget makeWidget(); diff --git a/tests/diagnostics/filtered-outside-inputs/excluded-input.yml b/tests/diagnostics/filtered-outside-inputs/excluded-input.yml new file mode 100644 index 00000000000..971e19381f5 --- /dev/null +++ b/tests/diagnostics/filtered-outside-inputs/excluded-input.yml @@ -0,0 +1,12 @@ +# The twin of mrdocs.yml: the vendor directory is now one of the inputs, +# only excluded, so the same declaration is reported. That shows the +# vendor symbol reaches the corpus, and that its being outside the +# inputs is what keeps the other run quiet. +source-root: . +input: + - api + - vendor +exclude: + - vendor +includes: + - vendor diff --git a/tests/diagnostics/filtered-outside-inputs/mrdocs.yml b/tests/diagnostics/filtered-outside-inputs/mrdocs.yml new file mode 100644 index 00000000000..1e306329513 --- /dev/null +++ b/tests/diagnostics/filtered-outside-inputs/mrdocs.yml @@ -0,0 +1,8 @@ +# A documented declaration names a symbol that lives under `source-root` +# but outside the inputs, the way an installed or vendored header can. That +# symbol belongs to someone else, so naming it is not reported. +source-root: . +input: + - api +includes: + - vendor diff --git a/tests/diagnostics/filtered-outside-inputs/vendor/vendored.hpp b/tests/diagnostics/filtered-outside-inputs/vendor/vendored.hpp new file mode 100644 index 00000000000..b8bba1af145 --- /dev/null +++ b/tests/diagnostics/filtered-outside-inputs/vendor/vendored.hpp @@ -0,0 +1,5 @@ +namespace vendor { + +struct widget {}; + +} // namespace vendor From 15166a610a1198f370a0a279395deb1cf7f34f24 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Fri, 18 Sep 2026 16:33:49 +0200 Subject: [PATCH 5/5] feat: add the warn-if-filtered-in-public-api option This gives projects which deliberately use filtered symbols in their public API a way to silence the new warning in one place, instead of, e.g., marking each such symbol `@implementationdefined`. As for most of the other `warn-*` options, the default is `true`. Note that the golden fixtures set the option to `false`, as several of them exist precisely to exercise a filtered symbol reached from the documented API. --- .../ROOT/attachments/schemas/config/mrdocs.schema.json | 10 ++++++++++ docs/modules/ROOT/pages/configuration/diagnostics.adoc | 1 + src/mrdocs/ConfigOptions.json | 7 +++++++ src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp | 1 + tests/CMakeLists.txt | 3 +++ tests/golden/fixtures/mrdocs.yml | 3 +++ 6 files changed, 25 insertions(+) diff --git a/docs/modules/ROOT/attachments/schemas/config/mrdocs.schema.json b/docs/modules/ROOT/attachments/schemas/config/mrdocs.schema.json index 32f843ad226..106be968a35 100644 --- a/docs/modules/ROOT/attachments/schemas/config/mrdocs.schema.json +++ b/docs/modules/ROOT/attachments/schemas/config/mrdocs.schema.json @@ -780,6 +780,16 @@ "title": "Warn if documentation has errors", "type": "boolean" }, + "warn-if-filtered-in-public-api": { + "default": true, + "description": "When set to `true`, MrDocs outputs a warning message when a documented declaration names a symbol that the filters removed, such as a return type or a public base class from an excluded namespace. Note that marking the symbol with `@implementationdefined` avoids the warning. Only symbols declared in the input files are reported.", + "enum": [ + true, + false + ], + "title": "Warn if the documented API names a filtered symbol", + "type": "boolean" + }, "warn-if-undoc-enum-val": { "default": true, "description": "When set to `true`, MrDocs outputs a warning message if an enum value is not documented.", diff --git a/docs/modules/ROOT/pages/configuration/diagnostics.adoc b/docs/modules/ROOT/pages/configuration/diagnostics.adoc index 2b1b751553c..77b52be0c0a 100644 --- a/docs/modules/ROOT/pages/configuration/diagnostics.adoc +++ b/docs/modules/ROOT/pages/configuration/diagnostics.adoc @@ -11,6 +11,7 @@ The `warn-*` family treats common documentation problems as warnings, so a CI ru * xref:configuration/reference.adoc#warn-no-paramdoc_option[`warn-no-paramdoc`] and xref:configuration/reference.adoc#warn-unnamed-param_option[`warn-unnamed-param`] flag functions whose parameters are not documented or not named. * xref:configuration/reference.adoc#warn-if-undoc-enum-val_option[`warn-if-undoc-enum-val`] flags enumerators without doc comments. * xref:configuration/reference.adoc#warn-broken-ref_option[`warn-broken-ref`] flags `@ref` and `@see` targets that do not resolve. +* xref:configuration/reference.adoc#warn-if-filtered-in-public-api_option[`warn-if-filtered-in-public-api`] flags a documented declaration that names a symbol removed by the filters, which leaves the reader a name with no page behind it. Marking that symbol xref:commands/reference.adoc#cmd-implementation-defined[`@implementationdefined`] renders it as a placeholder and settles the warning. xref:configuration/reference.adoc#warn-as-error_option[`warn-as-error`] turns every warning into a failure, the usual CI setup once a project is past the bring-up phase. diff --git a/src/mrdocs/ConfigOptions.json b/src/mrdocs/ConfigOptions.json index 6534da121c5..a3798dcb245 100644 --- a/src/mrdocs/ConfigOptions.json +++ b/src/mrdocs/ConfigOptions.json @@ -665,6 +665,13 @@ "type": "bool", "default": false }, + { + "name": "warn-if-filtered-in-public-api", + "brief": "Warn if the documented API names a filtered symbol", + "details": "When set to `true`, MrDocs outputs a warning message when a documented declaration names a symbol that the filters removed, such as a return type or a public base class from an excluded namespace. Note that marking the symbol with `@implementationdefined` avoids the warning. Only symbols declared in the input files are reported.", + "type": "bool", + "default": true + }, { "name": "warn-if-undoc-enum-val", "brief": "Warn if enum values are not documented", diff --git a/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp b/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp index 566d769e3ae..fe099395de7 100644 --- a/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp +++ b/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp @@ -2147,6 +2147,7 @@ void DocCommentFinalizer:: warnFilteredInPublicApi() { + MRDOCS_CHECK_OR(config_.warnIfFilteredInPublicApi); for (std::unique_ptr const& I : corpus_.info_) { MRDOCS_ASSERT(I); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 151586f8930..ea36a0cd20f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -44,6 +44,9 @@ add_test(NAME mrdocs-warn-filtered-in-public-api COMMAND ${MRDOCS_FILTERED_API_COMMAND}) set_tests_properties(mrdocs-warn-filtered-in-public-api PROPERTIES WILL_FAIL TRUE) +add_test(NAME mrdocs-quiet-filtered-in-public-api + COMMAND ${MRDOCS_FILTERED_API_COMMAND} --warn-if-filtered-in-public-api=false) + add_test(NAME mrdocs-ignore-filtered-outside-inputs COMMAND mrdocs "--config=${CMAKE_CURRENT_SOURCE_DIR}/diagnostics/filtered-outside-inputs/mrdocs.yml" diff --git a/tests/golden/fixtures/mrdocs.yml b/tests/golden/fixtures/mrdocs.yml index 2fd10cdeb0f..06422f08862 100644 --- a/tests/golden/fixtures/mrdocs.yml +++ b/tests/golden/fixtures/mrdocs.yml @@ -12,5 +12,8 @@ warn-if-undocumented: false warn-no-paramdoc: false warn-unnamed-param: false warn-if-undoc-enum-val: false +# Several fixtures exist to exercise a filtered symbol reached from the +# documented API, which is exactly the situation this warning is for. +warn-if-filtered-in-public-api: false tagfile: "" no-default-styles: true \ No newline at end of file