Skip to content

feat: link references to symbols documented elsewhere (input tagfiles) - #1267

Open
gennaroprota wants to merge 3 commits into
cppalliance:developfrom
gennaroprota:feat/read_tagfiles
Open

gennaroprota wants to merge 3 commits into
cppalliance:developfrom
gennaroprota:feat/read_tagfiles

Conversation

@gennaroprota

@gennaroprota gennaroprota commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator

MrDocs can now read the tagfiles of other documentation sets and, thus, link a doc comment reference to a symbol it never extracted. A new option, input-tagfiles, maps each tagfile to the location its documentation set is published at. A reference that resolves to nothing in the corpus takes its link target from there instead of rendering as plain code. The name is resolved from the scope it is written in outward, as any other reference is. Tagfiles written by Doxygen, by MrDocs and by cppreference.com are read.

Design decisions

These are the choices a reviewer could reasonably make differently.

  • Option names. input-tagfiles reads; the option MrDocs already had for writing a tagfile, tagfile, becomes output-tagfile, so that the pair says which direction each of them goes. tagfile remains as a deprecated alias, which the schema's replaced-by forwards to output-tagfile. This follows the earlier review round.
  • Locations work as in Doxygen's TAGFILES. A URL, or a path starting with /, is used as written; any other path is relative to the output directory, and is adjusted for the depth of each page. This is what lets a setup written for Doxygen work unchanged. Note that a relative location holds only if the two documentation sets are published in that relative layout; under Antora, which publishes pages in a layout of its own, which form the Antora extension should pass is an open question. In AsciiDoc, every link to a tagfile target is written with the link: macro, so that a relative target, or one with a space, is still read as a link.
  • Precedence. A symbol in the corpus always wins: tagfiles are consulted only when the corpus lookup fails. Within a tagfile, the first entry listed for a name wins. Across tagfiles, since input-tagfiles is a map, the tagfile whose path sorts first wins; this last rule is deliberately left out of the user documentation, so that it can change.
  • Keys are built by MrDocs's own reference parser. A tagfile name and a reference are reduced to a key by the same parser, which drops template arguments and parameter lists, so cppreference's operator[](size_type) meets @ref vector::operator[]. The cost is that overloads share a key: a reference written with a parameter list links to the first overload listed, whichever one it names. With cppreference, whose overloads share a page, that is the right page; with Doxygen, whose overloads have anchors of their own, it may be a sibling's anchor on the right page. A name that does not parse is retried without a trailing parenthesized part, which is how cppreference's isalnum (<cctype>), named after the header that tells an overload apart, is read.
  • Doxygen file compounds are read. Doxygen lists the functions, variables and typedefs of the global namespace only in the compound of the file that declares them, so the members of a file compound are recorded as global names. I checked with Doxygen 1.14 that it never lists a namespaced member there. Page and group compounds are skipped.
  • Specializations are skipped. The compound of a class template specialization, members included, is not recorded: a reference cannot name a specialization apart from its primary template, so its entries would only compete with the primary's.
  • Own reader, no LibXml2. Tagfiles are read with a small ad-hoc parser: the format is a small, machine-generated subset of XML, and linking LibXml2 into mrdocs-core would make it a dependency of every build and of every consumer of the installed package.
  • A bad tagfile fails the build, before anything is extracted, rather than being skipped with a warning: a path that cannot be read, a file with no root element (what a failed download leaves), a malformed character reference, a closing tag out of turn, a DTD or a CDATA section. A relative tagfile path is resolved against the directory of the configuration file, also when it is given on the command line.
  • Tagfile text is not trusted. A tagfile is a file MrDocs did not write, so an entry whose page carries a URL scheme, such as javascript:, is dropped, and the characters that would end a link in HTML or AsciiDoc (a space, ", <, >, brackets and braces) are percent-encoded in the link target. Other characters are written as given.
  • New public surface. ReferenceInline gets an href field, which shows in the DOM, in the XML output and in the schemas. Templates get a built-in external_href helper, which turns a location into a link target for the page being rendered, and which custom templates can use as well; the target is computed at render time because it depends on the page it appears on, which only the generator knows. Note that a built-in helper is hard to withdraw once templates rely on it. detail::TagfileIndex is exported from include/mrdocs/detail.
  • std:: references stay exempt from the broken-reference warning, as before, also when a tagfile for the standard library is loaded, so a misspelled std:: name still goes unreported.

Changes

  • Source: the tagfile index and its reader; the input-tagfiles option and loading it into the corpus; Corpus::externalUrl beside Corpus::lookup; href on ReferenceInline, set when a tagfile resolves the reference; the external_href template helper; the tagfile -> output-tagfile rename, with tagfile kept as a deprecated alias.
  • Parser: an empty template argument list (<>) no longer leaves a null argument behind, which made printing a conversion operator such as cppreference's operator coroutine_handle<> assert. This is its own commit, since doc comments never reached it.
  • Tests: unit suites for the index and for the reader (clang.mrdocs.TagfileIndex, clang.mrdocs.TagfileReader), covering what is read and what is rejected: a malformed character reference, a closing tag out of turn, a file with no root element, a DTD or a CDATA section, and an entry whose page carries a URL scheme. Config tests cover the tagfile alias and the command-line form of input-tagfiles.
  • Golden tests: a config/input-tagfiles fixture (xml, adoc, html) reads a tagfile Doxygen wrote for C++ code, one it wrote for a C header, one written by MrDocs and a hand-written one. It covers a fully qualified name, a name relative to its enclosing scope, names written with a parameter list or template arguments, a member name that carries its parameter list, globals listed only in a file compound, a namespace page, a member with an anchor, a member on its own page, a @throws target, a corpus symbol taking precedence over a tagfile entry, and names that resolve to nothing. A multipage locations fixture beside it (html, adoc) covers the three forms of a location, on a top-level page and two directories down, and the characters that have to be escaped. The config/tagfile fixture is renamed to config/output-tagfile and regenerated for its new file names.
  • Build: the new options land in ConfigOptions.json, so the generated config schema and the published option reference change; the new href field regenerates the reflected DOM schema (mrdocs.rng, generators/mrdocs.schema.json, dom-schema.adoc).
  • Tooling: the in-repo callers that passed --tagfile now pass --output-tagfile.
  • Breaking changes: none; tagfile still works, with a deprecation warning.

Not handled

  • @ref cannot name a user-defined literal operator, such as operator""sv, or a conversion operator to a type of more than one token, such as operator const char* or operator T&: MrDocs's reference parser has no rule for the former and reads a single type specifier for the latter. This holds for symbols in the corpus too, and it leaves the tagfile entries for such operators, a handful of cppreference's among them, unreachable. Doxygen's \ref cannot name them either. Teaching the parser both forms is a change of its own, left for a separate PR.
  • A member inherited from a base class is not found through the derived class.

Testing

The unit suites test the index and the reader, including the inputs it must reject; the golden fixtures test resolution end to end in XML, AsciiDoc and HTML. I rendered the AsciiDoc goldens with Asciidoctor to check that each link comes out with the intended target.

Beyond that, I read the current cppreference.com tagfile (from the 2025-02-09 release of the cppreference-doc archive) and compared every entry the index records with an independent extraction of the same file: all of them match. A sample corpus then linked std::sort, std::vector::push_back, std::vector::operator[], std::isalnum and std::chrono::duration to the right pages, and left a typo and std::chrono, which has no page, as plain code. The Doxygen tagfiles in the fixtures were generated with Doxygen 1.14.

Documentation

A new "Other documentation sets" section of configuration/inputs.adoc documents the option with an example; configuration/output.adoc and commands/inlines.adoc link to it, extensions/handlebars-extensions.adoc lists the external_href helper, and the option reference is generated from ConfigOptions.json.

Closes #1072.

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

✨ Highlights

  • 🧪 Existing golden tests changed (behavior likely shifted)

🧾 Changes by Scope

Scope Lines Δ% Lines Δ Lines + Lines - Files Δ Files + Files ~ Files ↔ Files -
🥇 Golden Tests 42% 1788 1777 11 40 27 1 11 1
🛠️ Source 37% 1599 1591 8 15 4 11 - -
🧪 Unit Tests 19% 796 796 - 4 2 2 - -
📄 Docs 2% 70 56 14 12 - 12 - -
📦 Other <1% 16 8 8 1 - 1 - -
🔧 Toolchain <1% 4 2 2 2 - 2 - -
Total 100% 4273 4230 43 74 33 29 11 1

Legend: Files + (added), Files ~ (modified), Files ↔ (renamed), Files - (removed)

🔝 Top Files

  • src/mrdocs/Support/TagfileReader.cpp (Source): 1139 lines Δ (+1139 / -0)
  • tests/unit/Support/TagfileReader.cpp (Unit Tests): 648 lines Δ (+648 / -0)
  • tests/golden/fixtures/config/input-tagfiles/input-tagfiles.xml (Golden Tests): 636 lines Δ (+636 / -0)

Generated by 🚫 dangerJS against 3ef1340

@codecov

codecov Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.21%. Comparing base (6f92577) to head (3ef1340).

Additional details and impacted files
@@           Coverage Diff            @@
##           develop    #1267   +/-   ##
========================================
  Coverage    83.21%   83.21%           
========================================
  Files           35       35           
  Lines         3670     3670           
  Branches       845      845           
========================================
  Hits          3054     3054           
  Misses         410      410           
  Partials       206      206           
Flag Coverage Δ
bootstrap 83.21% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cppalliance-bot

cppalliance-bot commented Aug 28, 2026 •

Copy link
Copy Markdown

An automated preview of the documentation is available at https://1267.mrdocs.prtest2.cppalliance.org/index.html

If more commits are pushed to the pull request, the docs will rebuild at the same URL.

2026-11-06 10:15:00 UTC

@alandefreitas alandefreitas left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feature is amazing. This is a feature I've wanted for a while, and the layout is right. Resolution happens in the finalizer, which is good, and the reference node has the href.

Is this Doxygen tagfile format good enough? Do we need our own format?

We should load the tagfiles before extraction so that a typo in a tagfile path fails the build before extracting the whole AST, which could take one hour for a large library such as LLVM.

The tagfile to output-tagfile transfer depends on declaration order. This can be solved by doing it once in Config::normalize. Then the special case in normalizeString and the comment can go.

TIP: A few projects use the tag file in a less obvious way: they read it from their own site-generation scripts to build a navigation tree or a search index from the symbol list, rather than maintaining one by hand.

NOTE: The inverse direction (consuming tag files emitted by other projects so that your pages link to *their* symbols) is handled by the xref:extensions/antora.adoc#antora-cpp-tagfiles-extension[`antora-cpp-tagfiles-extension`]. Point it at one or more tag files in your Antora playbook, and references like `boost::asio::io_context` resolve to the Boost.Asio site automatically.
xref:configuration/reference.adoc#input-tagfiles_option[`input-tagfiles`] is the other direction: it reads the tag files of documentation sets you do not build, so a reference to a symbol MrDocs never extracted still becomes a link. Each key is a tag file, each value the base URL that set is published under:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is input-tagfiles that go in the other direction really an "output" option?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, moved to the Inputs page.

include::example$configuration/input-tagfiles.yml[]
----

With that in place, `@ref std::vector` in a doc comment links to Cppreference instead of rendering as plain code. A name one of these sets documents resolves the way every other reference does, from the scope it is written in outward, so a name relative to its enclosing scope reaches an external symbol just as it would reach one of yours. What never happens is shortening: `vector` reaches `std::vector` only from inside `std`, because the shorter the name the likelier it is to collide with something unrelated in a foreign set, and a reference that silently points at the wrong page is worse than one that points nowhere. A name no scope accounts for is left as code.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cppreference or C++ Reference with link?

@gennaroprota gennaroprota Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, the site name was mentioned once, on the main page, as "Cppreference", but I can't find that now, so I'm switching to "cppreference.com", with a link.

include::example$configuration/input-tagfiles.yml[]
----

With that in place, `@ref std::vector` in a doc comment links to Cppreference instead of rendering as plain code. A name one of these sets documents resolves the way every other reference does, from the scope it is written in outward, so a name relative to its enclosing scope reaches an external symbol just as it would reach one of yours. What never happens is shortening: `vector` reaches `std::vector` only from inside `std`, because the shorter the name the likelier it is to collide with something unrelated in a foreign set, and a reference that silently points at the wrong page is worse than one that points nowhere. A name no scope accounts for is left as code.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has to be followed by output example, like all other options explained in this section. Without an example in form of narrative, it becomes redundant with the reference section.

@gennaroprota gennaroprota Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (I assume you meant "input example").

include::example$configuration/input-tagfiles.yml[]
----

With that in place, `@ref std::vector` in a doc comment links to Cppreference instead of rendering as plain code. A name one of these sets documents resolves the way every other reference does, from the scope it is written in outward, so a name relative to its enclosing scope reaches an external symbol just as it would reach one of yours. What never happens is shortening: `vector` reaches `std::vector` only from inside `std`, because the shorter the name the likelier it is to collide with something unrelated in a foreign set, and a reference that silently points at the wrong page is worse than one that points nowhere. A name no scope accounts for is left as code.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What never happens is shortening

These negatives are very AI-ish. I'm sure this didn't cross the reader's mind at this point. There's no reason to negate this or provide any details about edge cases at this point because we haven't even provided the example of the general case yet.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. The section now leads with what the option does and the example; the sentence after it on how a name resolves is stated positively.


NOTE: A tag file only says where the pages of that set are, so MrDocs can resolve no more than the set publishes. Cppreference, for one, gives `namespace std` itself no page, so `@ref std::chrono` stays plain text while `@ref std::chrono::duration` links. Either way, it is a reference that resolved to nothing, and xref:configuration/reference.adoc#warn-broken-ref_option[`warn-broken-ref`] reports it as one.

NOTE: For an Antora site there is also the xref:extensions/antora.adoc#antora-cpp-tagfiles-extension[`antora-cpp-tagfiles-extension`], which adds a `cpp:` macro and bundles the Cppreference tag file. The two are complementary, and reach different text: the extension resolves the names you write in your own pages, `input-tagfiles` resolves the ones inside doc comments, for every generator rather than for Asciidoctor alone.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of scope (even as a note)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

Comment thread include/mrdocs/Corpus.hpp Outdated
@param name The name of the symbol to look up.
@return The URL, or nothing if no tagfile documents the name.
*/
std::optional<std::string>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this use Expected to match the convention for lookup?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, better.

Comment thread src/mrdocs/Corpus.cpp Outdated
{
std::optional<std::string> result;
std::string_view const scopeQualifier = "::";
if (name.starts_with("scopeQualifier"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comparing with a literal string instead of the variable? this works?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch! Thanks! It was a string literal initially, then I introduced the variable but forgot to remove the quote characters.

// Only warn once per reference
!refWarned_.contains({ref.literal, ctx.Name}) &&
// Ignore std:: references
!ref.literal.starts_with("std::"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional and makes the comment about warn-broken-ref in the documentation incorrect. The reason we skip this is that users tend to know what they're doing in this case and they typically just want code formatting here. Doxygen libraries come with this a lot where people @ref they're own library and std freely just for the formatting.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The skip stays. And I removed the documentation note that contradicted it.

Comment thread include/mrdocs/Config.hpp Outdated
option's properties, and surfaced by
@ref reportDeprecatedOptions.
*/
struct DeprecatedOption

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not our first deprecated option. What was the model we had before, and why was it insufficient?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous model was erroneous, in that it tried to warn inside normalizeInteger but that never printed, because option parsing runs with the reporting level forced to errors-only. Deprecated options warn now for the first time.

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this generated by doxygen or us? We need to test both cases to be sure.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By us. I noticed that we write encoding="UTF-8", with double quotes, but Doxygen writes encoding='UTF-8', with a space before ?>. All forms are covered now.

@alandefreitas

alandefreitas commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

As a meta-comment, this seems to have a much higher ROI than plugins right now because the design is much clearer.

@alandefreitas alandefreitas mentioned this pull request Sep 3, 2026
6 tasks done
@gennaroprota

gennaroprota commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Is this Doxygen tagfile format good enough? Do we need our own format?

I think the Doxygen tagfile format is fine for now. It's what Cppreference uses, and our own writer emits. An own format would buy nothing unless we want more than name -> page.

We should load the tagfiles before extraction so that a typo in a tagfile path fails the build before extracting the whole AST, which could take one hour for a large library such as LLVM.

Good point. Done.

The tagfile to output-tagfile transfer depends on declaration order. This can be solved by doing it once in Config::normalize. Then the special case in normalizeString and the comment can go.

Done, too. Thanks for the great review, as usual!

@gennaroprota
gennaroprota force-pushed the feat/read_tagfiles branch 2 times, most recently from 380ecbf to bfa9d32 Compare September 14, 2026 12:39
`parseTemplateArguments` added an empty slot for the first argument
before looking for one, so `X<>` came out with a single null argument.
Printing a type parsed that way, as `toString` does for the conversion
operator `operator coroutine_handle<>` in cppreference's tagfile,
dereferenced the null and hit an assertion. A doc comment never reaches
this, since the reference text stops at the `<`, but a name parsed from
elsewhere, such as a tagfile entry, does.
MrDocs can now read external tagfiles and, thus, handle a doc comment
that references a symbol not extracted by MrDocs itself. A new option,
`input-tagfiles`, specifies the external tagfile(s) and maps each one to
the location its documentation set is published at, a URL or a path, as
in Doxygen's `TAGFILES`. A reference that resolves to nothing in the
corpus takes its link target from there instead of rendering as plain
code. The name is resolved from the scope it is written in outward, as
any other reference is.

Tagfiles are read with a small ad-hoc parser, rather than by LibXml2:
the format is a small, machine-generated subset of XML, and linking
LibXml2 into mrdocs-core would make it a dependency of every build and
of every consumer of the installed package.

A path that cannot be read, or a file that is not a tagfile, fails the
build.

Note that the option MrDocs already had for writing a tagfile (named
`tagfile`) becomes `output-tagfile`, so that the pair of options says
which direction each of them goes; `tagfile` remains as a deprecated
alias.
The site was sometimes mentioned as "cppreference", others as
"cppreference.com", and some mentions were not links. This makes things
uniform.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

support for reading tagfiles

3 participants