From e6b820b897e916b5fa4ae4ce69162aa4413f3977 Mon Sep 17 00:00:00 2001 From: Gennaro Prota Date: Fri, 28 Aug 2026 17:05:27 +0200 Subject: [PATCH] feat: link references to symbols documented elsewhere (input tagfiles) MrDocs can now read external tagfiles and, thus, have a doc comment that references a symbol not extracted by MrDocs itself. A new option, `input-tagfiles`, specifies the external tagfile and maps each one to the base URL its documentation set is published under. A reference that resolves to nothing in the corpus takes its URL 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, and is never shortened to reach an entry. Every generator takes the URL from one place. 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. 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. Note that all deprecated options warn now, including `report`, which didn't before. --- .../common/partials/doc/inline/reference.hbs | 2 + .../schemas/config/mrdocs.schema.json | 21 +- .../attachments/schemas/generators/mrdocs.rng | 2 +- .../schemas/generators/mrdocs.schema.json | 2 +- .../examples/configuration/input-tagfiles.yml | 3 + docs/modules/ROOT/pages/commands/inlines.adoc | 2 +- .../ROOT/pages/configuration/output.adoc | 16 +- .../modules/ROOT/pages/extensions/antora.adoc | 2 +- docs/modules/ROOT/partials/dom-schema.adoc | 4 + docs/website/render.js | 2 +- include/mrdocs/Config.hpp | 31 + include/mrdocs/Corpus.hpp | 25 + .../DocComment/Inline/ReferenceInline.hpp | 9 +- include/mrdocs/Support/TagfileIndex.hpp | 96 ++ src/mrdocs/Config.cpp | 64 +- src/mrdocs/ConfigOptions.json | 22 +- src/mrdocs/Corpus.cpp | 61 ++ .../Generators/hbs/HandlebarsGenerator.cpp | 2 +- .../Finalizers/DocCommentFinalizer.cpp | 5 + src/mrdocs/Support/TagfileIndex.cpp | 88 ++ src/mrdocs/Support/TagfileReader.cpp | 925 ++++++++++++++++++ src/mrdocs/Support/TagfileReader.hpp | 58 ++ tests/golden/TestRunner.cpp | 16 +- .../config/input-tagfiles/input-tagfiles.adoc | 78 ++ .../config/input-tagfiles/input-tagfiles.cpp | 26 + .../config/input-tagfiles/input-tagfiles.html | 88 ++ .../config/input-tagfiles/input-tagfiles.xml | 189 ++++ .../config/input-tagfiles/input-tagfiles.yml | 6 + .../config/input-tagfiles/other.tag.xml | 29 + .../output-tagfile.cpp} | 0 .../output-tagfile.multipage}/html/index.html | 0 .../output-tagfile.multipage}/html/ns.html | 0 .../html/ns/outer.html | 2 +- .../html/ns/outer/config.html | 2 +- .../html/ns/outer/config/apply.html | 2 +- .../html/ns/outer/method.html | 2 +- .../html/ns/outer/mode.html | 2 +- .../html/ns/response_factory.html | 2 +- .../html/ns/response_factory/make.html | 2 +- .../html/reference.tag.xml | 0 .../config/output-tagfile/output-tagfile.yml | 3 + .../fixtures/config/tagfile/tagfile.yml | 3 - tests/golden/fixtures/mrdocs.yml | 2 +- tests/unit/Support/TagfileIndex.cpp | 118 +++ tests/unit/Support/TagfileReader.cpp | 283 ++++++ utils/bootstrap/src/configs/run_configs.json | 2 +- utils/bootstrap/src/configs/run_configs.py | 2 +- 47 files changed, 2260 insertions(+), 41 deletions(-) create mode 100644 docs/modules/ROOT/examples/configuration/input-tagfiles.yml create mode 100644 include/mrdocs/Support/TagfileIndex.hpp create mode 100644 src/mrdocs/Support/TagfileIndex.cpp create mode 100644 src/mrdocs/Support/TagfileReader.cpp create mode 100644 src/mrdocs/Support/TagfileReader.hpp create mode 100644 tests/golden/fixtures/config/input-tagfiles/input-tagfiles.adoc create mode 100644 tests/golden/fixtures/config/input-tagfiles/input-tagfiles.cpp create mode 100644 tests/golden/fixtures/config/input-tagfiles/input-tagfiles.html create mode 100644 tests/golden/fixtures/config/input-tagfiles/input-tagfiles.xml create mode 100644 tests/golden/fixtures/config/input-tagfiles/input-tagfiles.yml create mode 100644 tests/golden/fixtures/config/input-tagfiles/other.tag.xml rename tests/golden/fixtures/config/{tagfile/tagfile.cpp => output-tagfile/output-tagfile.cpp} (100%) rename tests/golden/fixtures/config/{tagfile/tagfile.multipage => output-tagfile/output-tagfile.multipage}/html/index.html (100%) rename tests/golden/fixtures/config/{tagfile/tagfile.multipage => output-tagfile/output-tagfile.multipage}/html/ns.html (100%) rename tests/golden/fixtures/config/{tagfile/tagfile.multipage => output-tagfile/output-tagfile.multipage}/html/ns/outer.html (95%) rename tests/golden/fixtures/config/{tagfile/tagfile.multipage => output-tagfile/output-tagfile.multipage}/html/ns/outer/config.html (93%) rename tests/golden/fixtures/config/{tagfile/tagfile.multipage => output-tagfile/output-tagfile.multipage}/html/ns/outer/config/apply.html (90%) rename tests/golden/fixtures/config/{tagfile/tagfile.multipage => output-tagfile/output-tagfile.multipage}/html/ns/outer/method.html (90%) rename tests/golden/fixtures/config/{tagfile/tagfile.multipage => output-tagfile/output-tagfile.multipage}/html/ns/outer/mode.html (93%) rename tests/golden/fixtures/config/{tagfile/tagfile.multipage => output-tagfile/output-tagfile.multipage}/html/ns/response_factory.html (93%) rename tests/golden/fixtures/config/{tagfile/tagfile.multipage => output-tagfile/output-tagfile.multipage}/html/ns/response_factory/make.html (90%) rename tests/golden/fixtures/config/{tagfile/tagfile.multipage => output-tagfile/output-tagfile.multipage}/html/reference.tag.xml (100%) create mode 100644 tests/golden/fixtures/config/output-tagfile/output-tagfile.yml delete mode 100644 tests/golden/fixtures/config/tagfile/tagfile.yml create mode 100644 tests/unit/Support/TagfileIndex.cpp create mode 100644 tests/unit/Support/TagfileReader.cpp diff --git a/data/mrdocs/addons/generator/common/partials/doc/inline/reference.hbs b/data/mrdocs/addons/generator/common/partials/doc/inline/reference.hbs index 1747ea99f4c..3c557eac45c 100644 --- a/data/mrdocs/addons/generator/common/partials/doc/inline/reference.hbs +++ b/data/mrdocs/addons/generator/common/partials/doc/inline/reference.hbs @@ -1,5 +1,7 @@ {{#if (@root.mrdocs.corpus.getUrl (@root.mrdocs.corpus.get id))~}} {{#>markup/a href=(@root.mrdocs.corpus.getUrl (@root.mrdocs.corpus.get id))}}{{#>markup/code}}{{ literal }}{{/markup/code}}{{/markup/a}} +{{~else if href~}} +{{#>markup/a href=href}}{{#>markup/code}}{{ literal }}{{/markup/code}}{{/markup/a}} {{~else~}} {{#>markup/code}}{{ literal }}{{/markup/code}} {{~/if}} \ No newline at end of file diff --git a/docs/modules/ROOT/attachments/schemas/config/mrdocs.schema.json b/docs/modules/ROOT/attachments/schemas/config/mrdocs.schema.json index c8cbbb9b18b..c460831929f 100644 --- a/docs/modules/ROOT/attachments/schemas/config/mrdocs.schema.json +++ b/docs/modules/ROOT/attachments/schemas/config/mrdocs.schema.json @@ -414,6 +414,15 @@ "title": "Input directories to extract symbols from", "type": "array" }, + "input-tagfiles": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "A map of tagfile path to the base URL under which the documentation set it describes is published. A reference in a doc comment that names no symbol in this corpus, such as `@ref std::vector`, becomes a link into that set when one of these tagfiles lists the name. The name is resolved from the scope it is written in outward, as any other reference is, and is never shortened to reach an entry. Relative paths are resolved against the directory of the configuration file. On the command line, each entry is passed as `--input-tagfiles==`, which may be repeated. Doxygen writes a tagfile for its own output with `GENERATE_TAGFILE`, MrDocs with the `output-tagfile` option.", + "title": "Tagfiles of other documentation sets to link against", + "type": "object" + }, "legible-names": { "default": true, "description": "Use legible names for IDs in the documentation. When set to true, MrDocs uses legible names for symbol anchors in the corpus. These are symbols that are legible but still safe for URLs. When the option is set to false, MrDocs uses a hash of the symbol ID to avoid conflicts.", @@ -502,6 +511,12 @@ "title": "Output directory for the generated documentation", "type": "string" }, + "output-tagfile": { + "default": "/reference.tag.xml", + "description": "Specifies the full path (filename) where the generated tagfile should be saved. If left empty, no tagfile will be generated. The tagfiles of other documentation sets are read with the `input-tagfiles` option.", + "title": "Path for the tagfile", + "type": "string" + }, "overloads": { "default": true, "description": "When set to `true`, MrDocs detects function overloads and groups them as a single symbol type. The documentation for this new symbol comes from the union of non-ambiguous metadata from the functions.", @@ -689,9 +704,9 @@ "type": "array" }, "tagfile": { - "default": "/reference.tag.xml", - "description": "Specifies the full path (filename) where the generated tagfile should be saved. If left empty, no tagfile will be generated.", - "title": "Path for the tagfile", + "default": "", + "description": "Deprecated: this is the old name for `output-tagfile`. Setting it still sets that option, with a warning. An empty value leaves `output-tagfile` alone.", + "title": "Path for the tagfile (deprecated)", "type": "string" }, "transform-options": { diff --git a/docs/modules/ROOT/attachments/schemas/generators/mrdocs.rng b/docs/modules/ROOT/attachments/schemas/generators/mrdocs.rng index 3c317487f6a..72e2ba3a20a 100644 --- a/docs/modules/ROOT/attachments/schemas/generators/mrdocs.rng +++ b/docs/modules/ROOT/attachments/schemas/generators/mrdocs.rng @@ -84,7 +84,7 @@ - + diff --git a/docs/modules/ROOT/attachments/schemas/generators/mrdocs.schema.json b/docs/modules/ROOT/attachments/schemas/generators/mrdocs.schema.json index f440ac50e47..a5cf10fffbe 100644 --- a/docs/modules/ROOT/attachments/schemas/generators/mrdocs.schema.json +++ b/docs/modules/ROOT/attachments/schemas/generators/mrdocs.schema.json @@ -84,7 +84,7 @@ "S_2bdz8onDMx7PBYnExqtLgMxUqrPY": {"type":"object","additionalProperties":true,"properties":{"kind":{"type":"string"}}}, "S_pbDQ6katbvPedvuztLHCiRNi4G8": {"type":"object","additionalProperties":true,"properties":{"kind":{"type":"string"},"children":{"type":"array","items":{"$ref":"#/$defs/AnyInline"}},"href":{"type":"string"}}}, "S_3PrdQDrzihPKEMMq8ZtDjd69UoMf": {"type":"object","additionalProperties":true,"properties":{"kind":{"type":"string"},"literal":{"type":"string"}}}, - "S_2yM1iLKZLVpSpfu7Dq5sFaDfnwLa": {"type":"object","additionalProperties":true,"properties":{"kind":{"type":"string"},"id":{"type":"string"},"literal":{"type":"string"}}}, + "S_2yM1iLKZLVpSpfu7Dq5sFaDfnwLa": {"type":"object","additionalProperties":true,"properties":{"kind":{"type":"string"},"href":{"type":"string"},"id":{"type":"string"},"literal":{"type":"string"}}}, "S_22nFJc2hmNmmR5h1kq6NkgViKdEM": {"type":"object","additionalProperties":true,"properties":{"kind":{"type":"string"}}}, "S_CJPbC4NwrPJeuv6wSnDmgGcZcQc": {"type":"object","additionalProperties":true,"properties":{"kind":{"type":"string"},"children":{"type":"array","items":{"$ref":"#/$defs/AnyInline"}}}}, "S_28xEnXQadr6mJnmktXwag2oH9muy": {"type":"object","additionalProperties":true,"properties":{"kind":{"type":"string"},"children":{"type":"array","items":{"$ref":"#/$defs/AnyInline"}}}}, diff --git a/docs/modules/ROOT/examples/configuration/input-tagfiles.yml b/docs/modules/ROOT/examples/configuration/input-tagfiles.yml new file mode 100644 index 00000000000..9b12d277bae --- /dev/null +++ b/docs/modules/ROOT/examples/configuration/input-tagfiles.yml @@ -0,0 +1,3 @@ +input-tagfiles: + tagfiles/cppreference-doxygen-web.tag.xml: https://en.cppreference.com/w/ + tagfiles/boost-url.tag.xml: https://www.boost.org/doc/libs/release/libs/url/doc/html/ diff --git a/docs/modules/ROOT/pages/commands/inlines.adoc b/docs/modules/ROOT/pages/commands/inlines.adoc index e5712051bad..c9db0805c43 100644 --- a/docs/modules/ROOT/pages/commands/inlines.adoc +++ b/docs/modules/ROOT/pages/commands/inlines.adoc @@ -42,7 +42,7 @@ include::example$snippets/commands/link.adoc[tags=!footer] == Cross-references -xref:commands/reference.adoc#cmd-cross-reference[`@ref`] links to another symbol by its qualified name; MrDocs resolves it against the corpus and emits a working link. This is the command to reach for whenever one symbol's documentation should point at another. +xref:commands/reference.adoc#cmd-cross-reference[`@ref`] links to another symbol by its qualified name; MrDocs resolves it and emits a working link. This is the command to reach for whenever one symbol's documentation should point at another. A name the corpus does not hold can still resolve into another project's documentation, through xref:configuration/output.adoc#_external_docs[`input-tagfiles`]. .Cross-references [source,cpp] diff --git a/docs/modules/ROOT/pages/configuration/output.adoc b/docs/modules/ROOT/pages/configuration/output.adoc index 14fa3de414b..1e5e1301a62 100644 --- a/docs/modules/ROOT/pages/configuration/output.adoc +++ b/docs/modules/ROOT/pages/configuration/output.adoc @@ -125,11 +125,23 @@ include::example$snippets/options/base-url/base-url.adoc[tags=!footer] == External docs -xref:configuration/reference.adoc#tagfile_option[`tagfile`] writes a Doxygen-compatible tag file alongside the documentation. The expected use is cross-linking: other MrDocs runs can read it to resolve `@ref` targets that live in your library, and Doxygen-generated docs can consume it for the same purpose. Reach for it when you publish a library that other people's docs need to link to. +xref:configuration/reference.adoc#output-tagfile_option[`output-tagfile`] writes a Doxygen-compatible tag file alongside the documentation. The expected use is cross-linking: other MrDocs runs can read it to resolve `@ref` targets that live in your library, and Doxygen-generated docs can consume it for the same purpose. Reach for it when you publish a library that other people's docs need to link to. 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: + +.`mrdocs.yml` +[source,yaml] +---- +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. + +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. == Styling diff --git a/docs/modules/ROOT/pages/extensions/antora.adoc b/docs/modules/ROOT/pages/extensions/antora.adoc index 7a93ba80c56..ed10f2f1c3b 100644 --- a/docs/modules/ROOT/pages/extensions/antora.adoc +++ b/docs/modules/ROOT/pages/extensions/antora.adoc @@ -1,6 +1,6 @@ = Antora extensions -Two Antora extensions connect the xref:page$generators/adoc.adoc[Asciidoc Generator] into an Antora build. One runs Mr.Docs as a stage inside the Antora build. The other registers the resulting Mr.Docs xref:configuration/reference.adoc#tagfile_option[tagfile], so prose on the site can link to C++ symbols. +Two Antora extensions connect the xref:page$generators/adoc.adoc[Asciidoc Generator] into an Antora build. One runs Mr.Docs as a stage inside the Antora build. The other registers the resulting Mr.Docs xref:configuration/reference.adoc#output-tagfile_option[tagfile], so prose on the site can link to C++ symbols. [#antora-cpp-reference-extension] == C++ reference extension diff --git a/docs/modules/ROOT/partials/dom-schema.adoc b/docs/modules/ROOT/partials/dom-schema.adoc index afd234a74de..fd6ec89af1f 100644 --- a/docs/modules/ROOT/partials/dom-schema.adoc +++ b/docs/modules/ROOT/partials/dom-schema.adoc @@ -2554,6 +2554,10 @@ A reference to a symbol. |`string` |Discriminator identifying which inline variant is active. +|`href` +|`string` +|URL documenting the symbol, when that's in another documentation set. + |`id` |`string` |Symbol being referenced. diff --git a/docs/website/render.js b/docs/website/render.js index d9ecdc8c5ce..326005216d8 100644 --- a/docs/website/render.js +++ b/docs/website/render.js @@ -139,7 +139,7 @@ for (let panel of data.panels) { '--generator=html', '--embedded=true', '--show-namespaces=false', - '--tagfile=', + '--output-tagfile=', ]; const command = args.join(' '); console.log(`Running command: ${command}`) diff --git a/include/mrdocs/Config.hpp b/include/mrdocs/Config.hpp index 6ab4d4d5c9a..aa8dc5d8a39 100644 --- a/include/mrdocs/Config.hpp +++ b/include/mrdocs/Config.hpp @@ -139,12 +139,43 @@ class MRDOCS_DECL void reportUnknownConfigKeys() const; + /** Warn about the deprecated options this configuration sets. + + Reports each one as a warning, or as an error under + `warn-as-error`. Deferred and explicitly called for the same + reason as @ref reportUnknownConfigKeys. + */ + void + reportDeprecatedOptions() const; + private: /** Keys found in the configuration file that match no known option. Populated during load and surfaced by @ref reportUnknownConfigKeys. */ std::vector unknownConfigKeys; + + /** A deprecated option this configuration sets, and its advice. + + Populated during @ref normalize, which is the pass that sees each + option's properties, and surfaced by + @ref reportDeprecatedOptions. + */ + struct DeprecatedOption + { + /** Name of the option, as the configuration spells it. + */ + std::string name; + /** What to use instead, as the option itself declares. + */ + std::string advice; + }; + + /** The deprecated options this configuration sets. + */ + std::vector deprecatedOptions; + + friend struct ConfigSchemaVisitor; }; // Config adds no reflected options of its own; it only inherits the diff --git a/include/mrdocs/Corpus.hpp b/include/mrdocs/Corpus.hpp index 9235c66d8ca..c7285496c39 100644 --- a/include/mrdocs/Corpus.hpp +++ b/include/mrdocs/Corpus.hpp @@ -19,9 +19,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -135,6 +137,26 @@ class MRDOCS_VISIBLE Expected lookup(SymbolID const& context, std::string_view name) const; + /** Return the URL documenting a name this corpus does not hold. + + The counterpart of @ref lookup for the symbols covered by + another documentation set: the name is resolved from the context + outward, so one written relative to its enclosing scope reaches + an external symbol just as it would reach one of ours. What + comes back is a URL rather than a Symbol, since nothing was + extracted to point at. + + A name no enclosing scope accounts for is not matched against + a longer one: `vector` reaches `std::vector` only from inside + `std`, exactly as it would for a symbol of this corpus. + + @param context The context the name is written in. + @param name The name of the symbol to look up. + @return The URL, or nothing if no tagfile documents the name. + */ + std::optional + externalUrl(SymbolID const& context, std::string_view name) const; + /** Return the Symbol with the matching ID, or nullptr. */ Symbol const* @@ -386,6 +408,9 @@ class MRDOCS_VISIBLE // Undocumented symbols. detail::UndocumentedSymbolSet undocumented_; + // Symbols documented elsewhere, read from the configured tagfiles. + TagfileIndex externalSymbols_; + // Lookup cache: context Symbol ID -> (name -> Info). std::map> lookupCache_; diff --git a/include/mrdocs/Metadata/DocComment/Inline/ReferenceInline.hpp b/include/mrdocs/Metadata/DocComment/Inline/ReferenceInline.hpp index 477da71069e..b871b11404a 100644 --- a/include/mrdocs/Metadata/DocComment/Inline/ReferenceInline.hpp +++ b/include/mrdocs/Metadata/DocComment/Inline/ReferenceInline.hpp @@ -39,6 +39,13 @@ struct ReferenceInline /** Symbol being referenced. */ SymbolID id = SymbolID::invalid; + /** URL documenting the symbol, when that's in another documentation set. + + Set when the reference names no symbol in this corpus and a + tagfile says where it is documented, which leaves @ref id + invalid. Empty for a symbol of this corpus. + */ + std::string href; /** Construct a reference with optional display text. */ @@ -51,7 +58,7 @@ struct ReferenceInline MRDOCS_DESCRIBE_STRUCT( ReferenceInline, (InlineCommonBase), - (literal, id) + (literal, id, href) ) } // mrdocs::doc diff --git a/include/mrdocs/Support/TagfileIndex.hpp b/include/mrdocs/Support/TagfileIndex.hpp new file mode 100644 index 00000000000..1f06d856c39 --- /dev/null +++ b/include/mrdocs/Support/TagfileIndex.hpp @@ -0,0 +1,96 @@ +// +// 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_API_SUPPORT_TAGFILEINDEX_HPP +#define MRDOCS_API_SUPPORT_TAGFILEINDEX_HPP + +// The reading half of tagfile support. + +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs { + +/** The symbols documented outside this corpus, and where to find them. + + A reference in a doc comment can name a symbol MrDocs never + extracted. A tagfile says which symbols another documentation set + covers and which page each one is on, so a reference to one of them + can still become a link instead of plain text. +*/ +class MRDOCS_DECL + TagfileIndex +{ +public: + /** Where one symbol is documented. + + The parts are what a tagfile offers, joined into a URL by + @ref find. + */ + struct Target + { + /** URL the documentation set is published under. + */ + std::string baseUrl; + /** Name of the page within that documentation set. + */ + std::string page; + /** Anchor on that page; empty for a whole-page entry. + */ + std::string anchor; + }; + + /** Record where a symbol is documented. + + The first target recorded for a name is the one kept, so reading + a name a second time leaves the index as it was. + + @return `true` if the target was recorded, `false` if the name + was already known or either the name or the page is empty. + + @param qualifiedName The fully qualified name of the symbol. + @param target Where the symbol is documented. + */ + bool + insert(std::string_view qualifiedName, Target target); + + /** Return the URL documenting a symbol, or nothing if it has none. + + @param qualifiedName The fully qualified name to look for. + */ + std::optional + find(std::string_view qualifiedName) const; + + /** Return whether the index holds nothing. + */ + bool + empty() const noexcept; + + /** Return how many symbols the index holds. + + Reported per tagfile as it is read, since a tagfile that + contributes nothing is the first thing to suspect when a + reference to it stays unresolved. + */ + std::size_t + size() const noexcept; + +private: + std::map> targets_; +}; + +} // mrdocs + +#endif // MRDOCS_API_SUPPORT_TAGFILEINDEX_HPP diff --git a/src/mrdocs/Config.cpp b/src/mrdocs/Config.cpp index d615109b48d..13b26de34d1 100644 --- a/src/mrdocs/Config.cpp +++ b/src/mrdocs/Config.cpp @@ -7,6 +7,7 @@ // Copyright (c) 2023 Vinnie Falco (vinnie.falco@gmail.com) // Copyright (c) 2023 Alan de Freitas (alandefreitas@gmail.com) // Copyright (c) 2023 Krystian Stasiowski (sdkrystian@gmail.com) +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) // // Official repository: https://github.com/cppalliance/mrdocs // @@ -634,9 +635,10 @@ load_file( MRDOCS_TRY(c.normalize(dirs)); // Startup forces the log level low (errors only) so option parsing stays // quiet; now that the configured level is known, restore it and surface - // the unknown-key warnings that were deferred until this point. + // the warnings that were deferred until this point. report::setMinimumLevel(static_cast(c.logLevel)); c.reportUnknownConfigKeys(); + c.reportDeprecatedOptions(); return {}; } @@ -652,6 +654,10 @@ load_file( } struct ConfigSchemaVisitor { + // Where to leave the deprecated options this pass runs into. Owned by + // the Config being normalized. + std::vector* deprecated = nullptr; + template Expected operator()( @@ -714,6 +720,24 @@ struct ConfigSchemaVisitor { return {}; } + /* Note an option marked as deprecated, for + `reportDeprecatedOptions` to announce. + + Saying it here would say it to nobody, as normalization runs while + the reporting level is still forced down to errors only. + */ + void + recordIfDeprecated( + std::string_view const name, + bool const isDefault, + ConfigSchema::OptionProperties const& opts) const + { + if (deprecated && opts.deprecated && !isDefault) + { + deprecated->push_back({std::string(name), *opts.deprecated}); + } + } + Expected normalizeString( ConfigSchema& self, @@ -722,6 +746,7 @@ struct ConfigSchemaVisitor { ReferenceDirectories const& dirs, ConfigSchema::OptionProperties const& opts, bool const usingDefault) const { + recordIfDeprecated(name, usingDefault || value.empty(), opts); if (!value.empty() && (opts.type == ConfigSchema::OptionType::Path || opts.type == ConfigSchema::OptionType::DirPath @@ -741,6 +766,16 @@ struct ConfigSchemaVisitor { } } } + if (name == "tagfile" && !value.empty()) + { + // The option this one was renamed to is declared before it, + // and so is normalized first: the path handed over here is + // the one that survives. Saying so is left to + // reportDeprecatedOptions, because this runs while the + // reporting level is still forced down to errors only. + MRDOCS_ASSERT(opts.deprecated); + self.outputTagfile = value; + } return {}; } @@ -939,6 +974,11 @@ struct ConfigSchemaVisitor { T& value, ConfigSchema::OptionProperties const& opts) const { + recordIfDeprecated( + name, + std::holds_alternative(opts.defaultValue) + && std::cmp_equal(value, std::get(opts.defaultValue)), + opts); MRDOCS_CHECK( !opts.minValue || std::cmp_greater_equal(value, *opts.minValue), formatError( @@ -969,11 +1009,7 @@ struct ConfigSchemaVisitor { static_cast(ConfigSchema::LogLevel::Fatal) == static_cast(report::Level::fatal)); MRDOCS_ASSERT(opts.deprecated); - report::warn( - "`report` option is deprecated, use `log-level` instead"); auto const logLevel = static_cast(value); - auto logLevelStr = ConfigSchema::toString(logLevel); - report::warn("`report` option: setting `log-level` to \"{}\"", logLevelStr); self.logLevel = logLevel; return {}; } @@ -1130,7 +1166,9 @@ Expected Config:: normalize(ReferenceDirectories const& dirs) { - MRDOCS_TRY(ConfigSchema::normalize(dirs, ConfigSchemaVisitor{})); + deprecatedOptions.clear(); + MRDOCS_TRY(ConfigSchema::normalize( + dirs, ConfigSchemaVisitor{&deprecatedOptions})); return {}; } @@ -1155,4 +1193,18 @@ reportUnknownConfigKeys() const } } +void +Config:: +reportDeprecatedOptions() const +{ + auto const level = warnAsError + ? report::Level::error + : report::Level::warn; + for (DeprecatedOption const& option : deprecatedOptions) + { + report::log(level, "`{}` option is deprecated: {}", + option.name, option.advice); + } +} + } // mrdocs diff --git a/src/mrdocs/ConfigOptions.json b/src/mrdocs/ConfigOptions.json index eb5d5b46394..5afb2606b05 100644 --- a/src/mrdocs/ConfigOptions.json +++ b/src/mrdocs/ConfigOptions.json @@ -491,15 +491,33 @@ "must-exist": true }, { - "name": "tagfile", + "name": "input-tagfiles", + "brief": "Tagfiles of other documentation sets to link against", + "details": "A map of tagfile path to the base URL under which the documentation set it describes is published. A reference in a doc comment that names no symbol in this corpus, such as `@ref std::vector`, becomes a link into that set when one of these tagfiles lists the name. The name is resolved from the scope it is written in outward, as any other reference is, and is never shortened to reach an entry. Relative paths are resolved against the directory of the configuration file. On the command line, each entry is passed as `--input-tagfiles==`, which may be repeated. Doxygen writes a tagfile for its own output with `GENERATE_TAGFILE`, MrDocs with the `output-tagfile` option.", + "type": "map", + "default": {} + }, + { + "name": "output-tagfile", "brief": "Path for the tagfile", - "details": "Specifies the full path (filename) where the generated tagfile should be saved. If left empty, no tagfile will be generated.", + "details": "Specifies the full path (filename) where the generated tagfile should be saved. If left empty, no tagfile will be generated. The tagfiles of other documentation sets are read with the `input-tagfiles` option.", "type": "file-path", "default": "/reference.tag.xml", "relative-to": "", "must-exist": false, "should-exist": false }, + { + "name": "tagfile", + "brief": "Path for the tagfile (deprecated)", + "details": "Deprecated: this is the old name for `output-tagfile`. Setting it still sets that option, with a warning. An empty value leaves `output-tagfile` alone.", + "type": "file-path", + "default": "", + "relative-to": "", + "must-exist": false, + "should-exist": false, + "deprecated": "Use `output-tagfile` instead" + }, { "name": "legible-names", "brief": "Use legible names for anchors", diff --git a/src/mrdocs/Corpus.cpp b/src/mrdocs/Corpus.cpp index 94195a9ceca..5c953e7956d 100644 --- a/src/mrdocs/Corpus.cpp +++ b/src/mrdocs/Corpus.cpp @@ -33,6 +33,8 @@ #include #include #include +#include +#include #include #include #include @@ -41,6 +43,24 @@ namespace mrdocs { namespace { +// The symbols every configured tagfile documents, in one index. +Expected +loadInputTagfiles(Config const& config) +{ + Expected result; + TagfileIndex index; + for (auto const& [path, baseUrl]: config.inputTagfiles) + { + std::string const file = files::makeAbsolute(path, config.configDir()); + std::size_t const known = index.size(); + MRDOCS_TRY(loadTagfile(index, file, baseUrl)); + report::debug(" - \"{}\": {} symbols documented elsewhere", + path, index.size() - known); + } + result = std::move(index); + return result; +} + bool isTransparent(Symbol const& info) { @@ -522,6 +542,11 @@ Corpus::build( corpus.info_ = std::move(results); corpus.undocumented_ = std::move(undocumented); + // ------------------------------------------ + // Read the tagfiles of other documentation sets + // ------------------------------------------ + MRDOCS_TRY(corpus.externalSymbols_, loadInputTagfiles(config)); + // ------------------------------------------ // Finalize corpus // ------------------------------------------ @@ -788,6 +813,42 @@ lookup(SymbolID const& context, std::string_view name) return lookupImpl(*this, context, name); } +std::optional +Corpus:: +externalUrl(SymbolID const& context, std::string_view name) const +{ + std::optional result; + std::string_view const scopeQualifier = "::"; + if (name.starts_with("scopeQualifier")) + { + result = externalSymbols_.find(name.substr(scopeQualifier.size())); + } + else + { + // Each scope the name may be relative to, innermost first. + Symbol const* scope = find(context); + while (scope && !result) + { + std::string candidate = Corpus::qualifiedName(*scope); + if (!candidate.empty()) + { + candidate += scopeQualifier; + candidate += name; + result = externalSymbols_.find(candidate); + } + scope = scope->id == SymbolID::global + ? nullptr + : find(scope->Parent); + } + if (!result) + { + // The name as written, naming the scope it is in itself. + result = externalSymbols_.find(name); + } + } + return result; +} + template Expected Corpus:: diff --git a/src/mrdocs/Generators/hbs/HandlebarsGenerator.cpp b/src/mrdocs/Generators/hbs/HandlebarsGenerator.cpp index 212937da188..b167cfacb21 100644 --- a/src/mrdocs/Generators/hbs/HandlebarsGenerator.cpp +++ b/src/mrdocs/Generators/hbs/HandlebarsGenerator.cpp @@ -146,7 +146,7 @@ build(Corpus const& corpus, Config const& config) const // Resolve where this generator writes (a directory for a multi-page // render, a file or directory for a single document) from the config. std::string const outputPath = getGeneratorOutputPath(*this, config); - std::string_view const configuredTagfile = config.tagfile; + std::string_view const configuredTagfile = config.outputTagfile; // The tagfile and the copied stylesheets land in the same directory as // the generator's output, so they never collide between generators. diff --git a/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp b/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp index 99637669dee..1162bd1a900 100644 --- a/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp +++ b/src/mrdocs/Metadata/Finalizers/DocCommentFinalizer.cpp @@ -706,6 +706,11 @@ DocCommentFinalizer::resolveReference( auto& res = const_cast(*resRef); ref.id = res.id; } + else if (std::optional url = + corpus_.externalUrl(ctx.id, ref.literal)) + { + ref.href = *std::move(url); + } else if ( emitWarning && config_.warnings && diff --git a/src/mrdocs/Support/TagfileIndex.cpp b/src/mrdocs/Support/TagfileIndex.cpp new file mode 100644 index 00000000000..cf23f26b1a4 --- /dev/null +++ b/src/mrdocs/Support/TagfileIndex.cpp @@ -0,0 +1,88 @@ +// +// 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 + +namespace mrdocs { + +namespace { + +// Join the parts of an external URL with exactly one separator between +// them. +std::string +makeUrl(TagfileIndex::Target const& target) +{ + std::string url = target.baseUrl; + + // A configured base URL may or may not end with a slash, while a + // page name read from a tagfile never begins with one. + if (!url.empty() && + !url.ends_with('/')) + { + url += '/'; + } + url += target.page; + if (!target.anchor.empty()) + { + url += '#'; + url += target.anchor; + } + return url; +} + +} // (anon) + +bool +TagfileIndex:: +insert( + std::string_view qualifiedName, + Target target) +{ + bool const usable = + !qualifiedName.empty() && + !target.page.empty() && + !targets_.contains(qualifiedName); + if (usable) + { + targets_.emplace(std::string(qualifiedName), std::move(target)); + } + return usable; +} + +std::optional +TagfileIndex:: +find(std::string_view qualifiedName) const +{ + std::optional result; + std::map>::const_iterator const it = + targets_.find(qualifiedName); + if (it != targets_.end()) + { + result = makeUrl(it->second); + } + return result; +} + +bool +TagfileIndex:: +empty() const noexcept +{ + return targets_.empty(); +} + +std::size_t +TagfileIndex:: +size() const noexcept +{ + return targets_.size(); +} + +} // mrdocs diff --git a/src/mrdocs/Support/TagfileReader.cpp b/src/mrdocs/Support/TagfileReader.cpp new file mode 100644 index 00000000000..b63ad6f0bc7 --- /dev/null +++ b/src/mrdocs/Support/TagfileReader.cpp @@ -0,0 +1,925 @@ +// +// Licensed under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com) +// +// Official repository: https://github.com/cppalliance/mrdocs +// + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mrdocs { + +namespace { + +// What a tagfile is made of, once the punctuation is out of the way. +enum class TokenKind +{ + Open, // + Close, // + Empty, // + Text, // between tags + End // nothing left +}; + +struct Token +{ + TokenKind kind = TokenKind::End; + std::string name; + std::string kindAttribute; + std::string text; +}; + +struct Attribute +{ + std::string name; + std::string value; +}; + +bool +isSpace(char c) +{ + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} + +bool +isNameStart(char c) +{ + return (c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || c == '_' + || c == ':'; +} + +bool +isNameChar(char c) +{ + return isNameStart(c) + || (c >= '0' && c <= '9') + || c == '-' + || c == '.'; +} + +// Append the UTF-8 encoding of one code point. +void +appendUtf8(std::string& out, char32_t code) +{ + if (code < 0x80) + { + out += static_cast(code); + } + else if (code < 0x800) + { + out += static_cast(0xC0 | (code >> 6)); + out += static_cast(0x80 | (code & 0x3F)); + } + else if (code < 0x10000) + { + out += static_cast(0xE0 | (code >> 12)); + out += static_cast(0x80 | ((code >> 6) & 0x3F)); + out += static_cast(0x80 | (code & 0x3F)); + } + else + { + out += static_cast(0xF0 | (code >> 18)); + out += static_cast(0x80 | ((code >> 12) & 0x3F)); + out += static_cast(0x80 | ((code >> 6) & 0x3F)); + out += static_cast(0x80 | (code & 0x3F)); + } +} + +// What a named reference stands for, if it is one XML predefines. +std::optional +namedEntity(std::string_view body) +{ + static constexpr std::pair predefined[] = { + {"lt", '<'}, + {"gt", '>'}, + {"amp", '&'}, + {"quot", '"'}, + {"apos", '\''} + }; + std::optional result; + for (auto const& [name, character]: predefined) + { + if (name == body) + { + result = character; + break; + } + } + return result; +} + +/* The code point a character reference names, or nothing if it names + none: the digits may be missing, or not digits, or past the end of + Unicode. + + @param digits What follows the `#`, read as hexadecimal if it starts + with an `x` and as decimal otherwise. +*/ +std::optional +referencedCodePoint(std::string_view digits) +{ + std::optional result; + bool const hex = digits.starts_with("x") || digits.starts_with("X"); + std::string_view const number = hex ? digits.substr(1) : digits; + std::uint32_t code = 0; + if (!number.empty() + && std::from_chars( + number.data(), + number.data() + number.size(), + code, + hex ? 16 : 10).ec == std::errc{} + && code <= 0x10FFFF) + { + result = static_cast(code); + } + return result; +} + +/* The text a reference stands for: the five XML has names for, plus + the numeric ones, which a tagfile uses for anything outside plain + ASCII. + + @param body What is between the `&` and the `;`. + @param line The line the reference is on, named by the error. +*/ +Expected +expandedReference(std::string_view body, std::size_t line) +{ + Expected result; + if (std::optional const named = namedEntity(body)) + { + result = std::string(1, *named); + } + else if (!body.starts_with("#")) + { + result = Unexpected(formatError( + "the entity reference \"&{};\" on line {} of a tagfile, " + "where only the predefined ones and character references " + "may appear", body, line)); + } + else if (std::optional const code = + referencedCodePoint(body.substr(1))) + { + std::string text; + appendUtf8(text, *code); + result = std::move(text); + } + else + { + result = Unexpected(formatError( + "the character reference \"&{};\" on line {} of a tagfile, " + "which is malformed or outside Unicode", body, line)); + } + return result; +} + +/* A scanner over the constructs a tagfile is written with. + + It reports the line it is on so that a file which is not one can say + where it stopped being one. +*/ +class Scanner +{ + std::string_view in_; + std::size_t pos_ = 0; + std::size_t line_ = 1; + +public: + explicit + Scanner(std::string_view in) + : in_(in) + { + } + + std::size_t + line() const noexcept + { + return line_; + } + + Expected + next(); + +private: + bool + done() const noexcept + { + return pos_ >= in_.size(); + } + + char + peek(std::size_t ahead = 0) const noexcept + { + return pos_ + ahead < in_.size() ? in_[pos_ + ahead] : '\0'; + } + + void + advance() + { + if (peek() == '\n') + { + ++line_; + } + ++pos_; + } + + bool + match(std::string_view s) const noexcept + { + return in_.substr(pos_).starts_with(s); + } + + void + skip(std::size_t n) + { + while (n-- != 0 && !done()) + { + advance(); + } + } + + void + skipSpace() + { + while (!done() && isSpace(peek())) + { + advance(); + } + } + + std::string + takeName(); + + Expected + skipUntil(std::string_view terminator); + + Expected + skipIgnorableMarkup(); + + Expected + appendReference(std::string& out); + + Expected + takeCharacterData(char stop); + + Expected + takeText(); + + Expected + takeAttributeValue(); + + Expected + takeAttribute(); + + Expected + takeOpenTag(); + + Expected + takeCloseTag(); + + Expected + takeTag(); +}; + +std::string +Scanner:: +takeName() +{ + std::string result; + while (!done() && isNameChar(peek())) + { + result += peek(); + advance(); + } + return result; +} + +Expected +Scanner:: +skipUntil(std::string_view terminator) +{ + Expected result; + while (!done() && !match(terminator)) + { + advance(); + } + if (done()) + { + result = Unexpected(formatError( + "unterminated \"{}\" in a tagfile", terminator)); + } + else + { + skip(terminator.size()); + } + return result; +} + +/* The markup a tagfile carries nothing in: the declaration it opens + with, and comments, which may appear between any two tags. A + construct that would need more of XML than this reads is an error, + since nothing generating a tagfile emits one. +*/ +Expected +Scanner:: +skipIgnorableMarkup() +{ + Expected result; + bool ignorable = true; + while (ignorable && result.has_value()) + { + if (match(""); + } + else if (match(""); + } + else if (match(" +Scanner:: +appendReference(std::string& out) +{ + Expected result; + std::size_t const semicolon = in_.find(';', pos_); + if (semicolon == std::string_view::npos) + { + result = Unexpected(formatError( + "an unterminated entity reference on line {} of a tagfile", + line_)); + } + else + { + Expected const text = expandedReference( + in_.substr(pos_ + 1, semicolon - pos_ - 1), line_); + if (text.has_value()) + { + out += *text; + skip(semicolon - pos_ + 1); + } + else + { + result = Unexpected(text.error()); + } + } + return result; +} + +/* A run of character data with the references in it expanded, ending + at `stop` or at the end of the input, whichever comes first. The + delimiter itself is left unread. +*/ +Expected +Scanner:: +takeCharacterData(char stop) +{ + Expected result; + std::string text; + while (!done() && peek() != stop) + { + if (peek() == '&') + { + MRDOCS_TRY(appendReference(text)); + } + else + { + text += peek(); + advance(); + } + } + result = std::move(text); + return result; +} + +// The text between two tags. +Expected +Scanner:: +takeText() +{ + Expected result; + Token token; + token.kind = TokenKind::Text; + MRDOCS_TRY(token.text, takeCharacterData('<')); + result = std::move(token); + return result; +} + +/* The value of an attribute, which is quoted either way and holds the + same references text does. +*/ +Expected +Scanner:: +takeAttributeValue() +{ + Expected result; + char const quote = peek(); + if (quote != '"' && quote != '\'') + { + result = Unexpected(formatError( + "an unquoted attribute value on line {} of a tagfile", line_)); + } + else + { + advance(); + MRDOCS_TRY(std::string value, takeCharacterData(quote)); + if (done()) + { + result = Unexpected(formatError( + "an unterminated attribute value in a tagfile")); + } + else + { + advance(); + result = std::move(value); + } + } + return result; +} + +// One `name="value"` pair. +Expected +Scanner:: +takeAttribute() +{ + Expected result; + Attribute attribute; + attribute.name = takeName(); + skipSpace(); + if (attribute.name.empty()) + { + result = Unexpected(formatError( + "a malformed attribute on line {} of a tagfile", line_)); + } + else if (peek() != '=') + { + result = Unexpected(formatError( + "an attribute without a value on line {} of a tagfile", line_)); + } + else + { + advance(); + skipSpace(); + MRDOCS_TRY(attribute.value, takeAttributeValue()); + result = std::move(attribute); + } + return result; +} + +/* An opening tag, which turns out to be an empty element if it ends in + `/>`. Of its attributes only `kind` is kept: it is the one a tagfile + reads anything from. +*/ +Expected +Scanner:: +takeOpenTag() +{ + Expected result; + Token token; + token.kind = TokenKind::Open; + token.name = takeName(); + bool complete = false; + while (!complete && result.has_value()) + { + skipSpace(); + if (done()) + { + result = Unexpected(formatError( + "an unterminated tag in a tagfile")); + } + else if (peek() == '>') + { + advance(); + complete = true; + } + else if (peek() == '/' && peek(1) == '>') + { + skip(2); + token.kind = TokenKind::Empty; + complete = true; + } + else + { + MRDOCS_TRY(Attribute attribute, takeAttribute()); + if (attribute.name == "kind") + { + token.kindAttribute = std::move(attribute.value); + } + } + } + if (complete) + { + result = std::move(token); + } + return result; +} + +// A closing tag, positioned just after its ` +Scanner:: +takeCloseTag() +{ + Expected result; + Token token; + token.kind = TokenKind::Close; + token.name = takeName(); + skipSpace(); + if (peek() != '>') + { + result = Unexpected(formatError( + "a malformed closing tag on line {} of a tagfile", line_)); + } + else + { + advance(); + result = std::move(token); + } + return result; +} + +// A tag, positioned at its `<`. +Expected +Scanner:: +takeTag() +{ + Expected result; + advance(); // '<' + if (peek() == '/') + { + advance(); + result = takeCloseTag(); + } + else if (!isNameStart(peek())) + { + result = Unexpected(formatError( + "a malformed tag on line {} of a tagfile", line_)); + } + else + { + result = takeOpenTag(); + } + return result; +} + +Expected +Scanner:: +next() +{ + Expected result; + MRDOCS_TRY(skipIgnorableMarkup()); + if (done()) + { + result = Token{}; + } + else if (peek() == '<') + { + result = takeTag(); + } + else + { + result = takeText(); + } + return result; +} + +// Whether a compound of this kind names a scope a member belongs to. +bool +isScopeKind(std::string_view kind) +{ + return kind == "namespace" + || kind == "class" + || kind == "struct" + || kind == "union"; +} + +// The text of an element known to hold nothing else, left at its close. +Expected +readElementText(Scanner& scanner, std::string_view element) +{ + Expected result; + std::string text; + while (true) + { + MRDOCS_TRY(Token token, scanner.next()); + if (token.kind == TokenKind::Text) + { + text += token.text; + } + else if (token.kind == TokenKind::Close && token.name == element) + { + result = text; + break; + } + else if (token.kind == TokenKind::End) + { + result = Unexpected(formatError( + "\"{}\" is left open in a tagfile", element)); + break; + } + else + { + result = Unexpected(formatError( + "\"{}\" holds a \"{}\" on line {} of a tagfile", + element, token.name, scanner.line())); + break; + } + } + return result; +} + +// Everything up to the close of the element just opened, discarded. +Expected +skipElement(Scanner& scanner, std::string_view element) +{ + Expected result; + std::size_t depth = 1; + while (depth != 0) + { + MRDOCS_TRY(Token token, scanner.next()); + if (token.kind == TokenKind::Open) + { + ++depth; + } + else if (token.kind == TokenKind::Close) + { + --depth; + } + else if (token.kind == TokenKind::End) + { + result = Unexpected(formatError( + "\"{}\" is left open in a tagfile", element)); + break; + } + } + return result; +} + +/* Each element inside the one just opened, in turn. + + An element the reader has no use for is skipped whole, so a tagfile + may carry any number of them, holding anything, at no cost. What it + must carry is the closing tag: a file that ends before it has been + cut short, and is rejected rather than read as far as it goes. + + @param scanner The scanner, positioned just after the opening tag. + @param element The name in that opening tag, whose closing tag ends + the walk. + @param onChild Called with the opening token of each element inside, + and returns whether it read that element up to its close. One it + leaves unread is skipped. +*/ +template +Expected +readChildren( + Scanner& scanner, + std::string_view element, + OnChild onChild) +{ + Expected result; + bool open = true; + while (open && result.has_value()) + { + MRDOCS_TRY(Token token, scanner.next()); + if (token.kind == TokenKind::Close && token.name == element) + { + open = false; + } + else if (token.kind == TokenKind::End) + { + result = Unexpected(formatError( + "\"{}\" is left open in a tagfile", element)); + } + else if (token.kind == TokenKind::Open) + { + MRDOCS_TRY(bool const handled, onChild(token)); + if (!handled) + { + result = skipElement(scanner, token.name); + } + } + } + return result; +} + +struct Member +{ + std::string name; + std::string anchorFile; + std::string anchor; +}; + +// What a `` says about the member it documents. +Expected +readMember(Scanner& scanner) +{ + Expected result; + Member member; + MRDOCS_TRY(readChildren(scanner, "member", + [&](Token const& token) -> Expected + { + bool handled = true; + if (token.name == "name") + { + MRDOCS_TRY(member.name, readElementText(scanner, "name")); + } + else if (token.name == "anchorfile") + { + MRDOCS_TRY(member.anchorFile, + readElementText(scanner, "anchorfile")); + } + else if (token.name == "anchor") + { + MRDOCS_TRY(member.anchor, + readElementText(scanner, "anchor")); + } + else + { + handled = false; + } + return handled; + })); + result = std::move(member); + return result; +} + +struct Compound +{ + std::string name; + std::string fileName; + std::vector members; +}; + +// What a `` says about the scope it documents. +Expected +readCompound(Scanner& scanner) +{ + Expected result; + Compound compound; + MRDOCS_TRY(readChildren(scanner, "compound", + [&](Token const& token) -> Expected + { + bool handled = true; + if (token.name == "name") + { + MRDOCS_TRY(compound.name, readElementText(scanner, "name")); + } + else if (token.name == "filename") + { + MRDOCS_TRY(compound.fileName, + readElementText(scanner, "filename")); + } + else if (token.name == "member") + { + MRDOCS_TRY(Member member, readMember(scanner)); + compound.members.push_back(std::move(member)); + } + else + { + handled = false; + } + return handled; + })); + result = std::move(compound); + return result; +} + +// What a compound and its members document, as index entries. +void +recordCompound( + TagfileIndex& index, + std::string_view baseUrl, + Compound const& compound) +{ + index.insert( + compound.name, + {std::string(baseUrl), compound.fileName, ""}); + for (Member const& member: compound.members) + { + std::string qualified = compound.name; + qualified += "::"; + qualified += member.name; + std::string const& page = member.anchorFile.empty() + ? compound.fileName + : member.anchorFile; + index.insert( + qualified, + {std::string(baseUrl), page, member.anchor}); + } +} + +/* The opening tag of the root element, which a tagfile names `tagfile`. + + @return Whether there is anything inside it: a file with no elements + at all documents nothing, and so does one whose root is written + ``. + + @param scanner The scanner, positioned at the start of the file. +*/ +Expected +enterTagfileElement(Scanner& scanner) +{ + Expected result = false; + bool looking = true; + while (looking && result.has_value()) + { + MRDOCS_TRY(Token token, scanner.next()); + // Whitespace comes before the root element, not instead of it. + looking = token.kind == TokenKind::Text; + bool const isRoot = !looking && token.kind != TokenKind::End; + if (isRoot && token.name != "tagfile") + { + result = Unexpected(formatError( + "\"{}\" where a tagfile begins, on line {}", + token.name, scanner.line())); + } + else if (isRoot) + { + // An empty root element holds no compounds. + result = token.kind == TokenKind::Open; + } + } + return result; +} + +} // (unnamed) + +Expected +readTagfile( + TagfileIndex& index, + std::string_view contents, + std::string_view baseUrl) +{ + Expected result; + Scanner scanner(contents); + MRDOCS_TRY(bool const inTagfile, enterTagfileElement(scanner)); + if (inTagfile) + { + result = readChildren(scanner, "tagfile", + [&](Token const& token) -> Expected + { + bool const handled = token.name == "compound"; + if (handled) + { + MRDOCS_TRY(Compound compound, readCompound(scanner)); + if (isScopeKind(token.kindAttribute)) + { + recordCompound(index, baseUrl, compound); + } + } + return handled; + }); + } + return result; +} + +Expected +loadTagfile( + TagfileIndex& index, + std::string_view path, + std::string_view baseUrl) +{ + Expected result; + MRDOCS_TRY(std::string const contents, files::getFileText(path)); + Expected const read = readTagfile(index, contents, baseUrl); + if (!read.has_value()) + { + result = Unexpected(formatError( + "{}: {}", path, read.error().reason())); + } + return result; +} + +} // mrdocs diff --git a/src/mrdocs/Support/TagfileReader.hpp b/src/mrdocs/Support/TagfileReader.hpp new file mode 100644 index 00000000000..7dba34c3633 --- /dev/null +++ b/src/mrdocs/Support/TagfileReader.hpp @@ -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 +// + +#ifndef MRDOCS_LIB_SUPPORT_TAGFILEREADER_HPP +#define MRDOCS_LIB_SUPPORT_TAGFILEREADER_HPP + +#include +#include +#include +#include + +namespace mrdocs { + +/** Parses a tagfile's contents into an index. + + A tagfile is written in a small, machine-generated subset of XML, + and this reads that subset. + + @return Nothing, or an error naming the line that stopped it. + + @param index The index to record the symbols in. + @param contents The whole content of the tagfile. + @param baseUrl The URL the documentation set is published under, + which every target from this tagfile is resolved against. +*/ +Expected +readTagfile( + TagfileIndex& index, + std::string_view contents, + std::string_view baseUrl); + +/** Read a tagfile from a file into an index. + + Reads a tagfile from disk, naming the file in whatever error occurs, + since a run may read several. + + @return Nothing, or an error naming the file that stopped it. + + @param index The index to record the symbols in. + @param path The path of the tagfile to read. + @param baseUrl The URL the documentation set is published under. +*/ +Expected +loadTagfile( + TagfileIndex& index, + std::string_view path, + std::string_view baseUrl); + +} // mrdocs + +#endif // MRDOCS_LIB_SUPPORT_TAGFILEREADER_HPP diff --git a/tests/golden/TestRunner.cpp b/tests/golden/TestRunner.cpp index 643f7f47e94..f0ba0910d4e 100644 --- a/tests/golden/TestRunner.cpp +++ b/tests/golden/TestRunner.cpp @@ -51,7 +51,7 @@ loadDirConfig( dirConfig.sourceRoot = dirPath; dirConfig.input = {dirPath}; std::string const& configPath = files::appendPath(dirPath, "mrdocs.yml"); - bool const hasTagfileOverride = !dirConfig.tagfile.empty(); + bool const hasTagfileOverride = !dirConfig.outputTagfile.empty(); if (files::exists(configPath)) { MRDOCS_TRY(Config::load_file(dirConfig, configPath)); @@ -63,7 +63,7 @@ loadDirConfig( // Golden tests shouldn't emit tagfiles unless a test explicitly requests one. if (!hasTagfileOverride) { - dirConfig.tagfile.clear(); + dirConfig.outputTagfile.clear(); } return dirConfig; } @@ -182,7 +182,7 @@ buildTestLayout( bool const dirMultipage = loaded.dirMultipage; bool const hasFileConfig = loaded.hasFileConfig; Config settings = std::move(loaded.settings); - bool const hasTagfileOverride = !settings.tagfile.empty(); + bool const hasTagfileOverride = !settings.outputTagfile.empty(); // The no-op generator produces no output: normalize and return without a // scratch directory. @@ -190,7 +190,7 @@ buildTestLayout( { MRDOCS_TRY(settings.normalize(dirs)); if (!hasTagfileOverride) - settings.tagfile.clear(); + settings.outputTagfile.clear(); return std::pair{std::move(settings), std::optional{}}; } @@ -199,12 +199,12 @@ buildTestLayout( std::optional scratch(std::in_place, "mrdocs-test-output"); MRDOCS_CHECK_OR(!scratch->failed(), Unexpected(scratch->error())); settings.output = std::string(scratch->path()); - if (!settings.tagfile.empty() && - !llvm::sys::path::is_absolute(settings.tagfile)) - settings.tagfile = files::appendPath(scratch->path(), settings.tagfile); + if (!settings.outputTagfile.empty() && + !llvm::sys::path::is_absolute(settings.outputTagfile)) + settings.outputTagfile = files::appendPath(scratch->path(), settings.outputTagfile); MRDOCS_TRY(settings.normalize(dirs)); if (!hasTagfileOverride) - settings.tagfile.clear(); + settings.outputTagfile.clear(); // Validate that the on-disk fixtures match the configured mode. The // comparison recomputes these paths the same way. diff --git a/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.adoc b/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.adoc new file mode 100644 index 00000000000..e80ef95146f --- /dev/null +++ b/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.adoc @@ -0,0 +1,78 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Namespaces + +[cols="1"] +|=== +| Name +| link:#other[`other`] +|=== + + +=== Types + +[cols="1,4"] +|=== +| Name| Description +| link:#wrapper[`wrapper`] +| A socket wrapper this corpus documents. +|=== + + +[#other] +== other + +=== Types + +[cols="1,4"] +|=== +| Name| Description +| link:#other-local[`local`] +| A class of ours, in a namespace another set also documents. +|=== + + +[#other-local] +== link:#other[other]::local + +A class of ours, in a namespace another set also documents. + +=== Synopsis + +Declared in `<input‐tagfiles.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +struct local; +---- + +=== Description + +From in here the names need no qualifying, exactly as they would not for a symbol of this corpus: https://example.org/docs/other/socket.html[`socket`], and its https://example.org/docs/other/socket/close.html[`socket::close`]. + +[#wrapper] +== wrapper + +A socket wrapper this corpus documents. + +=== Synopsis + +Declared in `<input‐tagfiles.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +struct wrapper; +---- + +=== Description + +Wraps an https://example.org/docs/other/socket.html[`other::socket`], which another documentation set covers, and is closed with https://example.org/docs/other/socket/close.html[`other::socket::close`]. One is opened by https://example.org/docs/other.html#a1b2c3[`other::connect`], which that set documents on the page of the namespace holding it, while https://example.org/docs/elsewhere.html[`elsewhere`] is a namespace of it with a page of its own. + +`socket` stays plain text, and so does `other::missing`. + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.cpp b/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.cpp new file mode 100644 index 00000000000..cee2280adc2 --- /dev/null +++ b/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.cpp @@ -0,0 +1,26 @@ +/** A socket wrapper this corpus documents. + + Wraps an @ref other::socket, which another documentation set covers, + and is closed with @ref other::socket::close. One is opened by + @ref other::connect, which that set documents on the page of the + namespace holding it, while @ref elsewhere is a namespace of it with + a page of its own. + + @ref socket stays plain text, and so does @ref other::missing. +*/ +struct wrapper +{ +}; + +namespace other { + +/** A class of ours, in a namespace another set also documents. + + From in here the names need no qualifying, exactly as they would not + for a symbol of this corpus: @ref socket, and its @ref socket::close. +*/ +struct local +{ +}; + +} // namespace other diff --git a/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.html b/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.html new file mode 100644 index 00000000000..167037033af --- /dev/null +++ b/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.html @@ -0,0 +1,88 @@ + + +Reference + + + +
+

Reference

+
+
+

Global namespace

+
+

Namespaces

+ + + + + + + +
Name
other
+ +

Types

+ + + + + + + +
NameDescription
wrapper A socket wrapper this corpus documents.
+ +
+
+
+

other

+
+

Types

+ + + + + + + +
NameDescription
local A class of ours, in a namespace another set also documents.
+ +
+
+
+

other::local

+
+

A class of ours, in a namespace another set also documents.

+
+
+

Synopsis

+

Declared in <input-tagfiles.cpp>

+
struct local;
+
+
+

Description

+

From in here the names need no qualifying, exactly as they would not for a symbol of this corpus: socket, and its socket::close.

+
+
+
+
+

wrapper

+
+

A socket wrapper this corpus documents.

+
+
+

Synopsis

+

Declared in <input-tagfiles.cpp>

+
struct wrapper;
+
+
+

Description

+

Wraps an other::socket, which another documentation set covers, and is closed with other::socket::close. One is opened by other::connect, which that set documents on the page of the namespace holding it, while elsewhere is a namespace of it with a page of its own.

+

socket stays plain text, and so does other::missing.

+
+
+ +
+ + + \ No newline at end of file diff --git a/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.xml b/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.xml new file mode 100644 index 00000000000..125f9196d35 --- /dev/null +++ b/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.xml @@ -0,0 +1,189 @@ + + + + index + namespace + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + regular + + 3hmXv2oPCTiQCcSjs7AH9iaKf579 + 45JLuH5RCZNhJNwGBMvrCmq5C8dE + + + + other + other + + + + input-tagfiles.cpp + input-tagfiles.cpp + 15 + 1 + + + + namespace + 3hmXv2oPCTiQCcSjs7AH9iaKf579 + regular + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + + f5HZLD5Fqo5AnPDGf6S3Az6ZaFR + + + + local + local + + + input-tagfiles.cpp + input-tagfiles.cpp + 22 + 1 + + + + record + f5HZLD5Fqo5AnPDGf6S3Az6ZaFR + regular + 3hmXv2oPCTiQCcSjs7AH9iaKf579 + + + + paragraph + + + text + From in here the names need no qualifying, exactly as they would not for a symbol of this corpus: + + + reference + socket + https://example.org/docs/other/socket.html + + + text + , and its + + + reference + socket::close + https://example.org/docs/other/socket/close.html + + + text + . + + + + + + brief + + + text + A class of ours, in a namespace another set also documents. + + + + + struct + + + wrapper + wrapper + + + input-tagfiles.cpp + input-tagfiles.cpp + 11 + 1 + + + + record + 45JLuH5RCZNhJNwGBMvrCmq5C8dE + regular + 4ZrjxJnU1LA5xSyrWMNuXTvSYKwt + + + + paragraph + + + text + Wraps an + + + reference + other::socket + https://example.org/docs/other/socket.html + + + text + , which another documentation set covers, and is closed with + + + reference + other::socket::close + https://example.org/docs/other/socket/close.html + + + text + . One is opened by + + + reference + other::connect + https://example.org/docs/other.html#a1b2c3 + + + text + , which that set documents on the page of the namespace holding it, while + + + reference + elsewhere + https://example.org/docs/elsewhere.html + + + text + is a namespace of it with a page of its own. + + + + + paragraph + + + reference + socket + + + text + stays plain text, and so does + + + reference + other::missing + + + text + . + + + + + + brief + + + text + A socket wrapper this corpus documents. + + + + + struct + + diff --git a/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.yml b/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.yml new file mode 100644 index 00000000000..ef822a6345e --- /dev/null +++ b/tests/golden/fixtures/config/input-tagfiles/input-tagfiles.yml @@ -0,0 +1,6 @@ +generator: [xml, adoc, html] +input-tagfiles: + other.tag.xml: https://example.org/docs/ +# The input names two symbols on purpose that no tagfile documents, to +# show they stay plain text. +warn-broken-ref: false diff --git a/tests/golden/fixtures/config/input-tagfiles/other.tag.xml b/tests/golden/fixtures/config/input-tagfiles/other.tag.xml new file mode 100644 index 00000000000..2f1159eeb46 --- /dev/null +++ b/tests/golden/fixtures/config/input-tagfiles/other.tag.xml @@ -0,0 +1,29 @@ + + + + other + other.html + + void + connect + other.html + a1b2c3 + () + + + + elsewhere + elsewhere.html + + + other::socket + other/socket.html + + void + close + other/socket/close.html + + () + + + diff --git a/tests/golden/fixtures/config/tagfile/tagfile.cpp b/tests/golden/fixtures/config/output-tagfile/output-tagfile.cpp similarity index 100% rename from tests/golden/fixtures/config/tagfile/tagfile.cpp rename to tests/golden/fixtures/config/output-tagfile/output-tagfile.cpp diff --git a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/index.html b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/index.html similarity index 100% rename from tests/golden/fixtures/config/tagfile/tagfile.multipage/html/index.html rename to tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/index.html diff --git a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns.html b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns.html similarity index 100% rename from tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns.html rename to tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns.html diff --git a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer.html b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer.html similarity index 95% rename from tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer.html rename to tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer.html index f069f92cc64..337f8e21981 100644 --- a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer.html +++ b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer.html @@ -13,7 +13,7 @@

ns::outer

Synopsis

-

Declared in <tagfile.cpp>

+

Declared in <output-tagfile.cpp>

struct outer;

Types

diff --git a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/config.html b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/config.html similarity index 93% rename from tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/config.html rename to tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/config.html index e51b9578fe0..fd4e4d77363 100644 --- a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/config.html +++ b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/config.html @@ -13,7 +13,7 @@

ns::outer::config

Synopsis

-

Declared in <tagfile.cpp>

+

Declared in <output-tagfile.cpp>

struct config;

Member Functions

diff --git a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/config/apply.html b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/config/apply.html similarity index 90% rename from tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/config/apply.html rename to tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/config/apply.html index 62be064914c..4a4ad223ecd 100644 --- a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/config/apply.html +++ b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/config/apply.html @@ -11,7 +11,7 @@

ns::outer::

Synopsis

-

Declared in <tagfile.cpp>

+

Declared in <output-tagfile.cpp>

void
 apply();
diff --git a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/method.html b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/method.html similarity index 90% rename from tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/method.html rename to tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/method.html index 8141bbacd30..1d1b708526d 100644 --- a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/method.html +++ b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/method.html @@ -11,7 +11,7 @@

ns::outer::method

Synopsis

-

Declared in <tagfile.cpp>

+

Declared in <output-tagfile.cpp>

void
 method();
diff --git a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/mode.html b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/mode.html similarity index 93% rename from tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/mode.html rename to tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/mode.html index bbdf5441ec0..8024082c36e 100644 --- a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/outer/mode.html +++ b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/outer/mode.html @@ -13,7 +13,7 @@

ns::outer::mode

Synopsis

-

Declared in <tagfile.cpp>

+

Declared in <output-tagfile.cpp>

enum class mode : int;

Members

diff --git a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/response_factory.html b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/response_factory.html similarity index 93% rename from tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/response_factory.html rename to tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/response_factory.html index cdf302d5c39..f580c823247 100644 --- a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/response_factory.html +++ b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/response_factory.html @@ -13,7 +13,7 @@

ns::response_factory

Synopsis

-

Declared in <tagfile.cpp>

+

Declared in <output-tagfile.cpp>

struct response_factory;

Member Functions

diff --git a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/response_factory/make.html b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/response_factory/make.html similarity index 90% rename from tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/response_factory/make.html rename to tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/response_factory/make.html index 5f875d58869..2a723737b9c 100644 --- a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/ns/response_factory/make.html +++ b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/ns/response_factory/make.html @@ -11,7 +11,7 @@

ns::response_

Synopsis

-

Declared in <tagfile.cpp>

+

Declared in <output-tagfile.cpp>

int
 make();
diff --git a/tests/golden/fixtures/config/tagfile/tagfile.multipage/html/reference.tag.xml b/tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/reference.tag.xml similarity index 100% rename from tests/golden/fixtures/config/tagfile/tagfile.multipage/html/reference.tag.xml rename to tests/golden/fixtures/config/output-tagfile/output-tagfile.multipage/html/reference.tag.xml diff --git a/tests/golden/fixtures/config/output-tagfile/output-tagfile.yml b/tests/golden/fixtures/config/output-tagfile/output-tagfile.yml new file mode 100644 index 00000000000..791736d70fe --- /dev/null +++ b/tests/golden/fixtures/config/output-tagfile/output-tagfile.yml @@ -0,0 +1,3 @@ +multipage: true +generator: html +output-tagfile: reference.tag.xml diff --git a/tests/golden/fixtures/config/tagfile/tagfile.yml b/tests/golden/fixtures/config/tagfile/tagfile.yml deleted file mode 100644 index 759dab2d73e..00000000000 --- a/tests/golden/fixtures/config/tagfile/tagfile.yml +++ /dev/null @@ -1,3 +0,0 @@ -multipage: true -generator: html -tagfile: reference.tag.xml diff --git a/tests/golden/fixtures/mrdocs.yml b/tests/golden/fixtures/mrdocs.yml index 2fd10cdeb0f..e14ff66417c 100644 --- a/tests/golden/fixtures/mrdocs.yml +++ b/tests/golden/fixtures/mrdocs.yml @@ -12,5 +12,5 @@ warn-if-undocumented: false warn-no-paramdoc: false warn-unnamed-param: false warn-if-undoc-enum-val: false -tagfile: "" +output-tagfile: "" no-default-styles: true \ No newline at end of file diff --git a/tests/unit/Support/TagfileIndex.cpp b/tests/unit/Support/TagfileIndex.cpp new file mode 100644 index 00000000000..91da58bba06 --- /dev/null +++ b/tests/unit/Support/TagfileIndex.cpp @@ -0,0 +1,118 @@ +// +// 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 + +namespace mrdocs { + +struct TagfileIndexTest +{ + void + testEmpty() + { + TagfileIndex index; + BOOST_TEST(index.empty()); + BOOST_TEST(index.size() == 0); + BOOST_TEST(!index.find("boost::urls::url")); + } + + void + testPage() + { + TagfileIndex index; + BOOST_TEST(index.insert( + "boost::urls::url", + {"https://example.org/url/", "classes/url.html", ""})); + BOOST_TEST(!index.empty()); + BOOST_TEST(index.size() == 1); + BOOST_TEST(index.find("boost::urls::url") == + "https://example.org/url/classes/url.html"); + } + + void + testAnchor() + { + TagfileIndex index; + BOOST_TEST(index.insert( + "boost::urls::url::clear", + {"https://example.org/url/", "classes/url.html", "a1b2c3"})); + BOOST_TEST(index.find("boost::urls::url::clear") == + "https://example.org/url/classes/url.html#a1b2c3"); + } + + // A base URL is configured by hand, so it may or may not have the + // trailing slash the join needs. + void + testBaseUrlSlash() + { + TagfileIndex withSlash; + BOOST_TEST(withSlash.insert("a", {"https://example.org/d/", "p.html", ""})); + BOOST_TEST(withSlash.find("a") == "https://example.org/d/p.html"); + + TagfileIndex withoutSlash; + BOOST_TEST(withoutSlash.insert("a", {"https://example.org/d", "p.html", ""})); + BOOST_TEST(withoutSlash.find("a") == "https://example.org/d/p.html"); + } + + // Only the whole qualified name matches. + void + testExactNames() + { + TagfileIndex index; + BOOST_TEST(index.insert( + "boost::urls::url", {"https://example.org/", "p.html", ""})); + BOOST_TEST(!index.find("url")); + BOOST_TEST(!index.find("urls::url")); + BOOST_TEST(!index.find("boost::urls")); + BOOST_TEST(!index.find("boost::urls::url::clear")); + BOOST_TEST(index.find("boost::urls::url")); + } + + void + testFirstOneWins() + { + TagfileIndex index; + BOOST_TEST(index.insert("a", {"https://first.example/", "p.html", ""})); + BOOST_TEST(!index.insert("a", {"https://second.example/", "q.html", ""})); + BOOST_TEST(index.find("a") == "https://first.example/p.html"); + } + + // A tagfile can name a symbol without saying where it is documented, + // and such an entry would link to the top of the documentation set + // rather than to the symbol. + void + testUnusableEntries() + { + TagfileIndex index; + BOOST_TEST(!index.insert("", {"https://example.org/", "p.html", ""})); + BOOST_TEST(!index.insert("a", {"https://example.org/", "", ""})); + BOOST_TEST(index.empty()); + } + + void + run() + { + testEmpty(); + testPage(); + testAnchor(); + testBaseUrlSlash(); + testExactNames(); + testFirstOneWins(); + testUnusableEntries(); + } +}; + +TEST_SUITE( + TagfileIndexTest, + "clang.mrdocs.TagfileIndex"); + +} // mrdocs diff --git a/tests/unit/Support/TagfileReader.cpp b/tests/unit/Support/TagfileReader.cpp new file mode 100644 index 00000000000..b13b44fc889 --- /dev/null +++ b/tests/unit/Support/TagfileReader.cpp @@ -0,0 +1,283 @@ +// +// 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 { + +struct TagfileReaderTest +{ + static + constexpr std::string_view baseUrl_ = "https://example.org/ref/"; + + // Read a tagfile that is expected to be readable. + static + TagfileIndex + read(std::string_view const contents) + { + TagfileIndex index; + auto const result = readTagfile(index, contents, baseUrl_); + BOOST_TEST(result.has_value()); + return index; + } + + void + testEmptyFile() + { + TagfileIndex index; + BOOST_TEST(readTagfile(index, "", baseUrl_).has_value()); + BOOST_TEST(index.empty()); + } + + void + testCompoundAndMember() + { + TagfileIndex const index = read( + R"( + + + ns::outer + ns/outer.html + + void + method + ns/outer/method.html + + () + + + +)"); + BOOST_TEST(index.find("ns::outer") == + "https://example.org/ref/ns/outer.html"); + BOOST_TEST(index.find("ns::outer::method") == + "https://example.org/ref/ns/outer/method.html"); + } + + void + testAnchorOnThePage() + { + TagfileIndex const index = read( + R"( + + ns + ns.html + + f + ns.html + a1b2c3 + + + +)"); + BOOST_TEST(index.find("ns::f") == + "https://example.org/ref/ns.html#a1b2c3"); + } + + // A member with no anchorfile is documented on the compound's page. + void + testMemberWithoutAnchorFile() + { + TagfileIndex const index = read( + R"( + + ns::s + ns/s.html + + value + abc + + + +)"); + BOOST_TEST(index.find("ns::s::value") == + "https://example.org/ref/ns/s.html#abc"); + } + + void + testEntities() + { + TagfileIndex const index = read( + R"( + + ns::vec<T&> + ns/vec.html + + +)"); + BOOST_TEST(index.find("ns::vec") == + "https://example.org/ref/ns/vec.html"); + } + + void + testCharacterReferences() + { + TagfileIndex const index = read( + R"( + + ns::inner + ns/inner.html + + +)"); + BOOST_TEST(index.find("ns::inner") == + "https://example.org/ref/ns/inner.html"); + } + + // The elements Doxygen writes and a tagfile reader has no use for, + // including ones with children of their own. + void + testUnknownElementsAreSkipped() + { + TagfileIndex const index = read( + R"( + + + + ns::derived + ns/derived.html + ns::base + class T + ns::derived::nested + + color + ns/derived.html + e1 + red + + notes + + +)"); + BOOST_TEST(index.find("ns::derived") == + "https://example.org/ref/ns/derived.html"); + BOOST_TEST(index.find("ns::derived::color") == + "https://example.org/ref/ns/derived.html#e1"); + // The nested class is named by a `` reference here, and is + // recorded from its own compound instead, so nothing about it is + // learned from this one. + BOOST_TEST(!index.find("ns::derived::nested")); + BOOST_TEST(!index.find("ns::base")); + } + + // A compound whose name is not a symbol contributes nothing, and in + // particular does not put its members in the index unqualified. + void + testNonScopeCompounds() + { + TagfileIndex const index = read( + R"( + + core.hpp + core_8hpp.html + + f + core_8hpp.html + a1 + + + + intro + intro.html + + + algorithms + group__algorithms.html + + +)"); + BOOST_TEST(index.empty()); + } + + void + testRejectsWhatItCannotRead() + { + TagfileIndex index; + // A document type declaration. + BOOST_TEST(!readTagfile(index, + "", baseUrl_)); + // A character data section. + BOOST_TEST(!readTagfile(index, + "" + "", baseUrl_)); + // Something that is not a tagfile at all. + BOOST_TEST(!readTagfile(index, + "", baseUrl_)); + // A tag that never ends. + BOOST_TEST(!readTagfile(index, " " + "", baseUrl_)); + // Nothing was recorded by any of them. + BOOST_TEST(index.empty()); + } + + /* A file that stops in the middle is rejected rather than read as + far as it goes. + */ + void + testTruncatedFile() + { + TagfileIndex index; + // The root element is left open. + BOOST_TEST(!readTagfile(index, + "ns::c" + "c.html", baseUrl_)); + // A compound is left open. + BOOST_TEST(!readTagfile(index, + "ns::c", + baseUrl_)); + } + + void + testFirstTargetWins() + { + TagfileIndex const index = read( + R"( + + ns::c + first.html + + + ns::c + second.html + + +)"); + BOOST_TEST(index.find("ns::c") == + "https://example.org/ref/first.html"); + } + + void + run() + { + testEmptyFile(); + testCompoundAndMember(); + testAnchorOnThePage(); + testMemberWithoutAnchorFile(); + testEntities(); + testCharacterReferences(); + testUnknownElementsAreSkipped(); + testNonScopeCompounds(); + testRejectsWhatItCannotRead(); + testTruncatedFile(); + testFirstTargetWins(); + } +}; + +TEST_SUITE( + TagfileReaderTest, + "clang.mrdocs.TagfileReader"); + +} // mrdocs diff --git a/utils/bootstrap/src/configs/run_configs.json b/utils/bootstrap/src/configs/run_configs.json index d4259749021..ef4f7ae1575 100644 --- a/utils/bootstrap/src/configs/run_configs.json +++ b/utils/bootstrap/src/configs/run_configs.json @@ -99,7 +99,7 @@ "--addons=${mrdocs_src_dir}/data/mrdocs/addons", "--stdlib-includes=${mrdocs_stdlib_includes}", "--libc-includes=${mrdocs_src_dir}/data/mrdocs/headers/libc-stubs", - "--tagfile=reference.tag.xml", + "--output-tagfile=reference.tag.xml", "--multipage=true", "--concurrency=${num_cores}", "--log-level=debug" diff --git a/utils/bootstrap/src/configs/run_configs.py b/utils/bootstrap/src/configs/run_configs.py index 5b520dbbf1f..1e8c6fadc8d 100644 --- a/utils/bootstrap/src/configs/run_configs.py +++ b/utils/bootstrap/src/configs/run_configs.py @@ -333,7 +333,7 @@ def get_dynamic_run_configs( "--generator=adoc", f"--addons={os.path.join(options.source_dir, 'share', 'mrdocs', 'addons')}", f"--libc-includes={os.path.join(options.source_dir, 'share', 'mrdocs', 'headers', 'libc-stubs')}", - "--tagfile=reference.tag.xml", + "--output-tagfile=reference.tag.xml", "--multipage=true", f"--concurrency={num_cores}", "--log-level=debug",