diff --git a/.cspell.yaml b/.cspell.yaml index 39b5aa5..abe2e23 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -24,12 +24,17 @@ words: - fileassert - frontmatter - GHDL + - hrefs - isysroot - isystem - libc - libclang - libclangsharp - libext + - linkified + - Linkification + - Linkify + - linkify - misattributed - msbuild - MSBuild diff --git a/README.md b/README.md index 44168e9..01d5c4c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,17 @@ # ApiMark -[![Build](https://github.com/DemaConsulting/ApiMark/actions/workflows/build_on_push.yaml/badge.svg)](https://github.com/DemaConsulting/ApiMark/actions/workflows/build_on_push.yaml) - +[![GitHub forks](https://img.shields.io/github/forks/demaconsulting/ApiMark?style=plastic)](https://github.com/demaconsulting/ApiMark/network/members) +[![GitHub stars](https://img.shields.io/github/stars/demaconsulting/ApiMark?style=plastic)](https://github.com/demaconsulting/ApiMark/stargazers) +[![GitHub contributors](https://img.shields.io/github/contributors/demaconsulting/ApiMark?style=plastic)](https://github.com/demaconsulting/ApiMark/graphs/contributors) +[![License](https://img.shields.io/github/license/demaconsulting/ApiMark?style=plastic)](https://github.com/demaconsulting/ApiMark/blob/main/LICENSE) +[![Build](https://img.shields.io/github/actions/workflow/status/demaconsulting/ApiMark/build_on_push.yaml)](https://github.com/demaconsulting/ApiMark/actions/workflows/build_on_push.yaml) +[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=demaconsulting_ApiMark&metric=alert_status)](https://sonarcloud.io/dashboard?id=demaconsulting_ApiMark) +[![Security](https://sonarcloud.io/api/project_badges/measure?project=demaconsulting_ApiMark&metric=security_rating)](https://sonarcloud.io/dashboard?id=demaconsulting_ApiMark) +[![NuGet](https://img.shields.io/nuget/v/DemaConsulting.ApiMark.MSBuild?style=plastic)](https://www.nuget.org/packages/DemaConsulting.ApiMark.MSBuild) + ## Overview ApiMark generates compact, AI-friendly API reference documentation in Markdown @@ -15,21 +22,23 @@ type page — consuming only as much context as the task requires. ## Features -- Generates compact Markdown API reference from source code and doc comments -- Gradual disclosure output: root index → namespace summary → type page → member detail -- C#/.NET support via Mono.Cecil and XML documentation comments -- C++ support via `clang -ast-dump=json` and Doxygen-style doc comments -- MSBuild task integration for `.csproj` and `.vcxproj`-based builds -- `dotnet tool` CLI (`apimark`) covering all supported languages -- Designed for AI consumption — minimal noise, explicit navigation links between levels +- 📄 **Compact Markdown Output** - AI-friendly API reference from source code +- 🔍 **Gradual Disclosure** - Index → namespace → type → member detail +- 🔷 **C#/.NET Support** - Mono.Cecil + XML documentation comments +- ➕ **C++ Support** - `clang -ast-dump=json` + Doxygen-style comments +- 🔧 **MSBuild Integration** - Auto-documents `.csproj` and `.vcxproj` builds +- 🖥️ **CLI Tool** - `apimark` dotnet tool covering all languages +- 🤖 **AI-Optimized** - Minimal noise, explicit navigation links +- 🌐 **Multi-Platform** - Windows, Linux, and macOS on .NET 8, 9, and 10 +- ✅ **Self-Validation** - Built-in qualification tests for regulated environments ## Platform Support | Platform | .NET | C++ | | --- | --- | --- | -| Windows x64 | ✅ | ✅ | -| Linux x64 | ✅ | ✅ | -| macOS (Apple Silicon) | ✅ | ✅ | +| Windows | ✅ | ✅ | +| Linux | ✅ | ✅ | +| macOS | ✅ | ✅ | ## Prerequisites @@ -97,8 +106,16 @@ ApiMark generates documentation automatically after every build. # Generate API documentation from a .NET assembly apimark dotnet --assembly MyProject.dll --xml-doc MyProject.xml --output docs/api -# Generate API documentation from C++ public headers +# Generate API documentation from C++ public headers (document all headers under include/) apimark cpp --includes include/ --output docs/api + +# Document only specific headers using --api-headers patterns (gitignore-style, ordered) +apimark cpp \ + --includes include/ \ + --api-headers "include/**" \ + --api-headers "!include/detail/**" \ + --api-headers "include/detail/public_api.h" \ + --output docs/api ``` Run `apimark --help` for all options. Run `apimark dotnet --help` or `apimark cpp --help` for language-specific options. diff --git a/docs/design/api-mark-cpp.md b/docs/design/api-mark-cpp.md index e933348..a1cc4e9 100644 --- a/docs/design/api-mark-cpp.md +++ b/docs/design/api-mark-cpp.md @@ -49,8 +49,10 @@ ApiMarkCore. compiler flags, then parses the JSON output into structured C++ AST records. - *Contract*: `clang -Xclang -ast-dump=json -fparse-all-comments -fsyntax-only -x c++ -std={standard} -I {roots} -isystem {sysroots} -D {defines} {headers}`. - The clang executable is located automatically (PATH, xcrun on macOS, vswhere on - Windows) or from `CppGeneratorOptions.ClangPath`. + The clang executable is located using this priority order: + (1) `CppGeneratorOptions.ClangPath` when set; + (2) the `APIMARK_CLANG_PATH` environment variable when set; + (3) `clang` on PATH; (4) `xcrun clang` on macOS; (5) vswhere / default LLVM path on Windows. - *Constraints*: clang must be installed and accessible; a clear `InvalidOperationException` is thrown when clang cannot be found. @@ -79,14 +81,16 @@ N/A — not a safety-classified software item. ## Data Flow 1. The caller (ApiMarkTool) constructs `CppGeneratorOptions` with - PublicIncludeRoots, IncludePatterns, ExcludePatterns, SystemIncludePaths, - AdditionalIncludePaths, Defines, CppStandard, AdditionalCompilerArguments, + PublicIncludeRoots, ApiHeaderPatterns, SystemIncludePaths, + Defines, CppStandard, AdditionalCompilerArguments, Visibility, IncludeDeprecated, and LibraryName, then passes an IMarkdownWriterFactory to Generate. -2. CppGenerator enumerates all header files under each PublicIncludeRoot, - applying IncludePatterns and ExcludePatterns, to produce the candidate file - set. Each matched header is parsed as an independent translation unit so that - headers are self-contained. +2. CppGenerator enumerates all header files under each PublicIncludeRoot. + When ApiHeaderPatterns is non-empty, patterns are applied with gitignore-style + last-match-wins semantics to produce the candidate file set; when empty, all + recognized header files under every root are included automatically. Each + matched header is parsed as an independent translation unit so that headers + are self-contained. 3. CppGenerator calls `ClangAstParser.Parse` with all candidate headers and the configured options. `ClangAstParser` invokes `clang -ast-dump=json` and parses the resulting JSON into `CppCompilationResult` containing `CppNamespaceDecl` @@ -96,9 +100,8 @@ N/A — not a safety-classified software item. physically located in the public headers, already filtered by ownership. 5. CppGenerator applies the IsOwnedDeclaration filter to each declaration: only declarations whose source file normalizes to a path under a - PublicIncludeRoot, matches IncludePatterns, and does not match - ExcludePatterns are documented. System and third-party declarations are - used for type resolution only. + PublicIncludeRoot that was selected by ApiHeaderPatterns are documented. + System and third-party declarations are used for type resolution only. 6. For each owned declaration, CppGenerator derives the canonical #include path as the source file path relative to its matching PublicIncludeRoot, expressed with forward slashes. diff --git a/docs/design/api-mark-cpp/cpp-generator.md b/docs/design/api-mark-cpp/cpp-generator.md index ae1fec3..35eb356 100644 --- a/docs/design/api-mark-cpp/cpp-generator.md +++ b/docs/design/api-mark-cpp/cpp-generator.md @@ -27,8 +27,8 @@ library, emitted as an introductory paragraph in `api.md`. Omitted when empty. the top-level heading in `api.md`. **CppGeneratorOptions.PublicIncludeRoots**: `IReadOnlyList` — one or -more directories that define the public include root(s) of the library. This -property serves a dual purpose: +more directories that define the compiler include roots. This property serves +two purposes: 1. *Parse environment*: each root is passed to the Clang parser as an `-I` include directory so that `#include ` resolves correctly @@ -38,22 +38,23 @@ property serves a dual purpose: normalizing separators to forward slashes. For example, if the root is `C:\project\include` and the declaration source is `C:\project\include\mylib\renderer.h`, the canonical include path is - `mylib/renderer.h`, yielding `#include ` in the type + `mylib/renderer.h`, yielding `#include "mylib/renderer.h"` in the type page output. When multiple roots are configured and a file matches more than one, the longest matching root path (most specific) wins. -**CppGeneratorOptions.IncludePatterns**: `IReadOnlyList` — glob -patterns relative to each PublicIncludeRoot selecting which header files -contribute to the documented public API. Defaults to `["**/*"]` when empty, -which selects all files under all roots. +**CppGeneratorOptions.ApiHeaderPatterns**: `IList` — ordered list of +glob and exclusion pattern strings that determine which header files contribute to the +documented public API. Gitignore-style semantics apply: patterns are evaluated +in order; the last matching pattern wins. Entries without a `!` prefix are +include patterns; entries with a `!` prefix are exclusion patterns (the `!` +is stripped before glob matching). When empty, all headers with recognized C++ +extensions (`.h`, `.hpp`, `.hxx`, `.h++`) under all configured roots are +documented automatically without any pattern filtering. -**CppGeneratorOptions.ExcludePatterns**: `IReadOnlyList` — glob -patterns relative to each PublicIncludeRoot for files to exclude from the -documented API. Common values: `"detail/**"`, `"*_impl.h"`, `"internal/**"`. -ExcludePatterns are evaluated after IncludePatterns; a file matching both is -excluded. +Example — all headers except `detail/`, with one re-included: +`["include/**", "!include/detail/**", "include/detail/public_api.h"]` **CppGeneratorOptions.SystemIncludePaths**: `IReadOnlyList` — toolchain and SDK include directories passed to the Clang parser as system include paths @@ -62,13 +63,6 @@ and SDK include directories passed to the Clang parser as system include paths are used for type resolution only; declarations found within them are never documented. -**CppGeneratorOptions.AdditionalIncludePaths**: `IReadOnlyList` — -additional include directories for third-party headers included by public -headers but not part of the documented API (e.g. a vendored library's include -directory). Passed to the Clang parser as `-I` flags. Declarations from these -paths are never documented. Note: this option is currently not wired through the -CLI; it is available for direct API use only (v1 scope). - **CppGeneratorOptions.Defines**: `IReadOnlyList` — preprocessor symbol definitions passed to the Clang parser as `-D` flags, in the form `"NAME"` or `"NAME=value"`. Export macros (e.g. `MYLIB_API`, `__declspec(dllexport)` @@ -79,9 +73,10 @@ sees them as no-ops and does not misinterpret them as type annotations. to Clang (e.g. `"c++17"`, `"c++20"`). Defaults to `"c++17"` when not specified. -**CppGeneratorOptions.ClangPath**: `string?` — optional path to the clang executable; when null -or empty, clang is discovered automatically (PATH → xcrun on macOS → vswhere on Windows → -`C:\Program Files\LLVM\bin\clang.exe`). +**CppGeneratorOptions.ClangPath**: `string?` — optional explicit path to the clang executable. +When non-empty, this path is used directly (and must exist on disk); no discovery is performed. +When null or empty, the discovery order is: `APIMARK_CLANG_PATH` environment variable → +`clang` on PATH → `xcrun clang` on macOS → vswhere LLVM / `C:\Program Files\LLVM\bin\clang.exe` on Windows. **CppGeneratorOptions.AdditionalCompilerArguments**: `IReadOnlyList` — raw Clang compiler arguments appended after all structured options. Provides an @@ -134,6 +129,7 @@ filter, and writes the full Markdown output tree. | ----------- | ------------ | | Namespace | `{Namespace}.md` | | Type | `{Namespace}/{TypeName}.md` | + | Type alias | `{Namespace}/{AliasName}.md` | | Member | `{Namespace}/{TypeName}/{MemberName}.md` | | Free function | `{Namespace}/{FunctionName}.md` | | Enum | `{Namespace}/{EnumName}.md` | @@ -148,10 +144,10 @@ filter, and writes the full Markdown output tree. the canonical `#include ` at the top, followed by the class declaration, inheritance information, template parameters (for primary templates), and grouped sub-tables with links to all member detail pages. - When the class is marked `final`, a `class ClassName final` declaration - line (including base class names when inheritance is present) is appended - to the signature block so consumers can see the constraint without opening - the header. + When the class is marked `final` or has direct base classes, a class + declaration line (e.g. `class FinalClass final`, `class Circle : public Shape`) + is appended to the signature block so consumers can see the constraint and + inheritance chain without opening the header. - `factory.CreateMarkdown($"{qualifiedNamespace}/{typeName}", memberName)` — dedicated page for every visible non-operator member. All non-operator members always receive their own page, making navigation fully deterministic. @@ -166,9 +162,9 @@ filter, and writes the full Markdown output tree. name `"global"`. Execution steps: enumerate candidate header files under each PublicIncludeRoot -applying IncludePatterns and ExcludePatterns; build Clang options from all -configured paths, defines, standard, and additional arguments; write a temporary -combined header that `#include`s all candidate headers, invoke +applying ApiHeaderPatterns with gitignore-style last-match-wins semantics; build +Clang options from all configured paths, defines, standard, and additional arguments; +write a temporary combined header that `#include`s all candidate headers, invoke `clang -Xclang -ast-dump=json -fparse-all-comments -fsyntax-only` on it, parse the resulting JSON AST; walk the AST and apply IsOwnedDeclaration to each declaration; apply Visibility and IncludeDeprecated filters; write the library entrypoint; for @@ -183,16 +179,16 @@ members (case-insensitive collision) emit a single combined page via `operators.md` page via `WriteClassOperatorsPage`; emit grouped sub-tables with links; for each namespace with operator free functions emit a single `operators.md` page via `WriteNamespaceOperatorsPage` instead of individual -pages; delete the temporary combined header file. +pages; for each owned type alias emit a type alias page via `WriteTypeAliasPage`; +delete the temporary combined header file. **IsOwnedDeclaration** (internal): Determines whether a declaration belongs to the documented public API. - *Parameters*: declaration source file (absolute, normalized path). - *Returns*: `(bool owned, string includeRoot, string relativePath)` — owned - is true when the file falls under a PublicIncludeRoot and matches - IncludePatterns without matching ExcludePatterns; includeRoot and - relativePath are set when owned is true. + is true when the file falls under a PublicIncludeRoot and is selected by + ApiHeaderPatterns; includeRoot and relativePath are set when owned is true. - *Algorithm*: 1. Normalize the declaration source file path: resolve to absolute path, normalize directory separators to the OS separator, resolve `..` and @@ -203,9 +199,12 @@ the documented public API. overlap). 4. Compute relative path: strip the root prefix and normalize separators to forward slashes. - 5. Test the relative path against IncludePatterns (must match at least one) - and ExcludePatterns (must not match any). Return owned=true only when both - conditions hold. + 5. When ApiHeaderPatterns is non-empty, test whether the file is selected using + gitignore-style last-match-wins evaluation. Patterns are first evaluated + CWD-relative (when the file falls within the current working directory); when + the root is outside the CWD the file path relative to the root is used instead. + Return owned=true only when the final evaluated state is included. When + ApiHeaderPatterns is empty, all files under the matched root are owned. **CppGenerator.WriteCombinedMemberPage** (private): Writes a single combined Markdown page for a group of members whose base names collide on case-insensitive @@ -261,6 +260,64 @@ identified by the declaring class name. function → `className`; non-constructor function → `fn.Name`; field → `field.Name`; any other type → `className`. +**CppTypeLinkResolver** (internal): Resolves C++ type strings to Markdown link text +for use in table cells. + +- *Constructor*: Accepts `IReadOnlyDictionary knownTypes` — maps + fully-qualified C++ type names (e.g. `"fixtures::SampleClass"`) to documentation + page keys (e.g. `"fixtures/SampleClass"`). Built in `CppGenerator.Generate` from + the `namespaceDecls` dictionary. +- **Linkify** method: resolves a simplified C++ type string to a Markdown link or plain text. + - *Parameters*: `string cppTypeString`, `string currentFolder`, `ISet + externalTypes` accumulator. + - *Returns*: a Markdown link `[Name](relative/path.md)` when the stripped base type + is in `knownTypes`; the original string unchanged otherwise; non-std external types + with a namespace are tracked in `externalTypes`. + - *Algorithm*: strip qualifiers (`const`, `volatile`, `*`, `&`, `&&`, trailing-const, + and template arguments) to isolate the base type name; reject primitives and `std::` + types immediately; look up the base type in `knownTypes` by exact qualified match, + then by short-name fallback; compute a relative path and return the link; if not + found and the type has a non-std namespace, track as external. +- **StripQualifiers** (internal static): removes C++ cv and reference qualifiers and + template arguments from a type string, returning the bare base type name. + +**CppExternalTypeInfo** (internal record): Represents a non-standard external C++ +type reference collected during table cell generation. + +- *Properties*: `TypeString` (short type name without namespace), `Namespace` + (the C++ namespace using `::` separators). +- *Ordering*: implements `IComparable` by `TypeString` so + `SortedSet` produces alphabetically ordered tables. + +**CppGenerator.WriteExternalTypesSection** (private static): Emits the +`## External Types` section at the bottom of a page when at least one external +type was referenced in table cells. + +- *Parameters*: `IMarkdownWriter writer`, `SortedSet + externalTypes`. +- *Algorithm*: Returns immediately when the set is empty; otherwise writes an + H2 heading `"External Types"` and a two-column table (`Type`, `Namespace`). + +**CppGenerator.WriteTypeAliasPage** (private): Writes a documentation page for a +`using` type alias declaration. + +- *Parameters*: `IMarkdownWriterFactory factory`, `string nsKey`, `string nsDisplayName`, + `CppTypeAlias alias`. +- *Returns*: `void` +- *Algorithm*: Creates `{nsKey}/{alias.Name}.md` via the factory; writes an H1 heading + using `alias.Name`; emits the fully-qualified name comment + (`// {nsDisplayName}::{alias.Name}`), the optional `#include` directive from the source + location, and the `using {alias.Name} = {alias.UnderlyingTypeName}` declaration in a + fenced `cpp` code block; emits the doc comment summary paragraph or the no-description + placeholder; emits the extended details paragraph when a `@details` or `@remarks` block + is present. + +**CppFunction.IsDeleted** (data model property): `bool` — when `true`, the function +was declared with `= delete` in the source. `ClangAstParser` reads this from the +`"explicitlyDeleted"` field of `FunctionDecl` and `CXXMethodDecl` nodes in the clang JSON +AST. `BuildMethodSignature` appends the `= delete` suffix to the rendered signature when +this flag is set, making the intentional prohibition visible in the generated documentation. + ### Error Handling CppGenerator throws `DirectoryNotFoundException` when a path in @@ -276,7 +333,8 @@ DotNetGenerator behavior. - **IApiGenerator** — CppGenerator implements this interface from ApiMarkCore. - **IMarkdownWriterFactory** — CppGenerator receives an IMarkdownWriterFactory through Generate and calls CreateMarkdown to obtain each IMarkdownWriter. -- **clang** — the system clang executable (`clang` on PATH, `xcrun clang` on macOS, or +- **clang** — the system clang executable (resolved via `CppGeneratorOptions.ClangPath`, + `APIMARK_CLANG_PATH` environment variable, `clang` on PATH, `xcrun clang` on macOS, or vswhere-located clang on Windows) is invoked as a subprocess to parse C++ headers and produce a JSON AST — see Clang Integration Design. diff --git a/docs/design/api-mark-dot-net/dot-net-generator.md b/docs/design/api-mark-dot-net/dot-net-generator.md index 3856889..ca6f00c 100644 --- a/docs/design/api-mark-dot-net/dot-net-generator.md +++ b/docs/design/api-mark-dot-net/dot-net-generator.md @@ -77,6 +77,24 @@ between members of different kinds or differently-cased method names) emit a single combined page via `WriteCombinedMemberPage`; emit grouped sub-tables with links; dispose the AssemblyDefinition. +**DotNetGenerator.BuildTypeSignature** (private): Builds a human-readable C# +declaration signature for a type definition, including direct base class and +interface names when present. + +- *Parameters*: `TypeDefinition type` — the type to represent; + `string contextNamespace` — the namespace used to simplify base type and + interface names. +- *Returns*: `string` — a declaration of the form `public class Name`, + `public interface Name`, or `public class Name : BaseClass, IInterface` + when direct inheritance is present. +- *Algorithm*: Determines the keyword (`class`, `interface`, `enum`, or `struct`) + from the type's flags; computes the `sealed` or `static` modifier for classes; + strips generic arity from the type name and appends generic parameter names when + present; collects the direct base class (skipping `System.Object`, + `System.ValueType`, `System.Enum`, and `System.MulticastDelegate`) and all + directly declared interfaces using TypeNameSimplifier to produce idiomatic C# + names; appends `: BaseClass, IInterface` when the collected list is non-empty. + **DotNetGenerator.WriteCombinedMemberPage** (private): Writes a single combined Markdown page for a group of members whose sanitized file names collide on case-insensitive filesystems. @@ -120,6 +138,41 @@ a short human-readable kind string used in combined page H4 headings. `MethodDefinition` with name `.ctor` → `"Constructor"`; `MethodDefinition` → `"Method"`; all other types → `"Member"`. +**TypeLinkResolver** (internal): Resolves Mono.Cecil `TypeReference` instances +to Markdown link text for use in table cells. + +- *Constructor*: Accepts `IReadOnlyList rootNamespaces` — forwarded to + `DotNetGenerator.GetNamespaceFolderPath` when computing target page paths. +- **Linkify** method: resolves a `TypeReference` to a Markdown link string. + - *Parameters*: `TypeReference typeRef`, `string currentFolder` (path of the + containing file), `string contextNamespace`, `ISet + externalTypes` accumulator, optional `bool isNullableAnnotated`. + - *Returns*: a Markdown link when the type is intra-assembly; the original + simplified name otherwise; external non-System types are tracked in + `externalTypes`. + - *Rules*: `Nullable` → `T?` via recursion; array types → `elementText[]`; + generic instance types linkify the container when intra-assembly; primitives + and `System.*` types render as plain text; non-System external types are + added to the accumulator. + - Intra-assembly detection: `TypeReference.Scope is ModuleDefinition`. + +**ExternalTypeInfo** (internal record): Represents a non-standard external type +reference collected during table cell generation. + +- *Properties*: `SimplifiedName` (display form, may include escaped generic + angle brackets), `Namespace` (the type's .NET namespace). +- *Ordering*: implements `IComparable` by `SimplifiedName` so + `SortedSet` produces alphabetically ordered tables. + +**DotNetGenerator.WriteExternalTypesSection** (private static): Emits the +`## External Types` section at the bottom of a page when at least one external +type was referenced in table cells. + +- *Parameters*: `IMarkdownWriter writer`, `SortedSet + externalTypes`. +- *Algorithm*: Returns immediately when the set is empty; otherwise writes an + H2 heading `"External Types"` and a two-column table (`Type`, `Namespace`). + ### Error Handling DotNetGenerator throws `FileNotFoundException` explicitly when XmlDocPath does not diff --git a/docs/design/api-mark-msbuild/api-mark-task.md b/docs/design/api-mark-msbuild/api-mark-task.md index 0027972..ef5a62a 100644 --- a/docs/design/api-mark-msbuild/api-mark-task.md +++ b/docs/design/api-mark-msbuild/api-mark-task.md @@ -55,9 +55,17 @@ the `.targets` file when not explicitly set. If not set and the language is **ApiMarkTask.ApiMarkIncludePaths**: `string` — MSBuild property `$(ApiMarkIncludePaths)`; for the `cpp` language, a semicolon-separated list of -include paths. MSBuild uses semicolons as its standard list separator; the task -converts semicolons to commas when forwarding this value to the `--includes` -argument, which the tool parses as a comma-separated list. +include directory paths. Each entry is forwarded as an individual `--includes` +flag; all paths are passed to Clang as `-I` flags. When `ApiMarkApiHeaders` is +not set, all headers with recognized C++ extensions under these paths are documented. + +**ApiMarkTask.ApiMarkApiHeaders**: `string` — MSBuild property +`$(ApiMarkApiHeaders)`; for the `cpp` language, a semicolon-separated, +order-preserved list of glob and exclusion pattern strings forwarded as individual +`--api-headers` flags. Entries with a `!` prefix are exclusion patterns; +gitignore-style last-match-wins semantics apply. Optional — when empty or not +set, all headers with recognized C++ extensions under `ApiMarkIncludePaths` are +documented. **ApiMarkTask.ApiMarkLibraryName**: `string` — MSBuild property `$(ApiMarkLibraryName)`; for the `cpp` language, the library name used as the @@ -78,6 +86,12 @@ converted to commas when forwarding to the `--defines` argument. passed to Clang (e.g. `c++17`, `c++20`). The `.targets` file defaults this to `c++17` when not explicitly set. +**ApiMarkTask.ApiMarkClangPath**: `string` — MSBuild property +`$(ApiMarkClangPath)`; for the `cpp` language, the path to the clang +executable. Optional — when empty, clang is located using the priority order: +`APIMARK_CLANG_PATH` environment variable → `clang` on PATH → `xcrun` (macOS) +→ vswhere / default LLVM path (Windows). + **ApiMarkTask.ToolDllPath**: `string` — set by the `.targets` file to the path of the bundled `ApiMark.Tool.dll` inside the NuGet package `tools/net8.0/` directory. Not intended to be overridden by project authors. @@ -105,14 +119,16 @@ language is `cpp` and `ApiMarkIncludePaths` is not set, return true (skip generation with an informational log message); resolve the `dotnet` executable path (check `DOTNET_HOST_PATH` environment variable first, then search `PATH`); build the argument list from MSBuild properties according to language-specific -mapping (for `cpp`, semicolons in `ApiMarkIncludePaths` are converted to commas -because the tool's `--includes` argument accepts a comma-separated list; if -`ApiMarkLibraryName` is set, append `--library-name`; if `ApiMarkLibraryDescription` -is set, append `--library-description`; if `ApiMarkDefines` is set, convert -semicolons to commas and append `--defines`; if `ApiMarkCppStandard` is set, -append `--cpp-standard`); start the child process and pipe stdout lines as MSBuild -messages and stderr lines as MSBuild errors; wait for exit; return true if exit code -is zero, otherwise log an error with the exit code and return false. +mapping (for `cpp`, split `ApiMarkIncludePaths` on `;` and emit one `--includes` +flag per entry; split `ApiMarkApiHeaders` on `;` and emit one `--api-headers` +flag per entry, order-preserved including `!` exclusion patterns; if `ApiMarkLibraryName` +is set, append `--library-name`; if `ApiMarkLibraryDescription` is set, append +`--library-description`; if `ApiMarkDefines` is set, convert semicolons to commas +and append `--defines`; if `ApiMarkCppStandard` is set, append `--cpp-standard`; +if `ApiMarkClangPath` is set, append `--clang-path`); start the child +process and pipe stdout lines as MSBuild messages and stderr lines as MSBuild +errors; wait for exit; return true if exit code is zero, otherwise log an error +with the exit code and return false. ### Error Handling diff --git a/docs/design/api-mark-tool.md b/docs/design/api-mark-tool.md index 8971c67..e3058f3 100644 --- a/docs/design/api-mark-tool.md +++ b/docs/design/api-mark-tool.md @@ -39,8 +39,9 @@ users or CI pipelines. Supported subcommands: `dotnet`, `cpp`. Options for `dotnet`: `--assembly `, `--xml-doc `, `--output `, `--visibility `, `--include-obsolete`. - Options for `cpp`: `--includes `, `--library-name `, - `--library-description `, `--defines `, + Options for `cpp`: `--includes ` (repeatable), `--api-headers ` + (repeatable, ordered, supports `!` exclusion patterns), + `--library-name `, `--library-description `, `--defines `, `--cpp-standard `, `--output `, `--visibility `, `--include-obsolete`. Standard flags are valid anywhere in the argument list, diff --git a/docs/design/api-mark-tool/cli.md b/docs/design/api-mark-tool/cli.md index 6ade99e..9335a41 100644 --- a/docs/design/api-mark-tool/cli.md +++ b/docs/design/api-mark-tool/cli.md @@ -25,9 +25,9 @@ can be tested independently. file cannot be opened. - `Context` (IDisposable) — exposes parsed flags and options as typed properties (`Version`, `Help`, `Silent`, `Validate`, `Language`, - `Assembly`, `XmlDoc`, `Includes`, `Output`, `Visibility`, `IncludeObsolete`, - `ResultsFile`, `HeadingDepth`, `ExitCode`) and provides `WriteLine` and - `WriteError` for all program output routing. + `Assembly`, `XmlDoc`, `Includes`, `ApiHeaders`, `Output`, `Visibility`, + `IncludeObsolete`, `ResultsFile`, `HeadingDepth`, `ExitCode`) and provides + `WriteLine` and `WriteError` for all program output routing. **Consumed**: @@ -43,9 +43,9 @@ flags (`-v`, `--version`, `--help`, `--silent`, `--validate`, `--log`, `--results`, `--depth`) are recognized anywhere in the argument list. The first positional non-flag token (a token that does not start with `-`) is captured as the language subcommand. Language-specific options (`--assembly`, -`--xml-doc`, `--includes`, `--output`, `--visibility`, `--include-obsolete`) -may appear anywhere in the argument list after the language token is -recognized. +`--xml-doc`, `--includes`, `--api-headers`, `--output`, `--visibility`, +`--include-obsolete`) may appear anywhere in the argument list after the +language token is recognized. `Context` implements `IDisposable` to release the optional log file writer when the caller is done with the context. diff --git a/docs/design/api-mark-tool/cli/context.md b/docs/design/api-mark-tool/cli/context.md index 31abe98..9d1b5f1 100644 --- a/docs/design/api-mark-tool/cli/context.md +++ b/docs/design/api-mark-tool/cli/context.md @@ -27,7 +27,8 @@ argument array. | `Language` | `string?` | `null` | First positional non-flag token | | `Assembly` | `string?` | `null` | Path from `--assembly` | | `XmlDoc` | `string?` | `null` | Path from `--xml-doc` | -| `Includes` | `string[]` | `[]` | Comma-split values from `--includes` | +| `Includes` | `string[]` | `[]` | Plain directory paths accumulated from repeated `--includes` invocations | +| `ApiHeaders` | `string[]` | `[]` | Ordered patterns from repeated `--api-headers` invocations (may start with `!`) | | `Output` | `string?` | `null` | Directory from `--output` | | `Visibility` | `string` | `"Public"` | Value from `--visibility` | | `IncludeObsolete` | `bool` | `false` | `--include-obsolete` flag | @@ -52,7 +53,10 @@ construction path. - *Returns*: A new fully populated `Context` instance. - *Algorithm*: Creates an `ArgumentParser`, calls `ParseArguments`, copies all parsed values to a new `Context` via property initializers, and - optionally opens the log file. + optionally opens the log file. Each `--includes` flag appends a single + directory path to the `Includes` list; each `--api-headers` flag appends + a single pattern string (which may start with `!`) to the `ApiHeaders` + list, preserving order for gitignore-style evaluation. - *Preconditions*: `args` must be non-null. - *Postconditions*: All properties reflect the parsed argument values; log file is open if `--log` was specified. diff --git a/docs/design/api-mark-tool/program.md b/docs/design/api-mark-tool/program.md index a918cf8..465586e 100644 --- a/docs/design/api-mark-tool/program.md +++ b/docs/design/api-mark-tool/program.md @@ -71,6 +71,11 @@ the generator, and calls `Generate`. - Throws `ArgumentException` for invalid `Visibility` values; throws `NotSupportedException` for unrecognized or not-yet-implemented language identifiers. +- For the `cpp` language, `CppGeneratorOptions` is populated with + `PublicIncludeRoots` (from `context.Includes`), `ApiHeaderPatterns` (from + `context.ApiHeaders`), and the other cpp-specific options (`LibraryName`, + `Description`, `Defines`, `CppStandard`, `Visibility`, `IncludeDeprecated`, + `ClangPath`). **Program.PrintBanner** (private static): Prints the application banner (tool name, version, copyright line, and a blank line). diff --git a/docs/design/ots/clang.md b/docs/design/ots/clang.md index 176d835..402cbe0 100644 --- a/docs/design/ots/clang.md +++ b/docs/design/ots/clang.md @@ -35,20 +35,26 @@ users to control the exact compiler version used for parsing. - **Doc comment nodes** — `FullComment`, `ParagraphComment`, `BlockCommandComment`, `ParamCommandComment`, and `TextComment` sub-nodes within each declaration carry the structured Doxygen documentation comment content. -- **Clang discovery** — the parser locates the clang executable automatically: by searching PATH - on all platforms, by invoking clang through `xcrun` on macOS if the PATH search fails, and by - querying vswhere for LLVM installations on Windows if both earlier strategies fail. An explicit - path can be provided via `CppGeneratorOptions.ClangPath` to override discovery. +- **Clang discovery** — the parser locates the clang executable using this priority order: + (1) `CppGeneratorOptions.ClangPath` when explicitly set; + (2) the `APIMARK_CLANG_PATH` environment variable when set (allows project-wide or CI-wide + configuration without repeating the path on every invocation); + (3) `clang` on the system PATH; + (4) `xcrun clang` on macOS if the PATH search fails; + (5) vswhere-located LLVM clang on Windows, then `C:\Program Files\LLVM\bin\clang.exe`. + When a path is supplied by option (1) or (2) and the file does not exist on disk, an + `InvalidOperationException` is thrown immediately with a clear diagnostic message. ### Integration Pattern Clang is consumed by `ClangAstParser` in `src/ApiMark.Cpp/CppAst/ClangAstParser.cs`. The integration follows these steps: -1. `ClangAstParser.Parse` discovers the clang executable using the strategy described under - _Features Used_ above, or uses `CppGeneratorOptions.ClangPath` if set. +1. `ClangAstParser.Parse` discovers the clang executable using the priority order described + under _Features Used_ above: explicit `CppGeneratorOptions.ClangPath` → `APIMARK_CLANG_PATH` + environment variable → `clang` on PATH → `xcrun` on macOS → vswhere / default LLVM path on Windows. 2. The parser builds a combined `#include` header file that includes every file in the configured - public include roots (after applying IncludePatterns and ExcludePatterns). + public include roots (after applying `ApiHeaderPatterns` gitignore-style filters). 3. The parser constructs a clang command line: `-Xclang -ast-dump=json -fparse-all-comments -fsyntax-only` followed by include path flags (`-I`, `-isystem`), preprocessor defines (`-D`), and the combined header file. diff --git a/docs/reqstream/api-mark-cpp/cpp-generator.yaml b/docs/reqstream/api-mark-cpp/cpp-generator.yaml index 969e0a5..a4074ef 100644 --- a/docs/reqstream/api-mark-cpp/cpp-generator.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-generator.yaml @@ -135,3 +135,79 @@ sections: - CppGenerator_Generate_ClassWithOperators_OperatorsPageContainsOperatorEntry - CppGenerator_Generate_ClassWithOperators_TypePageLinksToOperatorsPage - CppGenerator_Generate_NamespaceFreeOperator_CreatesNamespaceOperatorsPage + - id: ApiMarkCpp-CppGenerator-ShowDirectInheritanceInTypeSignature + title: >- + CppGenerator shall include direct base class names in the class declaration + line of the signature block for classes that have direct base classes or are + marked final, so the inheritance chain is visible without opening the header. + justification: | + The direct base class list tells AI agents the contract a type satisfies and + the class it extends without requiring them to navigate to the header file. + Combining this with the final specifier in a single declaration line keeps + the signature block minimal and immediately scannable. + tests: + - CppGenerator_Generate_InheritanceClass_EmitsBaseClassInSignature + - CppGenerator_Generate_FinalClass_EmitsFinalKeywordInSignature + - id: ApiMarkCpp-CppGenerator-EmitIntraDocLinksInTableCells + title: CppGenerator shall emit Markdown links in type and parameter type table cells when the referenced type is documented in the same library. + justification: | + AI agents and human readers navigating API documentation benefit from + clickable links that jump directly to the referenced type page rather + than requiring a separate search. Links are only emitted in table cells, + not inside fenced code blocks, because Markdown links do not render + inside fences. + tests: + - CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInReturnsCell + - id: ApiMarkCpp-CppGenerator-ApplyGitignoreStylePatternSelectionToHeaders + title: >- + CppGenerator shall apply gitignore-style last-match-wins pattern selection to + determine which headers under the include roots contribute to the documented API + surface when ApiHeaderPatterns is set. + justification: | + Projects need fine-grained control over which headers appear in the generated + reference without changing the include roots. Gitignore-style semantics allow + build authors to start with a broad include pattern and then selectively exclude + or re-include specific subtrees using '!'-prefixed exclusion patterns, matching the + familiar gitignore convention. + tests: + - CppGenerator_Generate_NoApiHeaderPatterns_DocumentsAllHeaders + - CppGenerator_Generate_ApiHeaderPatterns_IncludePattern_OnlyMatchingFilesDocumented + - CppGenerator_Generate_ApiHeaderPatterns_ExcludePattern_ExcludesMatchingFiles + - CppGenerator_Generate_ApiHeaderPatterns_ReInclude_GitignoreSemantics_IncludesReIncludedHeader + - CppGenerator_Generate_ApiHeaderPatterns_ExcludeWithoutReInclude_ExcludesHeader + - id: ApiMarkCpp-CppGenerator-EmitExternalTypesSection + title: CppGenerator shall emit an "External Types" section at the bottom of each generated page that references at least one non-std external type. + justification: | + Readers using an external library type documented on a page need to know + which namespace or header to include. Collecting all external type + references per page and listing them alphabetically provides this context + without cluttering individual type cells with fully-qualified names. + tests: + - CppTypeLinkResolver_Linkify_UnknownNamespacedType_TracksExternalType + - id: ApiMarkCpp-CppGenerator-ShowDeletedFunctionsWithDeletedNotation + title: >- + CppGenerator shall document explicitly deleted functions and operators with a + = delete suffix in their signatures so that callers know the operation is + intentionally prohibited. + justification: | + Explicitly deleted special members (copy constructors, assignment operators, etc.) + communicate an intentional design decision to users of the API. Suppressing them + would imply the operation is available; showing them with = delete makes the + prohibition visible without requiring readers to examine the header. + tests: + - CppGenerator_Generate_DeletedCopyConstructor_EmitsDeleteSuffix + - CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffix + - id: ApiMarkCpp-CppGenerator-DocumentTypeAliases + title: >- + CppGenerator shall document using type aliases declared in documented namespaces + as first-class entries — each alias receives its own page showing the using + declaration, the underlying type, and the Doxygen summary comment. + justification: | + A using alias is a named type in the public API, not an implementation detail. + Documenting it with its own page allows AI agents and human readers to navigate + to it by name and understand both the alias and its underlying type without + inspecting header files. + tests: + - CppGenerator_Generate_TypeAlias_CreatesAliasPages + - CppGenerator_Generate_TypeAliasPage_ContainsDeclarationAndSummary + - CppGenerator_Generate_NamespacePage_ListsTypeAliases diff --git a/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml b/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml index 75a2792..5ab64b5 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml @@ -85,3 +85,34 @@ sections: opening it first. tests: - DotNetGenerator_Generate_ApiMd_ListsAllNamespacesWithTypeCount + - id: ApiMarkDotNet-DotNetGenerator-ShowDirectInheritanceInTypeSignature + title: >- + DotNetGenerator shall include the direct base class and directly declared + interfaces in the type signature, omitting well-known implicit bases such as + System.Object, System.ValueType, System.Enum, and System.MulticastDelegate. + justification: | + The direct inheritance list tells AI agents the contract a type satisfies and + the class it extends without requiring them to navigate to the source file. + Suppressing well-known implicit bases avoids noise that adds no information. + tests: + - DotNetGenerator_Generate_SampleImplementation_TypeSignatureShowsInterface + - DotNetGenerator_Generate_EnumTypeSignature_HasNoBaseClass + - id: ApiMarkDotNet-DotNetGenerator-EmitIntraDocLinksInTableCells + title: DotNetGenerator shall emit Markdown links in type and parameter type table cells when the referenced type is documented in the same assembly. + justification: | + AI agents and human readers navigating API documentation benefit from + clickable links that jump directly to the referenced type page rather + than requiring a separate search. Links are only emitted in table cells, + not inside fenced code blocks, because Markdown links do not render + inside fences. + tests: + - DotNetGenerator_Generate_IntraAssemblyReturnType_EmitsMarkdownLinkInReturnsCell + - id: ApiMarkDotNet-DotNetGenerator-EmitExternalTypesSection + title: DotNetGenerator shall emit an "External Types" section at the bottom of each generated page that references at least one non-System external type. + justification: | + Readers using an external library type documented on a page need to know + which package or namespace to import. Collecting all external type + references per page and listing them alphabetically provides this context + without cluttering individual type cells with fully-qualified names. + tests: + - DotNetGenerator_Generate_ExternalNonSystemParameterType_EmitsExternalTypesSection diff --git a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml index fbb4dcf..848399d 100644 --- a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml +++ b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml @@ -95,3 +95,14 @@ sections: build, parallel to the dotnet behavior when ApiMarkXmlDocPath is absent. tests: - ApiMarkTask_Cpp_EmptyIncludePaths_SkipsExecution + - id: ApiMarkMsbuild-ApiMarkTask-ForwardApiHeaders + title: >- + ApiMarkTask shall forward each entry of ApiMarkApiHeaders to the tool as a separate + --api-headers flag for C++ builds, preserving order and forwarding '!'-prefixed + exclusion patterns verbatim. + justification: | + C++ projects need fine-grained, gitignore-style control over which headers contribute + to the documented API surface. Forwarding each pattern as its own --api-headers flag + preserves order and lets CppGenerator apply last-match-wins semantics correctly. + tests: + - ApiMarkTask_Cpp_ApiHeaders_ForwardedAsIndividualFlags diff --git a/docs/reqstream/api-mark-tool/cli/context.yaml b/docs/reqstream/api-mark-tool/cli/context.yaml index 9e5fa67..7fcfacf 100644 --- a/docs/reqstream/api-mark-tool/cli/context.yaml +++ b/docs/reqstream/api-mark-tool/cli/context.yaml @@ -29,7 +29,7 @@ sections: tests: - Context_Create_WithLanguageSubcommand_SetsLanguage - id: ApiMarkTool-Cli-Context-ParseLanguageOptions - title: Context shall parse the language-specific options --assembly, --xml-doc, --output, --visibility, --include-obsolete, and --includes. + title: Context shall parse the language-specific options --assembly, --xml-doc, --output, --visibility, --include-obsolete, --includes, and --api-headers. justification: | Language-specific options must be available to callers as typed properties so that generators can be configured without re-parsing the command line. @@ -40,6 +40,28 @@ sections: - Context_Create_WithVisibilityOption_SetsVisibility - Context_Create_WithIncludeObsoleteFlag_SetsIncludeObsoleteTrue - Context_Create_WithIncludesOption_SetsIncludes + - Context_Create_WithRepeatedApiHeaders_AccumulatesAllPatternsInOrder + - id: ApiMarkTool-Cli-Context-ParseIncludes + title: Context shall parse repeated --includes flags and accumulate each plain directory path into the Includes array in order. + justification: | + C++ projects pass one include root per --includes flag; the context must + accumulate all supplied paths in order so CppGenerator receives the complete + set of include roots without any path being dropped or reordered. + tests: + - Context_Create_WithIncludesOption_SetsIncludes + - Context_Create_WithRepeatedIncludes_AccumulatesAllPaths + - Context_Create_WithSingleInclude_SetsSinglePath + - id: ApiMarkTool-Cli-Context-ParseApiHeaders + title: Context shall parse repeated --api-headers flags and accumulate each pattern into the ApiHeaders array in order, forwarding '!'-prefixed exclusion patterns verbatim. + justification: | + C++ projects express gitignore-style header selection as an ordered sequence of + include patterns and '!'-prefixed exclusion patterns. The context must accumulate all + supplied patterns in order and forward exclusion patterns verbatim so CppGenerator can + apply last-match-wins semantics correctly. + tests: + - Context_Create_WithRepeatedApiHeaders_AccumulatesAllPatternsInOrder + - Context_Create_WithApiHeadersExclusionPattern_ForwardsVerbatim + - Context_Create_WithNoArguments_HasEmptyApiHeaders - id: ApiMarkTool-Cli-Context-ParseDepthOption title: Context shall parse the --depth option and set HeadingDepth to the specified integer value in the range 1–6, defaulting to 1 when the option is absent. justification: | diff --git a/docs/reqstream/api-mark-tool/program.yaml b/docs/reqstream/api-mark-tool/program.yaml index 0bb09f5..63d053d 100644 --- a/docs/reqstream/api-mark-tool/program.yaml +++ b/docs/reqstream/api-mark-tool/program.yaml @@ -30,15 +30,18 @@ sections: - Program_Main_WithInvalidVisibility_ReturnsNonZeroExitCode - Program_Main_WithMissingAssembly_PrintsErrorAndFails - id: ApiMarkTool-Program-SupportCppOptions - title: Program shall support the cpp language with --includes, --output, --visibility, --include-obsolete, --library-name, --library-description, --defines, and --cpp-standard options. + title: Program shall support the cpp language with --includes, --output, --visibility, --include-obsolete, --library-name, --library-description, --defines, --cpp-standard, and --api-headers options. justification: | C++ documentation generation requires at minimum the public include root(s) and output directory. The --includes option is required; the tool validates its presence and returns a non-zero exit code with a descriptive error message when it is absent, ensuring users receive actionable feedback before any generation - is attempted. + is attempted. The --api-headers option provides gitignore-style header selection, + allowing fine-grained control over which headers contribute to the documented + API surface using ordered patterns with '!' exclusion prefix support. tests: - Program_Main_WithCppSubcommand_MissingIncludes_ReturnsNonZeroExitCode + - Program_Main_CppWithApiHeadersFlag_FlagIsAccepted - id: ApiMarkTool-Program-SupportHelpOption title: Program shall support -?, -h, and --help options that display usage information and exit zero. justification: | diff --git a/docs/reqstream/ots/clang.yaml b/docs/reqstream/ots/clang.yaml index b729dce..9676606 100644 --- a/docs/reqstream/ots/clang.yaml +++ b/docs/reqstream/ots/clang.yaml @@ -7,13 +7,15 @@ sections: requirements: - id: Clang-Executable-Discoverable title: >- - Clang shall be discoverable on the host system via PATH search, xcrun on macOS, or - vswhere on Windows without requiring an explicit path configuration. + Clang shall be discoverable on the host system via the APIMARK_CLANG_PATH environment + variable, PATH search, xcrun on macOS, or vswhere on Windows without requiring an + explicit path configuration. justification: | ApiMark must run in standard CI environments where clang is installed but its path is - not known in advance. Automatic discovery via PATH, xcrun, and vswhere covers the - common installation layouts on all supported platforms without forcing users to - configure an explicit path. + not known in advance. The APIMARK_CLANG_PATH environment variable allows project-wide + or CI-wide configuration without repeating the path on every command. Automatic + discovery via PATH, xcrun, and vswhere covers common installation layouts on all + supported platforms. tests: - CppGenerator_Generate_ValidHeaders_CreatesApiEntrypoint - id: Clang-AstDump-ProducesParseableJson diff --git a/docs/user_guide/cli-reference.md b/docs/user_guide/cli-reference.md index 3c20f7f..bc32ef0 100644 --- a/docs/user_guide/cli-reference.md +++ b/docs/user_guide/cli-reference.md @@ -42,16 +42,65 @@ apimark cpp [options] | Option | Description | | --- | --- | -| `--includes ` | Comma-separated list of public include directories (required) | +| `--includes ` | Include directory for clang `-I` (repeatable, required) | +| `--api-headers ` | Glob pattern for documented headers; supports `!` exclusions (repeatable, ordered) | | `--output ` | Output directory for Markdown files (required) | | `--library-name ` | Library name used as the top-level heading (default: output directory name) | | `--library-description ` | Optional description for the library `api.md` introduction | | `--defines ` | Comma-separated preprocessor definitions (e.g. `MYLIB_API=,NDEBUG`) | | `--cpp-standard ` | C++ language standard passed to Clang (default: `c++17`) | -| `--clang-path ` | Path to clang executable (default: auto-discovered via PATH / xcrun / vswhere) | +| `--clang-path ` | Path to clang executable (default: auto-discovered via `APIMARK_CLANG_PATH`, PATH / xcrun / vswhere) | | `--visibility ` | Visibility filter: `Public`, `PublicAndProtected`, `All` (default: `Public`) | | `--include-obsolete` | Include deprecated members in generated output | +#### `--includes` and `--api-headers` + +`--includes ` is repeatable — provide it once per include directory. All directories +are passed to Clang as `-I` paths. When `--api-headers` is not specified, all recognized +header files under every `--includes` directory are documented automatically. + +`--api-headers ` controls which headers appear in the generated documentation. +It is repeatable and ordered — patterns are evaluated in order with gitignore-style +last-match-wins semantics. Entries starting with `!` are exclusion patterns. + +When `--api-headers` is not specified, all headers under the `--includes` directories +with recognized C++ header extensions (`.h`, `.hpp`, `.hxx`, `.h++`) are documented. + +Patterns are evaluated as relative paths. When include roots lie within the current +working directory (the typical project layout), patterns are relative to the CWD — +so `include/**` targets exactly one root tree even when multiple `--includes` roots +are configured. When an include root is an absolute path outside the CWD (e.g. +`/usr/local/include`), patterns are matched against the path relative to that root +instead; filename-wildcard patterns such as `**/foo.h` work correctly in both cases. + +Example — document all headers except a `detail/` subtree, then re-include one header: + +```text +--includes include/ \ +--api-headers "include/**" \ +--api-headers "!include/detail/**" \ +--api-headers "include/detail/public_api.h" +``` + +This is equivalent to the gitignore rule sequence: + +| Pattern | Effect | +| --- | --- | +| `include/**` | Include all headers under `include/` | +| `!include/detail/**` | Exclude all headers under `include/detail/` | +| `include/detail/public_api.h` | Re-include `include/detail/public_api.h` specifically | + +#### Clang executable discovery + +ApiMark locates the clang executable using the following priority order: + +1. `--clang-path ` — explicit path, used as-is (must exist on disk). +2. `APIMARK_CLANG_PATH` environment variable — set this in CI or shell profiles + to configure clang project-wide without repeating the path on every invocation. +3. `clang` on the system `PATH`. +4. `xcrun clang` — macOS only, selects the active Xcode SDK automatically. +5. vswhere-located LLVM clang / `C:\Program Files\LLVM\bin\clang.exe` — Windows only. + ## Platform Support | Platform | `dotnet` | `cpp` | @@ -67,9 +116,12 @@ ApiMark uses a four-tier gradual disclosure layout: | File | Description | | --- | --- | | `api.md` | Root index — lists all namespaces with type counts and one-line summaries | -| `{namespace}.md` | Namespace summary — lists all types, enums, and functions with one-line summaries | +| `{namespace}.md` | Namespace summary — lists all types, enums, type aliases, and functions with one-line summaries | | `{namespace}/{type}.md` | Type page — members grouped by kind with signatures and doc comment details | +| `{namespace}/{alias}.md` | Type alias page — `using` declaration, underlying type, and doc comment | | `{namespace}/{type}/{member}.md` | Member detail page — full signature, parameters, return value, remarks | +| `{namespace}/{type}/{nested-type}.md` | Nested type page — same structure as a top-level type page | +| `{namespace}/{type}/{alias}.md` | Class-scoped type alias page — alias declared inside a class body | An AI agent can read the root index first, drill into the relevant namespace summary, and then load a specific type or member page — consuming only as much diff --git a/docs/user_guide/msbuild-integration.md b/docs/user_guide/msbuild-integration.md index de1ed8a..7606361 100644 --- a/docs/user_guide/msbuild-integration.md +++ b/docs/user_guide/msbuild-integration.md @@ -38,10 +38,11 @@ the project file extension. | Property | Default | Description | | --- | --- | --- | -| `ApiMarkIncludePaths` | _(required)_ | Semicolon-separated list of public include directories | +| `ApiMarkIncludePaths` | _(required)_ | Semicolon-separated list of include directory paths. Each entry is passed to Clang as a `-I` path. When `ApiMarkApiHeaders` is not set, all headers with recognized C++ extensions under these paths are documented. | +| `ApiMarkApiHeaders` | _(unset)_ | Semicolon-separated, order-preserved list of glob and exclusion pattern strings. Entries with `!` are exclusion patterns; gitignore-style last-match-wins semantics apply. When unset, all headers with recognized C++ extensions under `ApiMarkIncludePaths` are documented. | | `ApiMarkLibraryName` | `$(MSBuildProjectName)` | Library name used as the top-level heading in `api.md` | | `ApiMarkLibraryDescription` | _(unset)_ | Optional description for the `api.md` introduction paragraph | -| `ApiMarkDefines` | _(unset)_ | Comma-separated preprocessor definitions (e.g. `MYLIB_API=,NDEBUG`) | +| `ApiMarkDefines` | _(unset)_ | Semicolon-separated preprocessor definitions (e.g. `MYLIB_API=;NDEBUG`) | | `ApiMarkCppStandard` | `c++17` | C++ language standard passed to Clang | | `ApiMarkClangPath` | _(auto-discovered)_ | Path to clang executable; overrides PATH / xcrun / vswhere discovery | @@ -89,6 +90,12 @@ the project file extension. + + + + + + ``` diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index b7b510c..12afb48 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -20,7 +20,7 @@ configuration beyond a standard clang installation is required. ## Acceptance Criteria - All ApiMarkCpp tests pass with zero failures. -- The generator discovers namespaces, types, free functions, and enums from the fixture headers. +- The generator discovers namespaces, types, free functions, enums, and type aliases from the fixture headers. - Doxygen `@brief` comments appear as description paragraphs in generated output. - Visibility filtering (Public, PublicAndProtected, All) correctly includes and excludes class members based on their C++ access specifier. @@ -29,9 +29,13 @@ configuration beyond a standard clang installation is required. - All visible members — including parameterless methods, constructors, and free functions — receive their own dedicated detail pages, except where case-insensitive filename collisions require combining members onto one shared page. +- Explicitly deleted functions and operators are documented with a `= delete` suffix in their + signatures. +- `using` type aliases receive their own pages at `{namespace}/{aliasName}.md` and are listed + in the namespace summary under a "Type Aliases" section. - Output files follow the naming convention: `api.md` entrypoint, `{namespace}.md` namespace - summaries, `{namespace}/{TypeName}.md` type pages, and `{namespace}/{TypeName}/{MemberName}.md` - member detail pages. + summaries, `{namespace}/{TypeName}.md` type pages, `{namespace}/{AliasName}.md` type alias + pages, and `{namespace}/{TypeName}/{MemberName}.md` member detail pages. ## Test Scenarios @@ -156,3 +160,28 @@ than an unrelated null-reference failure during I/O. This scenario is tested by `DirectoryNotFoundException` when a configured PublicIncludeRoot path does not exist on disk, providing a clear diagnostic rather than silently producing empty output. This scenario is tested by `CppGenerator_Generate_NonexistentIncludeRoot_ThrowsDirectoryNotFoundException`. + +**Deleted copy constructor signature contains = delete suffix**: Verifies that a copy constructor +declared with `= delete` is documented with a `= delete` suffix in its signature block so that +readers can see the intentional prohibition without opening the header file. This scenario is +tested by `CppGenerator_Generate_DeletedCopyConstructor_EmitsDeleteSuffix`. + +**Deleted copy-assignment operator signature contains = delete suffix**: Verifies that a +copy-assignment operator declared with `= delete` is documented with a `= delete` suffix in its +signature so that the prohibition is visible on the combined operators page. This scenario is +tested by `CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffix`. + +**Type aliases receive their own pages**: Verifies that `using` type alias declarations in +documented namespaces produce individual pages at `{namespace}/{aliasName}`, following the same +convention as class and enum pages. This scenario is tested by +`CppGenerator_Generate_TypeAlias_CreatesAliasPages`. + +**Type alias page contains declaration and summary**: Verifies that the type alias page contains +the `using {name} = {underlying}` declaration in a fenced code block and the Doxygen `@brief` +summary as a description paragraph. This scenario is tested by +`CppGenerator_Generate_TypeAliasPage_ContainsDeclarationAndSummary`. + +**Namespace page lists type aliases**: Verifies that the namespace summary page includes a +"Type Aliases" section that lists every owned alias so readers can discover them without opening +individual alias pages. This scenario is tested by +`CppGenerator_Generate_NamespacePage_ListsTypeAliases`. diff --git a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs index 900927b..73d94f6 100644 --- a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs +++ b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs @@ -153,6 +153,9 @@ public static CppCompilationResult Parse( // Clang discovery // ========================================================================= + /// The name of the environment variable that overrides automatic clang discovery. + internal const string ClangPathEnvVar = "APIMARK_CLANG_PATH"; + /// /// Resolves the clang executable path and any command prefix needed to invoke it. /// @@ -160,6 +163,7 @@ public static CppCompilationResult Parse( /// Discovery order: /// /// Explicit from options (must exist on disk). + /// APIMARK_CLANG_PATH environment variable (must exist on disk). /// clang on the system PATH. /// On macOS: xcrun with "clang" as the first argument. /// On Windows: vswhere-located LLVM clang, then @@ -192,19 +196,33 @@ private static (string FileName, IReadOnlyList Prefix) FindClangExecutab return (clangPath, []); } - // Option 2: clang on the system PATH (all platforms) + // Option 2: APIMARK_CLANG_PATH environment variable + var envPath = Environment.GetEnvironmentVariable(ClangPathEnvVar); + if (!string.IsNullOrEmpty(envPath)) + { + if (!File.Exists(envPath)) + { + throw new InvalidOperationException( + $"Clang executable not found at the path specified by the " + + $"{ClangPathEnvVar} environment variable: '{envPath}'."); + } + + return (envPath, []); + } + + // Option 3: clang on the system PATH (all platforms) if (TryFindOnPath("clang", out var clangOnPath)) { return (clangOnPath, []); } - // Option 3: macOS — use xcrun so the active Xcode SDK is selected automatically + // Option 4: macOS — use xcrun so the active Xcode SDK is selected automatically if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { return ("xcrun", ["clang"]); } - // Option 4: Windows — query vswhere, then fall back to the default LLVM install location + // Option 5: Windows — query vswhere, then fall back to the default LLVM install location if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var vsClang = FindVsClang(); @@ -223,6 +241,7 @@ private static (string FileName, IReadOnlyList Prefix) FindClangExecutab throw new InvalidOperationException( "Clang executable not found. Install LLVM clang and ensure 'clang' is on PATH, " + + $"set the {ClangPathEnvVar} environment variable, " + "or set CppGeneratorOptions.ClangPath to the absolute path of the clang executable."); } @@ -342,7 +361,7 @@ private static bool TryFindOnPath( /// Any arguments (e.g. "clang" for xcrun). /// Core AST-dump flags and input-type flags. /// C++ standard flag. - /// -I flags for public include roots and additional include paths. + /// -I flags for public include roots. /// -isystem flags for system include paths. /// -D flags for preprocessor defines. /// Additional compiler arguments (escape-hatch). @@ -366,19 +385,14 @@ private static IReadOnlyList BuildArguments( // C++ language standard args.Add($"-std={options.CppStandard}"); - // Public include roots and additional include paths as -I flags + // All public include roots are passed as -I flags so headers can find each other + // — all compiler include directories live in PublicIncludeRoots after the redesign foreach (var root in options.PublicIncludeRoots) { args.Add("-I"); args.Add(root); } - foreach (var path in options.AdditionalIncludePaths) - { - args.Add("-I"); - args.Add(path); - } - // System include paths — declarations found here are resolved but never documented foreach (var path in options.SystemIncludePaths) { @@ -585,6 +599,10 @@ private void WalkNodes(JsonElement inner, string nsQualName) ParseEnum(node, nsQualName); break; + case "TypeAliasDecl": + ParseTypeAlias(node, nsQualName); + break; + case "FunctionDecl": case "FunctionTemplateDecl": ParseFreeFunction(node, nsQualName); @@ -671,6 +689,10 @@ private void WalkClassTemplate(JsonElement node, string nsQualName) /// Parses a CXXRecordDecl node into a and adds it /// to the appropriate namespace builder. /// + /// + /// This is a thin wrapper over that adds the resulting + /// to the namespace accumulator when parsing succeeds. + /// /// The CXXRecordDecl JSON node with completeDefinition: true. /// The qualified name of the enclosing namespace. /// @@ -681,23 +703,58 @@ private void ParseClass( JsonElement node, string nsQualName, IReadOnlyList? templateParams) + { + // Delegate to BuildClass for all parsing, then register the result in the namespace builder + var cls = BuildClass(node, nsQualName, templateParams); + if (cls != null) + { + GetNsBuilder(nsQualName).Classes.Add(cls); + } + } + + /// + /// Builds a from a CXXRecordDecl JSON node and returns + /// it without adding it to any namespace builder. + /// + /// + /// Separated from so that nested class declarations found + /// inside a class body can be parsed recursively and collected in the parent class's + /// list rather than added to the namespace builder. + /// + /// The CXXRecordDecl JSON node with completeDefinition: true. + /// + /// The qualified name of the enclosing scope (namespace or class). Used as context + /// when recursing into nested class bodies. + /// + /// + /// Template parameters extracted from an enclosing ClassTemplateDecl, or + /// for non-template classes. + /// + /// + /// A populated , or when the node is + /// not owned, is compiler-synthesized, or has no valid name. + /// + private CppClass? BuildClass( + JsonElement node, + string nsQualName, + IReadOnlyList? templateParams) { // Skip when the class is not owned by a public include root if (!IsOwned(_currentFile)) { - return; + return null; } // Skip compiler-synthesized records (anonymous structs, etc.) if (node.TryGetProperty("isImplicit", out var impl) && impl.GetBoolean()) { - return; + return null; } var name = GetName(node); if (string.IsNullOrEmpty(name)) { - return; + return null; } // Determine the default access level: struct → public, class → private @@ -709,6 +766,8 @@ private void ParseClass( var members = new List(); var fields = new List(); var baseTypes = new List(); + var nestedClasses = new List(); + var typeAliases = new List(); CppDocComment? doc = null; // Deprecated if isDeprecated flag is set; DeprecatedAttr in inner also triggers @@ -718,6 +777,28 @@ private void ParseClass( var isFinal = false; var location = GetCurrentSourceLocation(node); + // Clang 18+ surfaces base class information in a top-level "bases" array on the + // CXXRecordDecl node rather than as CXXBaseSpecifier child nodes inside "inner". + // Earlier versions emit CXXBaseSpecifier nodes in "inner" (handled below). + // Both paths are retained so the parser works across clang versions. + var hasTopLevelBases = false; + if (node.TryGetProperty("bases", out var bases)) + { + foreach (var baseEntry in bases.EnumerateArray()) + { + if (baseEntry.TryGetProperty("type", out var bt) && + bt.TryGetProperty("qualType", out var qtEl)) + { + var baseName = qtEl.GetString(); + if (!string.IsNullOrEmpty(baseName)) + { + baseTypes.Add(new CppBaseType(baseName)); + hasTopLevelBases = true; + } + } + } + } + if (node.TryGetProperty("inner", out var inner)) { foreach (var child in inner.EnumerateArray()) @@ -759,9 +840,112 @@ private void ParseClass( break; } + case "CXXRecordDecl": + // Recursively collect public nested classes with complete definitions; + // implicit and forward declarations are excluded by BuildClass itself + if (currentAccess == CppAccessibility.Public && + child.TryGetProperty("completeDefinition", out var ncd) && ncd.GetBoolean()) + { + var nestedCls = BuildClass(child, $"{nsQualName}::{name}", null); + if (nestedCls != null) + { + nestedClasses.Add(nestedCls); + } + } + + break; + + case "ClassTemplateDecl": + // Handle nested template classes; only collect public ones + if (currentAccess == CppAccessibility.Public && + child.TryGetProperty("inner", out var tmplInner)) + { + // Gather template type parameters before reaching the CXXRecordDecl child + var tmplParams = new List(); + var tmplParsed = false; + foreach (var tmplChild in tmplInner.EnumerateArray()) + { + UpdateCurrentFile(tmplChild); + var tmplKind = GetKind(tmplChild); + if (tmplKind is "TemplateTypeParmDecl" or "NonTypeTemplateParmDecl" + or "TemplateTemplateParmDecl") + { + var tpName = GetName(tmplChild); + if (!string.IsNullOrEmpty(tpName)) + { + tmplParams.Add(new CppTemplateParam(tpName)); + } + } + else if (!tmplParsed && tmplKind == "CXXRecordDecl" && + tmplChild.TryGetProperty("completeDefinition", out var tcd) && + tcd.GetBoolean()) + { + // Process only the primary template definition; skip specializations + var nestedCls = BuildClass(tmplChild, $"{nsQualName}::{name}", tmplParams); + if (nestedCls != null) + { + nestedClasses.Add(nestedCls); + } + + tmplParsed = true; + } + } + } + + break; + + case "TypeAliasDecl": + // Collect public class-scoped using-aliases and parse their doc comments + if (currentAccess == CppAccessibility.Public) + { + var aliasName = GetName(child); + if (!string.IsNullOrEmpty(aliasName)) + { + // Underlying type lives in child["type"]["qualType"] + var underlyingType = string.Empty; + if (child.TryGetProperty("type", out var typeNode) && + typeNode.TryGetProperty("qualType", out var qualTypeNode)) + { + underlyingType = qualTypeNode.GetString() ?? string.Empty; + } + + var aliasIsDeprecated = + child.TryGetProperty("isDeprecated", out var aliasDepEl) && + aliasDepEl.GetBoolean(); + var aliasLocation = GetCurrentSourceLocation(child); + CppDocComment? aliasDoc = null; + + if (child.TryGetProperty("inner", out var aliasInner)) + { + foreach (var aliasChild in aliasInner.EnumerateArray()) + { + var aliasChildKind = GetKind(aliasChild); + switch (aliasChildKind) + { + case "FullComment": + aliasDoc = ParseFullComment(aliasChild); + break; + case "DeprecatedAttr": + aliasIsDeprecated = true; + break; + } + } + } + + typeAliases.Add(new CppTypeAlias( + aliasName, underlyingType, aliasIsDeprecated, aliasLocation, aliasDoc)); + } + } + + break; + case "CXXBaseSpecifier": - // Capture the base-class name from type.qualType - if (child.TryGetProperty("type", out var bt) && + // Fallback for clang versions older than 18 that emit base specifiers + // as CXXBaseSpecifier child nodes in "inner" rather than in a top-level + // "bases" array. Skipped when base types were already collected from the + // top-level "bases" array to avoid duplicates across clang versions. + if (!hasTopLevelBases && + child.TryGetProperty("type", out var bt) && bt.TryGetProperty("qualType", out var qtEl)) { var baseName = qtEl.GetString(); @@ -788,18 +972,18 @@ private void ParseClass( } } - var cls = new CppClass( + return new CppClass( name, baseTypes, templateParams ?? [], members, fields, + nestedClasses, + typeAliases, isDeprecated, isFinal, location, doc); - - GetNsBuilder(nsQualName).Classes.Add(cls); } /// @@ -858,6 +1042,7 @@ private void ParseFreeFunction(JsonElement node, string nsQualName) var isVariadic = node.TryGetProperty("variadic", out var v) && v.GetBoolean(); var isDeprecated = node.TryGetProperty("isDeprecated", out var dep) && dep.GetBoolean(); + var isDeleted = node.TryGetProperty("explicitlyDeleted", out var del) && del.GetBoolean(); var location = GetCurrentSourceLocation(node); var parameters = new List(); CppDocComment? doc = null; @@ -892,6 +1077,7 @@ private void ParseFreeFunction(JsonElement node, string nsQualName) false, isVariadic, isDeprecated, + isDeleted, location, doc); @@ -963,6 +1149,59 @@ private void ParseEnum(JsonElement node, string nsQualName) GetNsBuilder(nsQualName).Enums.Add(cppEnum); } + /// + /// Parses a TypeAliasDecl node (a using X = Y declaration) into a + /// and adds it to the appropriate namespace builder. + /// + /// The TypeAliasDecl JSON node. + /// The qualified name of the enclosing namespace. + private void ParseTypeAlias(JsonElement node, string nsQualName) + { + // Skip when not owned by a public include root + if (!IsOwned(_currentFile)) + { + return; + } + + var name = GetName(node); + if (string.IsNullOrEmpty(name)) + { + return; + } + + // The underlying type is in node["type"]["qualType"] + var underlyingType = string.Empty; + if (node.TryGetProperty("type", out var typeNode) && + typeNode.TryGetProperty("qualType", out var qualTypeNode)) + { + underlyingType = qualTypeNode.GetString() ?? string.Empty; + } + + var isDeprecated = node.TryGetProperty("isDeprecated", out var dep) && dep.GetBoolean(); + var location = GetCurrentSourceLocation(node); + CppDocComment? doc = null; + + if (node.TryGetProperty("inner", out var inner)) + { + foreach (var child in inner.EnumerateArray()) + { + var childKind = GetKind(child); + switch (childKind) + { + case "FullComment": + doc = ParseFullComment(child); + break; + case "DeprecatedAttr": + isDeprecated = true; + break; + } + } + } + + var alias = new CppTypeAlias(name, underlyingType, isDeprecated, location, doc); + GetNsBuilder(nsQualName).TypeAliases.Add(alias); + } + /// /// Parses a CXXMethodDecl, CXXConstructorDecl, or /// CXXDestructorDecl node into a . @@ -1018,6 +1257,7 @@ private void ParseEnum(JsonElement node, string nsQualName) var isVirtual = node.TryGetProperty("isVirtual", out var iv) && iv.GetBoolean(); var isVariadic = node.TryGetProperty("variadic", out var varNode) && varNode.GetBoolean(); var isDeprecated = node.TryGetProperty("isDeprecated", out var dep) && dep.GetBoolean(); + var isDeleted = node.TryGetProperty("explicitlyDeleted", out var del) && del.GetBoolean(); // Source location: record the file and line from the node's own loc field var location = GetCurrentSourceLocation(node); @@ -1054,6 +1294,7 @@ private void ParseEnum(JsonElement node, string nsQualName) isConstructor, isVariadic, isDeprecated, + isDeleted, location, doc); } @@ -1120,7 +1361,7 @@ private void ParseEnum(JsonElement node, string nsQualName) /// Parses a ParmVarDecl node into a . /// /// The ParmVarDecl JSON node. - /// A with the parameter name and type. + /// A with the parameter name, type, and optional default value. private static CppParameter ParseParameter(JsonElement node) { var name = GetName(node) ?? string.Empty; @@ -1129,7 +1370,79 @@ private static CppParameter ParseParameter(JsonElement node) ? qt.GetString() ?? string.Empty : string.Empty; - return new CppParameter(name, typeName); + // When "init" is present the parameter has a default argument; extract a display string + // from the first child expression node + string? defaultValue = null; + if (node.TryGetProperty("init", out _) && + node.TryGetProperty("inner", out var innerEl) && + innerEl.GetArrayLength() > 0) + { + defaultValue = ExtractDefaultValue(innerEl[0]); + } + + return new CppParameter(name, typeName, defaultValue); + } + + /// + /// Recursively extracts a display string for a default-argument expression node. + /// Handles integer, floating-point, boolean, string, and nullptr literals, as well + /// as implicit cast wrappers that the clang AST inserts around some literals. + /// Returns when the expression is too complex to represent + /// as a simple display string. + /// + /// The root expression node from the inner array of a ParmVarDecl. + /// A display string for the default value, or . + private static string? ExtractDefaultValue(JsonElement node) + { + var kind = node.TryGetProperty("kind", out var k) ? k.GetString() : null; + + return kind switch + { + // Numeric literals carry their value as a JSON string + "IntegerLiteral" or "FloatingLiteral" => + node.TryGetProperty("value", out var v) ? v.GetString() : null, + + // Boolean literals carry their value as a JSON boolean, not a string + "CXXBoolLiteralExpr" => + node.TryGetProperty("value", out var bv) + ? bv.ValueKind switch + { + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => bv.GetString(), // fallback for unexpected encoding + } + : null, + + // String literals carry their value (already includes surrounding quotes) + "StringLiteral" => + node.TryGetProperty("value", out var sv) ? sv.GetString() : null, + + // nullptr literal + "CXXNullPtrLiteralExpr" => "nullptr", + + // Named constant or enum value — use the referenced declaration name + "DeclRefExpr" => + node.TryGetProperty("referencedDecl", out var rd) && + rd.TryGetProperty("name", out var rn) ? rn.GetString() : null, + + // Implicit casts and other wrapper expressions — recurse into first child + "ImplicitCastExpr" or "CStyleCastExpr" or "CXXStaticCastExpr" + or "CXXFunctionalCastExpr" or "MaterializeTemporaryExpr" + or "ExprWithCleanups" or "CXXBindTemporaryExpr" => + node.TryGetProperty("inner", out var inner) && inner.GetArrayLength() > 0 + ? ExtractDefaultValue(inner[0]) + : null, + + // Unary operator (e.g. -1) — reconstruct from operator token and child + "UnaryOperator" => + node.TryGetProperty("opcode", out var op) && + node.TryGetProperty("inner", out var uInner) && uInner.GetArrayLength() > 0 + ? $"{op.GetString()}{ExtractDefaultValue(uInner[0])}" + : null, + + // All other expressions are too complex to display simply + _ => null, + }; } // ========================================================================= @@ -1184,6 +1497,7 @@ private static CppAccessibility ParseAccessSpec(JsonElement node) string? summary = null; string? details = null; string? returns = null; + string? note = null; var paramDocs = new List(); foreach (var child in inner.EnumerateArray()) @@ -1233,6 +1547,11 @@ private static CppAccessibility ParseAccessSpec(JsonElement node) { returns = NormalizeSingleLine(cmdText); } + else if (string.Equals(cmdName, "note", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(cmdText)) + { + note = NormalizeSingleLine(cmdText); + } break; @@ -1250,12 +1569,12 @@ private static CppAccessibility ParseAccessSpec(JsonElement node) } // Return null when the comment block carried no extractable documentation - if (summary == null && details == null && returns == null && paramDocs.Count == 0) + if (summary == null && details == null && returns == null && note == null && paramDocs.Count == 0) { return null; } - return new CppDocComment(summary, details, paramDocs, returns); + return new CppDocComment(summary, details, paramDocs, returns, note); } /// @@ -1554,6 +1873,9 @@ public NamespaceBuilder(string qualifiedName) /// Gets the mutable list of enums being accumulated. public List Enums { get; } = []; + /// Gets the mutable list of type aliases being accumulated. + public List TypeAliases { get; } = []; + /// /// Builds an immutable from the accumulated /// declarations. @@ -1563,7 +1885,7 @@ public CppNamespaceDecl Build() { // Namespace-level doc comments are not reliably exposed in the clang JSON // AST dump for plain namespace declarations, so Doc is always null here - return new CppNamespaceDecl(QualifiedName, Classes, FreeFunctions, Enums, Doc: null); + return new CppNamespaceDecl(QualifiedName, Classes, FreeFunctions, Enums, TypeAliases, Doc: null); } } } diff --git a/src/ApiMark.Cpp/CppAst/CppAstModel.cs b/src/ApiMark.Cpp/CppAst/CppAstModel.cs index 8d7fe70..cb2da3e 100644 --- a/src/ApiMark.Cpp/CppAst/CppAstModel.cs +++ b/src/ApiMark.Cpp/CppAst/CppAstModel.cs @@ -42,11 +42,13 @@ public record CppParamDoc(string Name, string Description); /// /// One entry for each @param tag found on the declaration. /// Return description from a @return or @returns tag. +/// Contextual note from a @note tag, or when absent. public record CppDocComment( string? Summary, string? Details, IReadOnlyList Params, - string? Returns); + string? Returns, + string? Note = null); /// Names a base type in a C++ class inheritance list. /// @@ -75,7 +77,11 @@ public record CppEnumValue(string Name, CppDocComment? Doc); /// The parameter type as clang reports it in type.qualType, /// e.g. "const std::string &". /// -public record CppParameter(string Name, string TypeName); +/// +/// The default argument value as a display string (e.g. "0", "nullptr"), +/// or when no default is declared. +/// +public record CppParameter(string Name, string TypeName, string? DefaultValue = null); /// Represents a field (data member) of a C++ class or struct. /// The field name. @@ -120,6 +126,9 @@ public record CppField( /// /// when the function carries a [[deprecated]] attribute. /// +/// +/// when the function is declared = delete, explicitly forbidding its use. +/// /// Source location of the declaration, or when unavailable. /// Doxygen documentation attached to this function, or when absent. public record CppFunction( @@ -132,6 +141,7 @@ public record CppFunction( bool IsConstructor, bool IsVariadic, bool IsDeprecated, + bool IsDeleted, CppSourceLocation? Location, CppDocComment? Doc); @@ -140,12 +150,23 @@ public record CppFunction( /// Template classes carry their type parameters in ; non-template /// classes have an empty list. contains all constructors and methods; /// callers use to distinguish them. +/// holds any public nested class or struct declarations found +/// inside the class body. holds any public using type alias +/// declarations scoped to this class. /// /// The unqualified class name. /// Direct base classes, in declaration order. /// Template type parameters; empty for non-template classes. /// All constructors and methods declared in the class body, in declaration order. /// All data member fields declared in the class body, in declaration order. +/// +/// Public nested class and struct declarations found inside the class body, in declaration order. +/// Empty when no nested classes are present. +/// +/// +/// Public using type alias declarations scoped to this class, in declaration order. +/// Empty when no class-scoped aliases are present. +/// /// /// when the class carries a [[deprecated]] attribute. /// @@ -160,6 +181,8 @@ public record CppClass( IReadOnlyList TemplateParams, IReadOnlyList Members, IReadOnlyList Fields, + IReadOnlyList NestedClasses, + IReadOnlyList TypeAliases, bool IsDeprecated, bool IsFinal, CppSourceLocation? Location, @@ -180,6 +203,24 @@ public record CppEnum( CppSourceLocation? Location, CppDocComment? Doc); +/// Represents a using type alias declaration in a C++ namespace. +/// The alias name (e.g. fatal_error_code_t). +/// +/// The underlying type string as reported by clang (e.g. int32_t or +/// void (*)(fatal_error_code_t, const void *)). +/// +/// +/// when the alias carries a [[deprecated]] attribute. +/// +/// Source location of the alias declaration, or when unavailable. +/// Doxygen documentation attached to this alias, or when absent. +public record CppTypeAlias( + string Name, + string UnderlyingTypeName, + bool IsDeprecated, + CppSourceLocation? Location, + CppDocComment? Doc); + /// /// Groups all owned declarations contributed by a single C++ namespace (or the global namespace). /// @@ -195,12 +236,14 @@ public record CppEnum( /// All owned class and struct declarations contributed to this namespace. /// All owned free function declarations contributed to this namespace. /// All owned enum declarations contributed to this namespace. +/// All owned using type alias declarations contributed to this namespace. /// Doxygen documentation attached to the namespace, or when absent. public record CppNamespaceDecl( string QualifiedName, IReadOnlyList Classes, IReadOnlyList FreeFunctions, IReadOnlyList Enums, + IReadOnlyList TypeAliases, CppDocComment? Doc); /// Encapsulates the complete parsed result returned by . diff --git a/src/ApiMark.Cpp/CppExternalTypeInfo.cs b/src/ApiMark.Cpp/CppExternalTypeInfo.cs new file mode 100644 index 0000000..49c19c0 --- /dev/null +++ b/src/ApiMark.Cpp/CppExternalTypeInfo.cs @@ -0,0 +1,53 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +namespace ApiMark.Cpp; + +/// +/// Represents a non-standard external C++ type reference found in the documentation. +/// +/// +/// Instances are collected per generated Markdown file and emitted in the +/// "External Types" section so that readers know which additional headers or +/// libraries they must include to use the documented API. Types in the std:: +/// namespace and C++ primitives are excluded; only types with an explicit namespace +/// that is not std are recorded here. +/// +/// The type name as it appears in the documentation table. +/// The C++ namespace that declares the type, using :: separators. +internal sealed record CppExternalTypeInfo(string TypeString, string Namespace) + : IComparable +{ + /// + /// Compares this instance to by type string, + /// enabling deterministic alphabetical sorting of the External Types table. + /// + /// The other instance to compare against, or . + /// + /// A negative value when this instance sorts before , + /// zero when equal, or a positive value when it sorts after. + /// + public int CompareTo(CppExternalTypeInfo? other) + { + var nameComparison = StringComparer.Ordinal.Compare(TypeString, other?.TypeString); + return nameComparison != 0 + ? nameComparison + : StringComparer.Ordinal.Compare(Namespace, other?.Namespace); + } + + /// Returns when sorts before . + public static bool operator <(CppExternalTypeInfo left, CppExternalTypeInfo right) => + left.CompareTo(right) < 0; + + /// Returns when sorts before or equal to . + public static bool operator <=(CppExternalTypeInfo left, CppExternalTypeInfo right) => + left.CompareTo(right) <= 0; + + /// Returns when sorts after . + public static bool operator >(CppExternalTypeInfo left, CppExternalTypeInfo right) => + left.CompareTo(right) > 0; + + /// Returns when sorts after or equal to . + public static bool operator >=(CppExternalTypeInfo left, CppExternalTypeInfo right) => + left.CompareTo(right) >= 0; +} diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 2828253..a16d7b7 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -2,7 +2,6 @@ using ApiMark.Core; using ApiMark.Cpp.CppAst; using Microsoft.Extensions.FileSystemGlobbing; -using Microsoft.Extensions.FileSystemGlobbing.Abstractions; namespace ApiMark.Cpp; @@ -114,6 +113,32 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) CollectResultNamespace(ns, namespaceDecls); } + // Build the intra-library type map for link resolution. + // nsKey uses "." separators for file paths; display names use "::" for C++ qualified names. + // FlattenClass recursively registers a class, its scoped type aliases, and any nested + // classes so that fully-qualified names like "fixtures::Outer::Inner" resolve correctly. + static IEnumerable<(string Key, string Value)> FlattenClass(string nsDisplay, string nsPath, CppClass cls) + { + var clsDisplay = $"{nsDisplay}::{cls.Name}"; + var clsPath = $"{nsPath}/{cls.Name}"; + return new[] { (Key: clsDisplay, Value: clsPath) } + .Concat(cls.TypeAliases.Select(alias => + (Key: $"{clsDisplay}::{alias.Name}", Value: $"{clsPath}/{alias.Name}"))) + .Concat(cls.NestedClasses.SelectMany(nested => + FlattenClass(clsDisplay, clsPath, nested))); + } + + var knownTypes = namespaceDecls.SelectMany(kv => + { + var nsDisplay = kv.Key.Replace(".", "::", StringComparison.Ordinal); + var nsPath = kv.Key; // preserve dot-separated key to match CreateMarkdown page keys + return kv.Value.Classes.SelectMany(cls => FlattenClass(nsDisplay, nsPath, cls)) + .Concat(kv.Value.Enums.Select(enm => (Key: $"{nsDisplay}::{enm.Name}", Value: $"{nsPath}/{enm.Name}"))) + .Concat(kv.Value.TypeAliases.Select(a => (Key: $"{nsDisplay}::{a.Name}", Value: $"{nsPath}/{a.Name}"))); + }).ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); + + var cppResolver = new CppTypeLinkResolver(knownTypes); + // Write the library entrypoint page listing all discovered namespaces WriteApiPage(factory, namespaceDecls); @@ -121,10 +146,11 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) // one detail page per owned free function, and one detail page per owned enum foreach (var (nsKey, nsDecls) in namespaceDecls) { - WriteNamespacePage(factory, nsKey, nsDecls); + WriteNamespacePage(factory, nsKey, nsDecls, cppResolver); foreach (var cls in nsDecls.Classes) { - WriteTypePage(factory, nsKey, nsDecls.DisplayName, cls); + WriteTypePage(factory, nsKey, nsDecls.DisplayName, cls, cppResolver); + WriteNestedTypePages(factory, nsKey, nsDecls.DisplayName, cls, cppResolver); } // Partition free functions into regular functions and operator overloads; @@ -137,12 +163,12 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) foreach (var fn in nsDecls.FreeFunctions .Where(fn => !fn.Name.StartsWith("operator", StringComparison.Ordinal))) { - WriteFreeFunctionPage(factory, nsKey, nsDecls.DisplayName, fn); + WriteFreeFunctionPage(factory, nsKey, nsDecls.DisplayName, fn, cppResolver); } if (nsOperatorFunctions.Count > 0) { - WriteNamespaceOperatorsPage(factory, nsKey, nsDecls.DisplayName, nsOperatorFunctions); + WriteNamespaceOperatorsPage(factory, nsKey, nsDecls.DisplayName, nsOperatorFunctions, cppResolver); } // Write one enum detail page per owned enum declared in this namespace @@ -150,6 +176,12 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) { WriteEnumPage(factory, nsKey, nsDecls.DisplayName, en); } + + // Write one type alias page per owned using-alias declared in this namespace + foreach (var alias in nsDecls.TypeAliases) + { + WriteTypeAliasPage(factory, nsKey, nsDecls.DisplayName, alias, cppResolver); + } } } @@ -159,31 +191,78 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) /// /// Enumerates candidate header files under each configured public include root, - /// applying and - /// when present. + /// applying with gitignore-style + /// semantics to determine which headers appear in the generated documentation. /// /// /// A list of absolute header file paths with recognized C++ header extensions - /// (.h, .hpp, .hxx, .h++) that satisfy the configured - /// include/exclude glob patterns. + /// (.h, .hpp, .hxx, .h++) that are selected for + /// documentation by the configured pattern list. /// /// - /// When is empty all recognized - /// headers pass the include step. When - /// is empty no files are removed. Patterns are evaluated relative to each root directory - /// using from Microsoft.Extensions.FileSystemGlobbing. + /// + /// When is empty the default + /// patterns ["**/*.h", "**/*.hpp", "**/*.hxx", "**/*.h++"] are used, + /// preserving the behavior of runs with no configured patterns. + /// + /// + /// Gitignore-style evaluation: for each candidate file, start with + /// included = false and walk the pattern list in order. If a pattern + /// starts with !, strip the prefix and if the file matches set + /// included = false; otherwise if the file matches set + /// included = true. The final value of included determines + /// whether the header is forwarded to clang. This allows include/exclude/re-include + /// sequences that are not possible with separate include and exclude lists. + /// + /// + /// All patterns (both caller-supplied and the per-root defaults) are evaluated + /// relative to the current working directory so that paths like + /// "src/include/**" are unambiguous across multiple --includes + /// roots. A single-pattern is created per pattern check. + /// /// /// /// Thrown when a configured public include root does not exist on disk. /// private List CollectHeaderFiles() { - // Recognized C++ header file extensions + // Recognized C++ header file extensions — files with other extensions are never + // forwarded to clang even when a pattern technically matches them var headerExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".h", ".hpp", ".hxx", ".h++", }; + // Patterns are evaluated preferentially against the current working directory. + // When the include root is not under CWD (e.g. an absolute path), the pattern + // falls back to root-relative matching. Matchers are precompiled once here and + // reused for every file rather than being recreated per (file × pattern) pair. + var cwdAbsolute = Path.GetFullPath(Directory.GetCurrentDirectory()); + + // Precompile one Matcher per pattern (outside both loops) to avoid constructing + // short-lived Matcher objects on every file/pattern evaluation pair. + // Each entry is (isExclusion, compiledMatcher); empty exclusion globs are dropped. + var compiledPatterns = new List<(bool IsExclusion, Matcher Matcher)>(); + foreach (var pattern in _options.ApiHeaderPatterns) + { + if (pattern.StartsWith("!", StringComparison.Ordinal)) + { + var exclusionGlob = pattern.Substring(1).Trim(); + if (exclusionGlob.Length > 0) + { + var m = new Matcher(); + m.AddInclude(exclusionGlob); + compiledPatterns.Add((true, m)); + } + } + else + { + var m = new Matcher(); + m.AddInclude(pattern); + compiledPatterns.Add((false, m)); + } + } + var headers = new List(); foreach (var root in _options.PublicIncludeRoots) { @@ -194,53 +273,58 @@ private List CollectHeaderFiles() $"Public include root not found: '{root}'"); } - // Enumerate all files recursively and retain only recognized header extensions + var rootAbsolute = Path.GetFullPath(root); + + // Enumerate all files recursively and retain only recognized header extensions; + // non-header files are pre-filtered to prevent them from reaching clang var allFiles = Directory.GetFiles(root, "*", SearchOption.AllDirectories) .Where(f => headerExtensions.Contains(Path.GetExtension(f))) + .Select(Path.GetFullPath) .ToList(); - // Apply include/exclude glob patterns when at least one is configured; when - // neither is set, all discovered headers are forwarded to clang without filtering - if (_options.IncludePatterns.Count > 0 || _options.ExcludePatterns.Count > 0) + if (compiledPatterns.Count == 0) { - // Build a Matcher whose patterns are relative to the root directory - var matcher = new Matcher(); - - if (_options.IncludePatterns.Count > 0) + // No patterns configured: include all recognized header files under this root. + // This is the default behavior that documents every header reachable from + // the include roots, equivalent to specifying --api-headers **/*.h etc. + headers.AddRange(allFiles); + } + else + { + // Apply gitignore-style evaluation for each header file using precompiled + // matchers. Patterns are CWD-relative when the file is under CWD; otherwise + // the path is computed relative to the include root (covers absolute roots and + // test environments where the include root is not under CWD). + foreach (var absoluteFile in allFiles) { - // Caller-supplied include patterns define the set of accepted headers - foreach (var pattern in _options.IncludePatterns) + var relFromCwd = Path.GetRelativePath(cwdAbsolute, absoluteFile).Replace('\\', '/'); + + // Use CWD-relative path when the file is inside the CWD, otherwise fall + // back to root-relative. Check for the specific parent-segment tokens + // ("../" prefix or exactly "..") and rooted paths (different drive on Windows) + // to avoid misclassifying filenames that legitimately start with two dots. + var relPath = IsOutsideCwd(relFromCwd) + ? Path.GetRelativePath(rootAbsolute, absoluteFile).Replace('\\', '/') + : relFromCwd; + + // Start with the file excluded — it must explicitly match an include pattern + var included = false; + + foreach (var (isExclusion, matcher) in compiledPatterns) { - matcher.AddInclude(pattern); + if (matcher.Match(relPath).HasMatches) + { + // Inclusion pattern sets included=true; exclusion sets it false. + // Last match wins (gitignore semantics). + included = !isExclusion; + } } - } - else - { - // No include patterns: accept every file so that ExcludePatterns alone - // can narrow the set without requiring an explicit catch-all - matcher.AddInclude("**/*"); - } - foreach (var pattern in _options.ExcludePatterns) - { - matcher.AddExclude(pattern); + if (included) + { + headers.Add(absoluteFile); + } } - - // Execute the glob matcher against the root; result.Files contains matched - // relative paths which are then converted back to absolute for clang - var matchResult = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(root))); - var matchedAbsolute = new HashSet( - matchResult.Files.Select(f => Path.GetFullPath(Path.Combine(root, f.Path))), - FileSystemPathComparer); - - // Intersect the extension-filtered list with the glob-matched set so that - // files with non-header extensions are never forwarded even if a glob matches them - headers.AddRange(allFiles.Where(f => matchedAbsolute.Contains(Path.GetFullPath(f)))); - } - else - { - // No patterns configured: include all discovered header files under this root - headers.AddRange(allFiles); } } @@ -254,6 +338,27 @@ private List CollectHeaderFiles() .ToList(); } + /// + /// Returns when the path returned by + /// indicates the file lies outside the base directory. + /// + /// + /// returns ".." or a "../"-prefixed + /// path when the file is above the base, and returns the original absolute path unchanged + /// when the base and the file are on different drives (Windows). Both cases mean the file + /// is outside the base directory. Checking for the exact two-dot segment avoids + /// misclassifying filenames that legitimately start with two dots (e.g. ..hidden.h). + /// + /// The forward-slash-normalized relative path to test. + /// + /// when indicates the file is + /// outside the base directory. + /// + private static bool IsOutsideCwd(string relativeFromBase) => + relativeFromBase == ".." + || relativeFromBase.StartsWith("../", StringComparison.Ordinal) + || Path.IsPathRooted(relativeFromBase); + // ========================================================================= // Error checking // ========================================================================= @@ -363,6 +468,18 @@ private void CollectResultNamespace( EnsureNamespace(result, nsKey, displayName, ns.Doc); result[nsKey].Enums.Add(en); } + + // Collect owned type aliases, applying the deprecated filter + foreach (var alias in ns.TypeAliases) + { + if (!_options.IncludeDeprecated && alias.IsDeprecated) + { + continue; + } + + EnsureNamespace(result, nsKey, displayName, ns.Doc); + result[nsKey].TypeAliases.Add(alias); + } } /// @@ -521,7 +638,7 @@ private void WriteApiPage( var rows = namespaces.Select(kv => { var nsDecls = kv.Value; - var declarationCount = nsDecls.Classes.Count + nsDecls.Enums.Count + nsDecls.FreeFunctions.Count; + var declarationCount = nsDecls.Classes.Count + nsDecls.Enums.Count + nsDecls.TypeAliases.Count + nsDecls.FreeFunctions.Count; var description = GetNamespaceDescription(nsDecls); return new[] { $"[{nsDecls.DisplayName}]({kv.Key}.md)", declarationCount.ToString(), description }; }); @@ -551,14 +668,19 @@ private void WriteApiPage( /// Factory for creating the output writer. /// The file-path-compatible namespace key used as the output file name. /// The declarations that belong to this namespace. + /// Type link resolver used to linkify type-column cells. private static void WriteNamespacePage( IMarkdownWriterFactory factory, string nsKey, - NamespaceDeclarations nsDecls) + NamespaceDeclarations nsDecls, + CppTypeLinkResolver cppResolver) { using var writer = factory.CreateMarkdown("", nsKey); writer.WriteHeading(1, $"{nsDecls.DisplayName} Namespace"); + // Accumulate external type references found in type-column cells on this page + var externalTypes = new SortedSet(); + // Type table — one row per owned class or struct, sorted alphabetically if (nsDecls.Classes.Count > 0) { @@ -589,6 +711,22 @@ private static void WriteNamespacePage( writer.WriteTable(enumHeaders, enumRows); } + // Type alias table — one row per owned using-alias, sorted alphabetically + if (nsDecls.TypeAliases.Count > 0) + { + writer.WriteHeading(2, "Type Aliases"); + var aliasHeaders = new[] { "Alias", "Underlying Type", DescriptionColumnHeader }; + var aliasRows = nsDecls.TypeAliases + .OrderBy(a => a.Name, StringComparer.Ordinal) + .Select(alias => + { + var summary = GetSummary(alias.Doc) ?? NoDescriptionPlaceholder; + var underlying = cppResolver.Linkify(SimplifyTypeName(alias.UnderlyingTypeName), nsKey, externalTypes); + return new[] { $"[{alias.Name}]({nsKey}/{alias.Name}.md)", underlying, summary }; + }); + writer.WriteTable(aliasHeaders, aliasRows); + } + // Partition free functions into regular functions and operator overloads; operator names // such as operator+, operator-, and operator<< all sanitize to the same file name and // must be grouped onto a single operators.md page to avoid file-name collisions @@ -609,7 +747,10 @@ private static void WriteNamespacePage( .Select(fn => { var summary = GetSummary(fn.Doc) ?? NoDescriptionPlaceholder; - var returnType = SimplifyTypeName(fn.ReturnTypeName); + + // Linkify the return type cell; namespace page folder is "" (root) + var returnType = cppResolver.Linkify( + SimplifyTypeName(fn.ReturnTypeName), string.Empty, externalTypes); var safeName = SanitizeFileName(fn.Name); return new[] { $"[{fn.Name}]({nsKey}/{safeName}.md)", returnType, summary }; }); @@ -625,6 +766,8 @@ private static void WriteNamespacePage( new[] { "Operators", DescriptionColumnHeader }, new[] { new[] { $"[operators]({nsKey}/operators.md)", "Operator overloads" } }); } + + WriteExternalTypesSection(writer, externalTypes); } /// @@ -641,11 +784,13 @@ private static void WriteNamespacePage( /// fully-qualified type name shown in the signature comment. /// /// The C++ class or struct to document. + /// Type link resolver used to linkify type-column cells. private void WriteTypePage( IMarkdownWriterFactory factory, string nsKey, string nsDisplayName, - CppClass cls) + CppClass cls, + CppTypeLinkResolver cppResolver) { // Build the template parameter suffix (e.g. "" for template class Stack) var templateParamDisplay = BuildTemplateParamDisplay(cls); @@ -677,11 +822,12 @@ private void WriteTypePage( sigParts.Add(templateDecl); } - sigParts.Add($"#include <{includePath}>"); + sigParts.Add($"#include \"{includePath}\""); - // When the class is marked final, append the class declaration line so readers - // can see at a glance that the class cannot be used as a base class - if (cls.IsFinal) + // Append the class declaration line when the class is marked final or has base types so + // readers can see the final constraint and inheritance chain at a glance without + // opening the header + if (cls.IsFinal || cls.BaseTypes.Count > 0) { sigParts.Add(BuildClassDeclaration(cls)); } @@ -700,6 +846,13 @@ private void WriteTypePage( writer.WriteParagraph(typeDetails); } + // Emit @note as a blockquote when present + var typeNote = GetNote(cls.Doc); + if (!string.IsNullOrEmpty(typeNote)) + { + writer.WriteParagraph($"> **Note:** {typeNote}"); + } + // Emit base type names so readers know the inheritance chain without reading the header if (cls.BaseTypes.Count > 0) { @@ -718,7 +871,15 @@ private void WriteTypePage( var visibleMethods = GetVisibleMethods(cls).OrderBy(m => m.Name, StringComparer.Ordinal).ToList(); var visibleFields = GetVisibleFields(cls).OrderBy(f => f.Name, StringComparer.Ordinal).ToList(); - if (visibleCtors.Count == 0 && visibleMethods.Count == 0 && visibleFields.Count == 0) + // Accumulate external type references found in type-column cells on this page. + // Declared before the early return so the nested-class and type-alias sections + // that follow the member tables can also add entries. + var externalTypes = new SortedSet(); + + // Early exit when the class has no members, no nested classes, and no type aliases — + // emitting an empty page with only the signature and summary is still valid output + if (visibleCtors.Count == 0 && visibleMethods.Count == 0 && visibleFields.Count == 0 + && cls.NestedClasses.Count == 0 && cls.TypeAliases.Count == 0) { return; } @@ -790,11 +951,11 @@ private void WriteTypePage( { if (group.Count == 1) { - WriteMemberPage(factory, nsKey, nsDisplayName, cls, ctor, pageFileName); + WriteMemberPage(factory, nsKey, nsDisplayName, cls, ctor, pageFileName, cppResolver); } else { - WriteCombinedMemberPage(factory, nsKey, nsDisplayName, cls, pageFileName, group); + WriteCombinedMemberPage(factory, nsKey, nsDisplayName, cls, pageFileName, group, cppResolver); } } @@ -809,17 +970,19 @@ private void WriteTypePage( var group = caseInsensitiveGroups[lowerKey]; var pageFileName = SanitizeFileName(group.Count == 1 ? baseName : lowerKey); var methodSummary = GetSummary(method.Doc) ?? NoDescriptionPlaceholder; - var returnType = SimplifyTypeName(method.ReturnTypeName); + + // Linkify the return type cell using the type link resolver + var returnType = cppResolver.Linkify(SimplifyTypeName(method.ReturnTypeName), nsKey, externalTypes); if (writtenKeys.Add(lowerKey)) { if (group.Count == 1) { - WriteMemberPage(factory, nsKey, nsDisplayName, cls, method, pageFileName); + WriteMemberPage(factory, nsKey, nsDisplayName, cls, method, pageFileName, cppResolver); } else { - WriteCombinedMemberPage(factory, nsKey, nsDisplayName, cls, pageFileName, group); + WriteCombinedMemberPage(factory, nsKey, nsDisplayName, cls, pageFileName, group, cppResolver); } } @@ -839,17 +1002,19 @@ private void WriteTypePage( var group = caseInsensitiveGroups[lowerKey]; var pageFileName = SanitizeFileName(group.Count == 1 ? baseName : lowerKey); var fieldSummary = GetSummary(field.Doc) ?? NoDescriptionPlaceholder; - var typeName = SimplifyTypeName(field.TypeName); + + // Linkify the field type cell using the type link resolver + var typeName = cppResolver.Linkify(SimplifyTypeName(field.TypeName), nsKey, externalTypes); if (writtenKeys.Add(lowerKey)) { if (group.Count == 1) { - WriteMemberPage(factory, nsKey, nsDisplayName, cls, field, pageFileName); + WriteMemberPage(factory, nsKey, nsDisplayName, cls, field, pageFileName, cppResolver); } else { - WriteCombinedMemberPage(factory, nsKey, nsDisplayName, cls, pageFileName, group); + WriteCombinedMemberPage(factory, nsKey, nsDisplayName, cls, pageFileName, group, cppResolver); } } @@ -860,19 +1025,19 @@ private void WriteTypePage( // Each section is only emitted when at least one member of that kind is present. if (ctorRows.Count > 0) { - writer.WriteHeading(3, "Constructors"); + writer.WriteHeading(2, "Constructors"); writer.WriteTable(new[] { "Constructor", DescriptionColumnHeader }, ctorRows); } if (methodRows.Count > 0) { - writer.WriteHeading(3, "Methods"); + writer.WriteHeading(2, "Methods"); writer.WriteTable(new[] { "Member", "Returns", DescriptionColumnHeader }, methodRows); } if (fieldRows.Count > 0) { - writer.WriteHeading(3, "Fields"); + writer.WriteHeading(2, "Fields"); writer.WriteTable(new[] { "Member", "Type", DescriptionColumnHeader }, fieldRows); } @@ -880,12 +1045,90 @@ private void WriteTypePage( // a single page to prevent file-name collisions between operator+, operator-, etc. if (operatorMethods.Count > 0) { - WriteClassOperatorsPage(factory, nsKey, nsDisplayName, cls, operatorMethods); - writer.WriteHeading(3, "Operators"); + WriteClassOperatorsPage(factory, nsKey, nsDisplayName, cls, operatorMethods, cppResolver); + writer.WriteHeading(2, "Operators"); writer.WriteTable( new[] { "Operators", DescriptionColumnHeader }, new[] { new[] { $"[operators]({cls.Name}/operators.md)", "Operator overloads" } }); } + + // Emit Nested Classes table so readers can discover inner types without browsing the header + if (cls.NestedClasses.Count > 0) + { + writer.WriteHeading(2, "Nested Classes"); + var nestedHeaders = new[] { "Type", DescriptionColumnHeader }; + var nestedRows = cls.NestedClasses + .OrderBy(n => n.Name, StringComparer.Ordinal) + .Select(nested => + { + var summary = GetSummary(nested.Doc) ?? NoDescriptionPlaceholder; + return new[] { $"[{nested.Name}]({cls.Name}/{nested.Name}.md)", summary }; + }); + writer.WriteTable(nestedHeaders, nestedRows); + } + + // Emit Type Aliases table for public class-scoped using-aliases + if (cls.TypeAliases.Count > 0) + { + writer.WriteHeading(2, "Type Aliases"); + var aliasHeaders = new[] { "Alias", "Underlying Type", DescriptionColumnHeader }; + var aliasRows = cls.TypeAliases + .OrderBy(a => a.Name, StringComparer.Ordinal) + .Select(alias => + { + var summary = GetSummary(alias.Doc) ?? NoDescriptionPlaceholder; + var underlying = cppResolver.Linkify(SimplifyTypeName(alias.UnderlyingTypeName), nsKey, externalTypes); + return new[] { $"[{alias.Name}]({cls.Name}/{alias.Name}.md)", underlying, summary }; + }); + writer.WriteTable(aliasHeaders, aliasRows); + } + + WriteExternalTypesSection(writer, externalTypes); + } + + /// + /// Recursively writes type alias pages and nested class pages for a class, + /// following the same folder conventions as the parent class pages. + /// + /// + /// Class-scoped type alias pages are placed at {parentKey}/{cls.Name}/{alias.Name}. + /// Nested class type pages are placed at {parentKey}/{cls.Name}/{nested.Name}. + /// Recursion continues so that arbitrarily deep nesting is supported. + /// + /// Factory for creating output writers. + /// + /// The file-path-compatible key of the parent scope (namespace key for top-level classes, + /// or the parent class folder for deeper nesting). + /// + /// + /// The C++ qualified name of the parent scope used to build fully-qualified names + /// shown in signature comments. + /// + /// The class whose nested artifacts to write. + /// Type link resolver used to linkify type-column cells. + private void WriteNestedTypePages( + IMarkdownWriterFactory factory, + string parentKey, + string parentDisplayName, + CppClass cls, + CppTypeLinkResolver cppResolver) + { + // The class folder and display name form the base for all children of this class + var clsKey = $"{parentKey}/{cls.Name}"; + var clsDisplayName = $"{parentDisplayName}::{cls.Name}"; + + // Write one type alias page per public class-scoped using-alias + foreach (var alias in cls.TypeAliases) + { + WriteTypeAliasPage(factory, clsKey, clsDisplayName, alias, cppResolver); + } + + // Write one type page per public nested class and recurse into its own children + foreach (var nested in cls.NestedClasses) + { + WriteTypePage(factory, clsKey, clsDisplayName, nested, cppResolver); + WriteNestedTypePages(factory, clsKey, clsDisplayName, nested, cppResolver); + } } /// @@ -909,14 +1152,17 @@ private void WriteTypePage( /// The ordered list of operator methods to document. All elements must have names /// starting with "operator". Must contain at least one element. /// + /// Type link resolver used to linkify parameter type cells in operator content. private void WriteClassOperatorsPage( IMarkdownWriterFactory factory, string nsKey, string nsDisplayName, CppClass cls, - IReadOnlyList operators) + IReadOnlyList operators, + CppTypeLinkResolver cppResolver) { - using var writer = factory.CreateMarkdown($"{nsKey}/{cls.Name}", "operators"); + var opsCurrentFolder = $"{nsKey}/{cls.Name}"; + using var writer = factory.CreateMarkdown(opsCurrentFolder, "operators"); writer.WriteHeading(1, "operators"); // Emit the qualified class name comment and #include directive from the first operator @@ -928,18 +1174,22 @@ private void WriteClassOperatorsPage( if (firstWithLocation != null) { var includePath = GetIncludePath(firstWithLocation.Location!.File); - writer.WriteSignature("cpp", $"// {qualifiedClassName}\n#include <{includePath}>"); + writer.WriteSignature("cpp", $"// {qualifiedClassName}\n#include \"{includePath}\""); } writer.WriteParagraph($"Operator overloads for {cls.Name}."); + var externalTypes = new SortedSet(); + // Emit an H2 section for each operator so readers can locate a specific overload quickly foreach (var op in operators) { var paramTypes = string.Join(", ", op.Parameters.Select(p => SimplifyTypeName(p.TypeName))); writer.WriteHeading(2, $"{op.Name}({paramTypes})"); - WriteFunctionContent(writer, nsDisplayName, cls.Name, op); + WriteFunctionContent(writer, nsDisplayName, cls.Name, op, cppResolver, opsCurrentFolder, externalTypes, parametersHeadingLevel: 3); } + + WriteExternalTypesSection(writer, externalTypes); } /// @@ -964,12 +1214,15 @@ private void WriteClassOperatorsPage( /// elements must have names starting with "operator". Must contain at least /// one element. /// + /// Type link resolver used to linkify parameter type cells in operator content. private void WriteNamespaceOperatorsPage( IMarkdownWriterFactory factory, string nsKey, string nsDisplayName, - IReadOnlyList operators) + IReadOnlyList operators, + CppTypeLinkResolver cppResolver) { + var opsCurrentFolder = nsKey; using var writer = factory.CreateMarkdown(nsKey, "operators"); writer.WriteHeading(1, "operators"); @@ -982,19 +1235,23 @@ private void WriteNamespaceOperatorsPage( ? firstWithLocation.Name : $"{nsDisplayName}::{firstWithLocation.Name}"; var includePath = GetIncludePath(firstWithLocation.Location!.File); - writer.WriteSignature("cpp", $"// {qualifiedName}\n#include <{includePath}>"); + writer.WriteSignature("cpp", $"// {qualifiedName}\n#include \"{includePath}\""); } var displayNs = string.IsNullOrEmpty(nsDisplayName) ? GlobalNamespaceKey : nsDisplayName; writer.WriteParagraph($"Operator overloads in the {displayNs} namespace."); + var externalTypes = new SortedSet(); + // Emit an H2 section for each operator so readers can locate a specific overload quickly foreach (var op in operators) { var paramTypes = string.Join(", ", op.Parameters.Select(p => SimplifyTypeName(p.TypeName))); writer.WriteHeading(2, $"{op.Name}({paramTypes})"); - WriteFreeFunctionContent(writer, nsDisplayName, op); + WriteFreeFunctionContent(writer, nsDisplayName, op, cppResolver, opsCurrentFolder, externalTypes, parametersHeadingLevel: 3); } + + WriteExternalTypesSection(writer, externalTypes); } /// @@ -1013,15 +1270,20 @@ private void WriteNamespaceOperatorsPage( /// name shown as the first line of the signature comment. /// /// The free function declaration to document. + /// Type link resolver used to linkify parameter type cells. private void WriteFreeFunctionPage( IMarkdownWriterFactory factory, string nsKey, string nsDisplayName, - CppFunction fn) + CppFunction fn, + CppTypeLinkResolver cppResolver) { + var fnCurrentFolder = nsKey; using var writer = factory.CreateMarkdown(nsKey, SanitizeFileName(fn.Name)); writer.WriteHeading(1, fn.Name); - WriteFreeFunctionContent(writer, nsDisplayName, fn); + var externalTypes = new SortedSet(); + WriteFreeFunctionContent(writer, nsDisplayName, fn, cppResolver, fnCurrentFolder, externalTypes); + WriteExternalTypesSection(writer, externalTypes); } /// @@ -1044,10 +1306,22 @@ private void WriteFreeFunctionPage( /// for global-namespace functions. /// /// The free function declaration to document. + /// Type link resolver used to linkify parameter type cells. + /// Folder path of the containing Markdown file for relative link computation. + /// External type accumulator for the containing page. + /// + /// Heading level for the "Parameters" and "Returns" sub-sections. Use 2 for standalone + /// function pages (which open at H1) and 3 for combined/operator pages (where the + /// function entry is already at H2). + /// private void WriteFreeFunctionContent( IMarkdownWriter writer, string nsDisplayName, - CppFunction fn) + CppFunction fn, + CppTypeLinkResolver cppResolver, + string currentFolder, + ISet externalTypes, + int parametersHeadingLevel = 2) { // Emit the fully-qualified name as a comment followed by the optional #include // directive and C++ signature so that an AI reader has all context needed to @@ -1059,7 +1333,7 @@ private void WriteFreeFunctionContent( if (fn.Location != null) { var includePath = GetIncludePath(fn.Location.File); - writer.WriteSignature("cpp", $"// {qualifiedName}\n#include <{includePath}>\n{signature}"); + writer.WriteSignature("cpp", $"// {qualifiedName}\n#include \"{includePath}\"\n{signature}"); } else { @@ -1077,13 +1351,22 @@ private void WriteFreeFunctionContent( writer.WriteParagraph(details); } + // Emit @note as a blockquote when present + var note = GetNote(fn.Doc); + if (!string.IsNullOrEmpty(note)) + { + writer.WriteParagraph($"> **Note:** {note}"); + } + // Emit parameter table when the function has at least one parameter if (fn.Parameters.Count > 0) { - writer.WriteHeading(4, "Parameters"); + writer.WriteHeading(parametersHeadingLevel, "Parameters"); var paramHeaders = new[] { "Parameter", "Type", DescriptionColumnHeader }; + + // Linkify parameter type cells; resolver tracks external types encountered var paramRows = fn.Parameters.Select(p => - new[] { p.Name, SimplifyTypeName(p.TypeName), GetParamDescription(fn.Doc, p.Name) ?? string.Empty }); + new[] { p.Name, cppResolver.Linkify(SimplifyTypeName(p.TypeName), currentFolder, externalTypes), GetParamDescription(fn.Doc, p.Name) ?? string.Empty }); writer.WriteTable(paramHeaders, paramRows); } @@ -1091,9 +1374,11 @@ private void WriteFreeFunctionContent( var returnTypeName = SimplifyTypeName(fn.ReturnTypeName); if (!string.Equals(returnTypeName, "void", StringComparison.Ordinal)) { - writer.WriteHeading(4, "Returns"); + // Always linkify/track the return type even when a doc description is present + var linkedReturnType = cppResolver.Linkify(returnTypeName, currentFolder, externalTypes); + writer.WriteHeading(parametersHeadingLevel, "Returns"); var returnDescription = GetReturnDescription(fn.Doc); - writer.WriteParagraph(!string.IsNullOrEmpty(returnDescription) ? returnDescription : returnTypeName); + writer.WriteParagraph(!string.IsNullOrEmpty(returnDescription) ? returnDescription : linkedReturnType); } } @@ -1112,27 +1397,34 @@ private void WriteFreeFunctionContent( /// or a . /// /// The unique file name (without extension) allocated for this member. + /// Type link resolver used to linkify parameter type cells. private static void WriteMemberPage( IMarkdownWriterFactory factory, string nsKey, string nsDisplayName, CppClass cls, object member, - string fileName) + string fileName, + CppTypeLinkResolver cppResolver) { - using var memberWriter = factory.CreateMarkdown($"{nsKey}/{cls.Name}", fileName); + var memberCurrentFolder = $"{nsKey}/{cls.Name}"; + using var memberWriter = factory.CreateMarkdown(memberCurrentFolder, fileName); + + var externalTypes = new SortedSet(); // Dispatch to the appropriate page writer based on the concrete member type switch (member) { case CppFunction method: - WriteFunctionPage(memberWriter, nsDisplayName, cls.Name, method); + WriteFunctionPage(memberWriter, nsDisplayName, cls.Name, method, cppResolver, memberCurrentFolder, externalTypes); break; case CppField field: WriteFieldPage(memberWriter, nsDisplayName, cls.Name, field); break; } + + WriteExternalTypesSection(memberWriter, externalTypes); } /// @@ -1146,14 +1438,20 @@ private static void WriteMemberPage( /// /// The declaring class name, used in the page heading. /// The method declaration to document. + /// Type link resolver used to linkify parameter type cells. + /// Folder path of the containing Markdown file for relative link computation. + /// External type accumulator for the containing page. private static void WriteFunctionPage( IMarkdownWriter writer, string nsDisplayName, string className, - CppFunction method) + CppFunction method, + CppTypeLinkResolver cppResolver, + string currentFolder, + ISet externalTypes) { - writer.WriteHeading(3, $"{className}.{method.Name}"); - WriteFunctionContent(writer, nsDisplayName, className, method); + writer.WriteHeading(1, $"{className}.{method.Name}"); + WriteFunctionContent(writer, nsDisplayName, className, method, cppResolver, currentFolder, externalTypes); } /// @@ -1163,7 +1461,7 @@ private static void WriteFunctionPage( /// /// /// Separated from so that combined pages can write an - /// H4 heading of their own before delegating to this method for the content body. + /// H2 heading of their own before delegating to this method for the content body. /// /// The Markdown writer to emit content into. /// @@ -1172,11 +1470,23 @@ private static void WriteFunctionPage( /// /// The declaring class name. /// The method declaration to document. + /// Type link resolver used to linkify parameter type cells. + /// Folder path of the containing Markdown file for relative link computation. + /// External type accumulator for the containing page. + /// + /// Heading level for the "Parameters" and "Returns" sub-sections. Use 2 for standalone + /// member pages (which open at H1) and 3 for combined/operator pages (where the + /// member entry is already at H2). + /// private static void WriteFunctionContent( IMarkdownWriter writer, string nsDisplayName, string className, - CppFunction method) + CppFunction method, + CppTypeLinkResolver cppResolver, + string currentFolder, + ISet externalTypes, + int parametersHeadingLevel = 2) { // Emit the fully-qualified name as a comment followed by the C++ signature so that // an AI reader has the namespace and class context needed to call the member correctly @@ -1197,13 +1507,22 @@ private static void WriteFunctionContent( writer.WriteParagraph(details); } + // Emit @note as a blockquote when present + var note = GetNote(method.Doc); + if (!string.IsNullOrEmpty(note)) + { + writer.WriteParagraph($"> **Note:** {note}"); + } + // Emit parameter table when the method has at least one parameter if (method.Parameters.Count > 0) { - writer.WriteHeading(4, "Parameters"); + writer.WriteHeading(parametersHeadingLevel, "Parameters"); var paramHeaders = new[] { "Parameter", "Type", DescriptionColumnHeader }; + + // Linkify parameter type cells; resolver tracks external types encountered var paramRows = method.Parameters.Select(p => - new[] { p.Name, SimplifyTypeName(p.TypeName), GetParamDescription(method.Doc, p.Name) ?? string.Empty }); + new[] { p.Name, cppResolver.Linkify(SimplifyTypeName(p.TypeName), currentFolder, externalTypes), GetParamDescription(method.Doc, p.Name) ?? string.Empty }); writer.WriteTable(paramHeaders, paramRows); } @@ -1214,9 +1533,11 @@ private static void WriteFunctionContent( var returnTypeName = SimplifyTypeName(method.ReturnTypeName); if (!string.Equals(returnTypeName, "void", StringComparison.Ordinal)) { - writer.WriteHeading(4, "Returns"); + // Always linkify/track the return type even when a doc description is present + var linkedReturnType = cppResolver.Linkify(returnTypeName, currentFolder, externalTypes); + writer.WriteHeading(parametersHeadingLevel, "Returns"); var returnDescription = GetReturnDescription(method.Doc); - writer.WriteParagraph(!string.IsNullOrEmpty(returnDescription) ? returnDescription : returnTypeName); + writer.WriteParagraph(!string.IsNullOrEmpty(returnDescription) ? returnDescription : linkedReturnType); } } } @@ -1238,7 +1559,7 @@ private static void WriteFieldPage( string className, CppField field) { - writer.WriteHeading(3, $"{className}.{field.Name}"); + writer.WriteHeading(1, $"{className}.{field.Name}"); WriteFieldContent(writer, nsDisplayName, className, field); } @@ -1250,7 +1571,7 @@ private static void WriteFieldPage( /// /// /// Separated from so that combined pages can write an - /// H4 heading of their own before delegating to this method for the content body. + /// H2 heading of their own before delegating to this method for the content body. /// /// The Markdown writer to emit content into. /// @@ -1283,6 +1604,13 @@ private static void WriteFieldContent( { writer.WriteParagraph(details); } + + // Emit @note as a blockquote when present + var note = GetNote(field.Doc); + if (!string.IsNullOrEmpty(note)) + { + writer.WriteParagraph($"> **Note:** {note}"); + } } /// @@ -1313,7 +1641,7 @@ private void WriteEnumPage( if (cppEnum.Location != null) { var includePath = GetIncludePath(cppEnum.Location.File); - writer.WriteSignature("cpp", $"// {qualifiedName}\n#include <{includePath}>"); + writer.WriteSignature("cpp", $"// {qualifiedName}\n#include \"{includePath}\""); } else { @@ -1331,10 +1659,17 @@ private void WriteEnumPage( writer.WriteParagraph(enumDetails); } + // Emit @note as a blockquote when present + var enumNote = GetNote(cppEnum.Doc); + if (!string.IsNullOrEmpty(enumNote)) + { + writer.WriteParagraph($"> **Note:** {enumNote}"); + } + // Emit a values table so readers can see all valid values and their meanings if (cppEnum.Values.Count > 0) { - writer.WriteHeading(3, "Values"); + writer.WriteHeading(2, "Values"); var headers = new[] { "Value", DescriptionColumnHeader }; var rows = cppEnum.Values.Select(item => { @@ -1345,6 +1680,69 @@ private void WriteEnumPage( } } + /// + /// Writes a documentation page for a using type alias declaration. + /// + /// Factory for creating the output writer. + /// The file-path-compatible namespace key used as the output folder. + /// + /// The C++ qualified namespace name used in the fully-qualified signature comment. + /// + /// The type alias declaration to document. + /// Type link resolver used to linkify the underlying type and track external types. + private void WriteTypeAliasPage( + IMarkdownWriterFactory factory, + string nsKey, + string nsDisplayName, + CppTypeAlias alias, + CppTypeLinkResolver cppResolver) + { + using var writer = factory.CreateMarkdown(nsKey, alias.Name); + writer.WriteHeading(1, alias.Name); + + // Accumulate external type references found in the underlying type on this page + var externalTypes = new SortedSet(); + + // Emit the fully-qualified name comment, optional #include, and the using declaration + // so readers have everything needed to use the alias without browsing the header tree + var qualifiedName = string.IsNullOrEmpty(nsDisplayName) + ? alias.Name + : $"{nsDisplayName}::{alias.Name}"; + var simplifiedUnderlying = SimplifyTypeName(alias.UnderlyingTypeName); + var declaration = $"using {alias.Name} = {simplifiedUnderlying}"; + if (alias.Location != null) + { + var includePath = GetIncludePath(alias.Location.File); + writer.WriteSignature("cpp", $"// {qualifiedName}\n#include \"{includePath}\"\n{declaration}"); + } + else + { + writer.WriteSignature("cpp", $"// {qualifiedName}\n{declaration}"); + } + + // Emit summary from doc comment or placeholder + var summary = GetSummary(alias.Doc); + writer.WriteParagraph(!string.IsNullOrEmpty(summary) ? summary : NoDescriptionPlaceholder); + + // Emit extended details when the doc comment contains a @details or @remarks block + var details = GetDetails(alias.Doc); + if (!string.IsNullOrEmpty(details)) + { + writer.WriteParagraph(details); + } + + // Emit @note as a blockquote when present + var aliasNote = GetNote(alias.Doc); + if (!string.IsNullOrEmpty(aliasNote)) + { + writer.WriteParagraph($"> **Note:** {aliasNote}"); + } + + // Resolve the underlying type to track any non-std external type references + cppResolver.Linkify(simplifiedUnderlying, nsKey, externalTypes); + WriteExternalTypesSection(writer, externalTypes); + } + // ========================================================================= // Visibility filtering // ========================================================================= @@ -1425,6 +1823,14 @@ private bool IsVisibleMember(CppAccessibility accessibility) /// The details string, or when absent. private static string? GetDetails(CppDocComment? doc) => doc?.Details; + /// + /// Extracts the @note text from a , or returns + /// when no note is present. + /// + /// The doc comment to inspect. May be null. + /// The note string, or when absent. + private static string? GetNote(CppDocComment? doc) => doc?.Note; + /// /// Looks up the description for a named parameter in a . /// @@ -1501,7 +1907,9 @@ private static string BuildMethodSignature(CppFunction fn) // Build the parameter list; append "..." for variadic functions var paramParts = fn.Parameters - .Select(p => $"{SimplifyTypeName(p.TypeName)} {p.Name}") + .Select(p => p.DefaultValue != null + ? $"{SimplifyTypeName(p.TypeName)} {p.Name} = {p.DefaultValue}" + : $"{SimplifyTypeName(p.TypeName)} {p.Name}") .ToList(); if (fn.IsVariadic) { @@ -1511,6 +1919,11 @@ private static string BuildMethodSignature(CppFunction fn) sb.Append(string.Join(", ", paramParts)); sb.Append(')'); + if (fn.IsDeleted) + { + sb.Append(" = delete"); + } + return sb.ToString(); } @@ -1520,14 +1933,15 @@ private static string BuildMethodSignature(CppFunction fn) /// final and base class names when inheritance is present. /// /// - /// Called only when is true so that the declaration - /// fragment makes the final constraint immediately visible to readers without - /// them needing to open the header file. + /// Used when is true or when the class has direct base + /// types so that the declaration fragment makes the constraint and inheritance chain + /// immediately visible to readers without them needing to open the header file. /// /// The C++ class to produce a declaration line for. /// - /// A C++ declaration string such as class FinalClass final or - /// class FinalClass final : public Shape. + /// A C++ declaration string such as class FinalClass final, + /// class FinalClass final : public Shape, or + /// class Circle : public Shape. /// private static string BuildClassDeclaration(CppClass cls) { @@ -1643,7 +2057,7 @@ private static string SimplifyTypeName(string typeName) /// /// This handles the case where a method Name() and a field name would /// map to the same file name on case-insensitive file systems. - /// All colliding members are documented together under H4 sub-headings that show + /// All colliding members are documented together under H2 sub-headings that show /// both the exact display name and the member kind (e.g., Name (Method)). /// /// Factory for creating the output writer. @@ -1654,7 +2068,7 @@ private static string SimplifyTypeName(string typeName) /// /// The declaring class. /// - /// The shared lowercase file name key. Used as both the page file name and the H3 + /// The shared lowercase file name key. Used as both the page file name and the H1 /// page heading so the combined page has a stable, predictable address. /// /// @@ -1662,19 +2076,25 @@ private static string SimplifyTypeName(string typeName) /// systems. Elements must be or . /// Must contain at least two elements. /// + /// Type link resolver used to linkify parameter type cells. private static void WriteCombinedMemberPage( IMarkdownWriterFactory factory, string nsKey, string nsDisplayName, CppClass cls, string lowerKey, - IReadOnlyList members) + IReadOnlyList members, + CppTypeLinkResolver cppResolver) { - using var writer = factory.CreateMarkdown($"{nsKey}/{cls.Name}", lowerKey); + var combinedCurrentFolder = $"{nsKey}/{cls.Name}"; + using var writer = factory.CreateMarkdown(combinedCurrentFolder, lowerKey); // The shared lowercase key serves as the page heading so every member in the group // can be found at the same predictable path regardless of filesystem case-sensitivity - writer.WriteHeading(3, lowerKey); + writer.WriteHeading(1, lowerKey); + + // Accumulate external types across all members on this shared page + var externalTypes = new SortedSet(); foreach (var member in members) { @@ -1687,16 +2107,18 @@ private static void WriteCombinedMemberPage( var fnParamTypes = string.Join( ", ", fn.Parameters.Select(p => SimplifyTypeName(p.TypeName))); - writer.WriteHeading(4, $"{fn.Name}({fnParamTypes}) ({fnKind})"); - WriteFunctionContent(writer, nsDisplayName, cls.Name, fn); + writer.WriteHeading(2, $"{fn.Name}({fnParamTypes}) ({fnKind})"); + WriteFunctionContent(writer, nsDisplayName, cls.Name, fn, cppResolver, combinedCurrentFolder, externalTypes, parametersHeadingLevel: 3); break; case CppField field: - writer.WriteHeading(4, $"{field.Name} (Field)"); + writer.WriteHeading(2, $"{field.Name} (Field)"); WriteFieldContent(writer, nsDisplayName, cls.Name, field); break; } } + + WriteExternalTypesSection(writer, externalTypes); } /// @@ -1722,6 +2144,32 @@ private static void WriteCombinedMemberPage( _ => className, }; + /// + /// Writes the "External Types" section at the end of a generated Markdown page, + /// listing all non-standard C++ types referenced in table cells on that page. + /// + /// + /// The section is emitted only when is non-empty. + /// Rows are sorted alphabetically by type string because + /// preserves the order defined by . + /// + /// The Markdown writer for the current page. + /// + /// The set of external types accumulated during table row generation. May be empty. + /// + private static void WriteExternalTypesSection(IMarkdownWriter writer, SortedSet externalTypes) + { + if (externalTypes.Count == 0) + { + return; + } + + writer.WriteHeading(2, "External Types"); + writer.WriteTable( + ["Type", "Namespace"], + externalTypes.Select(t => new[] { t.TypeString, t.Namespace })); + } + // ========================================================================= // Inner data model // ========================================================================= @@ -1761,6 +2209,9 @@ public NamespaceDeclarations(string displayName, CppDocComment? doc = null) /// Gets the list of owned enums declared in this namespace. public List Enums { get; } = []; + /// Gets the list of owned using type aliases declared in this namespace. + public List TypeAliases { get; } = []; + /// Gets the list of owned free functions declared in this namespace. public List FreeFunctions { get; } = []; } diff --git a/src/ApiMark.Cpp/CppGeneratorOptions.cs b/src/ApiMark.Cpp/CppGeneratorOptions.cs index d574b6f..5f71460 100644 --- a/src/ApiMark.Cpp/CppGeneratorOptions.cs +++ b/src/ApiMark.Cpp/CppGeneratorOptions.cs @@ -22,28 +22,41 @@ public sealed class CppGeneratorOptions public string LibraryName { get; set; } = string.Empty; /// - /// Gets or sets the public include root directories that define the documented API boundary. + /// Gets or sets the compiler include directories passed to Clang as -I paths + /// and used as the base for the default glob when + /// that list is empty. /// /// - /// Each root serves two purposes: (1) it is passed to Clang as an -I path so headers - /// can find each other, and (2) a declaration is considered "owned" only when its source file - /// falls under one of these roots. Must contain at least one entry. + /// Each root serves two purposes: (1) it is passed to Clang as an -I path so + /// headers can find each other during AST parsing, and (2) it is the base directory + /// against which globs are evaluated to select which + /// headers appear in the generated documentation. Must contain at least one entry. /// public IReadOnlyList PublicIncludeRoots { get; set; } = []; /// - /// Gets or sets glob patterns (relative to each entry) - /// selecting which header files contribute to the documented API. - /// When empty, all files under the roots are included. + /// Gets or sets the ordered list of glob and exclusion pattern strings that define which + /// headers appear in the generated documentation. /// - public IReadOnlyList IncludePatterns { get; set; } = []; - - /// - /// Gets or sets glob patterns (relative to each entry) - /// for header files to exclude from the documented API. - /// Evaluated after ; a file matching both is excluded. - /// - public IReadOnlyList ExcludePatterns { get; set; } = []; + /// + /// + /// Gitignore-style semantics apply: patterns are evaluated in order; the last + /// matching pattern wins. Entries without a ! prefix are include patterns; + /// entries with a ! prefix are exclusion patterns (the ! is stripped + /// before glob matching). + /// + /// + /// When this list is empty, all headers under with + /// recognized C++ header extensions (.h, .hpp, .hxx, .h++) + /// are included — equivalent to specifying + /// ["**/*.h", "**/*.hpp", "**/*.hxx", "**/*.h++"]. + /// + /// + /// Example — document all headers except a detail/ subtree with re-include: + /// ["**/*", "!**/detail/**", "**/detail/public.h"]. + /// + /// + public IList ApiHeaderPatterns { get; set; } = new List(); /// /// Gets or sets toolchain and SDK include directories passed to Clang as system include @@ -52,13 +65,6 @@ public sealed class CppGeneratorOptions /// public IReadOnlyList SystemIncludePaths { get; set; } = []; - /// - /// Gets or sets additional include directories for third-party headers that are included - /// by public headers but are not part of the documented API. - /// Declarations from these paths are never documented. - /// - public IReadOnlyList AdditionalIncludePaths { get; set; } = []; - /// /// Gets or sets preprocessor symbol definitions passed to Clang as -D flags, /// in the form "NAME" or "NAME=value". diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs new file mode 100644 index 0000000..29ffd01 --- /dev/null +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -0,0 +1,262 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +namespace ApiMark.Cpp; + +/// +/// Resolves C++ type strings to Markdown link text suitable for table cells in +/// the generated API documentation. +/// +/// +/// Linkification is applied only in table cells — never inside fenced code blocks, +/// because Markdown links do not render inside fences. Three outcomes are possible +/// for each type string: +/// +/// +/// Intra-library type +/// +/// When the stripped base name matches a key in , +/// a relative Markdown link to the type's page is emitted. +/// +/// +/// +/// Primitive or std:: type +/// +/// Emitted as plain text and NOT tracked as an external type. +/// +/// +/// +/// Non-std external type +/// +/// Emitted as plain text and added to the caller-supplied +/// set for later emission in the +/// "External Types" section. +/// +/// +/// +/// Stateless with respect to type-link resolution itself; mutable state is +/// carried only via the caller-supplied parameter. +/// Thread-safe for concurrent resolution when each caller supplies its own +/// instance. +/// +internal sealed class CppTypeLinkResolver +{ + /// + /// C++ primitive type names that are always rendered as plain text and never + /// tracked as external dependencies. + /// + private static readonly HashSet Primitives = new(StringComparer.Ordinal) + { + "void", "bool", "int", "short", "long", "unsigned", "float", "double", + "char", "wchar_t", "size_t", "ptrdiff_t", "auto", "nullptr_t", + "int8_t", "int16_t", "int32_t", "int64_t", + "uint8_t", "uint16_t", "uint32_t", "uint64_t", + }; + + /// + /// Maps fully-qualified C++ type names (using :: separators) to their + /// documentation page keys (using / separators). + /// For example, "fixtures::SampleClass" maps to "fixtures/SampleClass". + /// + private readonly IReadOnlyDictionary _knownTypes; + + /// + /// Initializes a new instance of with the + /// map of known intra-library types. + /// + /// + /// Dictionary mapping fully-qualified C++ type names (:: separators) to + /// page keys (/ separators). Must not be null. + /// + public CppTypeLinkResolver(IReadOnlyDictionary knownTypes) + { + _knownTypes = knownTypes; + } + + /// + /// Resolves to a Markdown link when it names + /// an intra-library type, plain text when it is a primitive or std:: type, + /// or plain text with external tracking when it names a non-std external type. + /// + /// + /// The simplified C++ type string to resolve (e.g. "const fixtures::SampleClass &"). + /// Must not be null. + /// + /// + /// The folder path of the Markdown file that will contain the link, relative to the + /// documentation output root (e.g. "fixtures/SampleClass"). + /// Used to compute relative path hrefs. Pass an empty string for root-level files. + /// + /// + /// Mutable set that accumulates non-std external type references found during + /// resolution. The caller creates this set per output file and emits the "External + /// Types" section after all table rows have been written. + /// + /// + /// A Markdown string: either a link of the form [Name](relative/path.md), + /// or the original unchanged. + /// + public string Linkify( + string cppTypeString, + string currentFolder, + ISet externalTypes) + { + if (string.IsNullOrWhiteSpace(cppTypeString)) + { + return cppTypeString; + } + + // Strip qualifiers to isolate the base type name for lookup + var stripped = StripQualifiers(cppTypeString); + if (string.IsNullOrEmpty(stripped)) + { + return cppTypeString; + } + + // Primitives are always rendered as plain text + if (Primitives.Contains(stripped)) + { + return cppTypeString; + } + + // std:: types are always rendered as plain text and never tracked as external; + // only the stripped base name is checked so that intra-library types whose + // signatures contain std:: template arguments (e.g. Foo) are + // still linkified correctly + if (stripped.StartsWith("std::", StringComparison.Ordinal)) + { + return cppTypeString; + } + + // Check for an exact qualified-name match first, then fall back to short-name matching + var pageKey = FindPageKey(stripped); + + if (pageKey != null) + { + // Intra-library type: replace only the base type token in the original string, + // preserving qualifiers (const, *, &, etc.) around the link + var from = currentFolder.Length > 0 ? currentFolder : "."; + var relativePath = Path.GetRelativePath(from, pageKey + ".md").Replace('\\', '/'); + var shortName = stripped.Contains("::", StringComparison.Ordinal) + ? stripped[(stripped.LastIndexOf("::", StringComparison.Ordinal) + 2)..] + : stripped; + var linked = $"[{shortName}]({relativePath})"; + return cppTypeString.Replace(shortName, linked, StringComparison.Ordinal); + } + + // External type with a namespace: track for the External Types section + var ns = ExtractNamespace(stripped); + if (!string.IsNullOrEmpty(ns) && ns != "std" && !ns.StartsWith("std::", StringComparison.Ordinal)) + { + var lastSep = stripped.LastIndexOf("::", StringComparison.Ordinal); + var shortName = lastSep >= 0 ? stripped[(lastSep + 2)..] : stripped; + externalTypes.Add(new CppExternalTypeInfo(shortName, ns)); + } + + return cppTypeString; + } + + /// + /// Looks up the page key for in . + /// First tries an exact qualified match; falls back to matching by short (unqualified) name. + /// + /// The base type name after qualifier removal. + /// The page key when found; otherwise. + private string? FindPageKey(string stripped) + { + // Exact match on the fully-qualified name + if (_knownTypes.TryGetValue(stripped, out var key)) + { + return key; + } + + // Short-name fallback: only used when exactly one known type has this unqualified name. + // If multiple types share the same short name (e.g. Outer::size_type and Other::size_type), + // the reference is ambiguous and we fall through to external-type tracking instead. + string? matchKey = null; + var ambiguous = false; + foreach (var (knownQualified, knownKey) in _knownTypes) + { + var lastSep = knownQualified.LastIndexOf("::", StringComparison.Ordinal); + var shortName = lastSep >= 0 ? knownQualified[(lastSep + 2)..] : knownQualified; + if (shortName == stripped) + { + if (matchKey != null) + { + ambiguous = true; + break; + } + + matchKey = knownKey; + } + } + + return ambiguous ? null : matchKey; + } + + /// + /// Removes C++ cv-qualifiers (const, volatile), reference qualifiers + /// (&, &&), pointer qualifiers (*), and generic + /// template arguments from a type string to isolate the base type name. + /// + /// + /// Template arguments are removed because the base name is what identifies the type + /// in the documentation tree; e.g. Stack<T> resolves via Stack. + /// + /// The raw C++ type string to strip. + /// The base type name without qualifiers or template arguments. + internal static string StripQualifiers(string typeString) + { + var s = typeString.Trim(); + + // Remove leading cv-qualifiers + if (s.StartsWith("const ", StringComparison.Ordinal)) + { + s = s[6..].TrimStart(); + } + + if (s.StartsWith("volatile ", StringComparison.Ordinal)) + { + s = s[9..].TrimStart(); + } + + // Remove trailing reference, pointer, and trailing-const qualifiers iteratively + s = s.TrimEnd(); + while (true) + { + if (s.EndsWith(" &&", StringComparison.Ordinal)) { s = s[..^3].TrimEnd(); continue; } + if (s.EndsWith("&&", StringComparison.Ordinal)) { s = s[..^2].TrimEnd(); continue; } + if (s.EndsWith(" &", StringComparison.Ordinal)) { s = s[..^2].TrimEnd(); continue; } + if (s.EndsWith("&", StringComparison.Ordinal)) { s = s[..^1].TrimEnd(); continue; } + if (s.EndsWith(" *", StringComparison.Ordinal)) { s = s[..^2].TrimEnd(); continue; } + if (s.EndsWith("*", StringComparison.Ordinal)) { s = s[..^1].TrimEnd(); continue; } + if (s.EndsWith(" const", StringComparison.OrdinalIgnoreCase)) { s = s[..^6].TrimEnd(); continue; } + + break; + } + + // Remove template arguments — base name is everything before the first '<' + var ltIdx = s.IndexOf('<'); + if (ltIdx >= 0) + { + s = s[..ltIdx].TrimEnd(); + } + + return s; + } + + /// + /// Extracts the namespace portion from a qualified C++ name by taking everything + /// before the last :: separator. + /// + /// The qualified C++ name (e.g. "acme::io::Logger"). + /// + /// The namespace (e.g. "acme::io"), or an empty string when the name is + /// unqualified. + /// + private static string ExtractNamespace(string qualifiedName) + { + var lastSep = qualifiedName.LastIndexOf("::", StringComparison.Ordinal); + return lastSep >= 0 ? qualifiedName[..lastSep] : string.Empty; + } +} diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index ae166c3..7fc4ffe 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -155,6 +155,7 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) apiWriter.WriteTable(conventionHeaders, conventionRows); // Write one namespace page per namespace, ordered so parents precede children + var resolver = new TypeLinkResolver(rootNamespaces); foreach (var namespaceName in allNamespaces) { WriteNamespacePage( @@ -164,7 +165,8 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) byNamespace, rootNamespaces, xmlDocs, - namespaceDescriptions); + namespaceDescriptions, + resolver); } } @@ -182,6 +184,7 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) /// Namespace-level summaries sourced from the NamespaceDoc convention, /// keyed by fully-qualified namespace name. /// + /// Type link resolver forwarded to each type page within this namespace. private void WriteNamespacePage( IMarkdownWriterFactory factory, string namespaceName, @@ -189,7 +192,8 @@ private void WriteNamespacePage( Dictionary> byNamespace, List rootNamespaces, XmlDocReader xmlDocs, - IReadOnlyDictionary namespaceDescriptions) + IReadOnlyDictionary namespaceDescriptions, + TypeLinkResolver resolver) { var folderPath = GetNamespaceFolderPath(namespaceName, rootNamespaces); SplitPath(folderPath, out var subFolder, out var shortName); @@ -236,14 +240,15 @@ private void WriteNamespacePage( { var typeMemberId = BuildTypeId(t); var summary = xmlDocs.GetSummary(typeMemberId) ?? NoDescriptionPlaceholder; - var link = $"{shortName}/{t.Name}.md"; - return new[] { $"[{t.Name}]({link})", summary }; + var typeDisplayName = StripArity(t.Name); + var link = $"{shortName}/{FlattenArity(t.Name)}.md"; + return new[] { $"[{typeDisplayName}]({link})", summary }; }); nsWriter.WriteTable(typeHeaders, typeRows); foreach (var type in nsTypes) { - WriteTypePage(factory, namespaceName, folderPath, type, xmlDocs); + WriteTypePage(factory, namespaceName, folderPath, type, xmlDocs, resolver); } } @@ -259,18 +264,20 @@ private void WriteNamespacePage( /// /// The type whose page is being written. /// XML documentation index for summary and remarks lookups. + /// Type link resolver used to emit Markdown links in table type cells. private void WriteTypePage( IMarkdownWriterFactory factory, string namespaceName, string namespaceFolderPath, TypeDefinition type, - XmlDocReader xmlDocs) + XmlDocReader xmlDocs, + TypeLinkResolver resolver) { - using var typeWriter = factory.CreateMarkdown(namespaceFolderPath, type.Name); - typeWriter.WriteHeading(2, type.Name); + using var typeWriter = factory.CreateMarkdown(namespaceFolderPath, FlattenArity(type.Name)); + typeWriter.WriteHeading(1, StripArity(type.Name)); - // Emit the C# declaration signature so readers can see the type kind and modifiers - var typeSignature = BuildTypeSignature(type); + // Emit the C# declaration signature so readers can see the type kind, modifiers, and direct inheritance + var typeSignature = BuildTypeSignature(type, namespaceName); typeWriter.WriteSignature("csharp", typeSignature); var typeMemberId = BuildTypeId(type); @@ -285,6 +292,15 @@ private void WriteTypePage( typeWriter.WriteParagraph(typeRemarks); } + // Delegates carry all their useful information in the declaration signature — + // the compiler-injected Invoke/BeginInvoke/EndInvoke methods and the synthetic + // (object, IntPtr) constructor are implementation noise that should never appear + // in public API docs, analogous to how enum backing fields are suppressed. + if (IsDelegate(type)) + { + return; + } + // Collect visible members: constructors first, then alphabetically var members = GetVisibleMembers(type) .OrderBy(m => m.Name == ConstructorMethodName ? 0 : 1) @@ -323,6 +339,9 @@ private void WriteTypePage( var fieldRows = new List(); var eventRows = new List(); + // Accumulate external type references found in all type-column cells on this page + var externalTypes = new SortedSet(); + // Emit one page per unique lowercase key and one table row per visible member. // Members whose sanitized file names collide case-insensitively share a combined page // named after the lowercase key; all their table rows link to that shared page. @@ -336,14 +355,17 @@ private void WriteTypePage( // No collision: write an individual page using the member's own display name var memberId = BuildMemberId(member); var memberSummary = xmlDocs.GetSummary(memberId) ?? NoDescriptionPlaceholder; - var memberTypeName = GetMemberTypeName(member, namespaceName); + var memberTypeRef = GetMemberTypeRef(member); + var memberTypeName = memberTypeRef != null + ? resolver.Linkify(memberTypeRef, namespaceFolderPath, namespaceName, externalTypes, IsMemberTypeNullableAnnotated(member)) + : string.Empty; var memberDisplayName = GetMemberDisplayName(member); var sanitizedName = GetSanitizedMemberFileName(member, type); - var memberPageLink = $"{type.Name}/{sanitizedName}.md"; + var memberPageLink = $"{FlattenArity(type.Name)}/{sanitizedName}.md"; if (member is MethodDefinition singleMethod) { - WriteMemberPage(factory, namespaceName, namespaceFolderPath, type, singleMethod, xmlDocs, memberId); + WriteMemberPage(factory, namespaceName, namespaceFolderPath, type, singleMethod, xmlDocs, memberId, resolver); var isConstructor = singleMethod.Name == ConstructorMethodName; if (isConstructor) { @@ -356,7 +378,7 @@ private void WriteTypePage( } else { - WriteMemberPage(factory, namespaceName, namespaceFolderPath, type, member, xmlDocs, memberId); + WriteMemberPage(factory, namespaceName, namespaceFolderPath, type, member, xmlDocs, memberId, resolver); switch (member) { case PropertyDefinition: @@ -393,13 +415,16 @@ private void WriteTypePage( var representative = orderedOverloads[0]; var representativeMemberId = BuildMemberId(representative); var representativeSummary = xmlDocs.GetSummary(representativeMemberId) ?? NoDescriptionPlaceholder; - var representativeTypeName = GetMemberTypeName(representative, namespaceName); + var representativeTypeRef = GetMemberTypeRef(representative); + var representativeTypeName = representativeTypeRef != null + ? resolver.Linkify(representativeTypeRef, namespaceFolderPath, namespaceName, externalTypes, IsMemberTypeNullableAnnotated(representative)) + : string.Empty; var overloadDisplayName = GetMethodGroupDisplayName(representative, orderedOverloads.Count); var overloadFileName = GetSanitizedMemberFileName(representative, type); - var memberLink = $"{type.Name}/{overloadFileName}.md"; + var memberLink = $"{FlattenArity(type.Name)}/{overloadFileName}.md"; var isConstructorGroup = representative.Name == ConstructorMethodName; - WriteMethodOverloadPage(factory, namespaceName, namespaceFolderPath, type, orderedOverloads, xmlDocs); + WriteMethodOverloadPage(factory, namespaceName, namespaceFolderPath, type, orderedOverloads, xmlDocs, resolver); if (isConstructorGroup) { @@ -414,18 +439,21 @@ private void WriteTypePage( { // Case-insensitive collision: mixed kinds or different-case method names. // Write a single combined page named after the lowercase key on first encounter. - var memberLink = $"{type.Name}/{lowerKey}.md"; + var memberLink = $"{FlattenArity(type.Name)}/{lowerKey}.md"; if (writtenLowerKeys.Add(lowerKey)) { - WriteCombinedMemberPage(factory, namespaceName, namespaceFolderPath, type, lowerKey, group, xmlDocs); + WriteCombinedMemberPage(factory, namespaceName, namespaceFolderPath, type, lowerKey, group, xmlDocs, resolver); } // Every member in the collision group still contributes its own row to the // appropriate sub-table, all linking to the shared combined page var memberId = BuildMemberId(member); var memberSummary = xmlDocs.GetSummary(memberId) ?? NoDescriptionPlaceholder; - var memberTypeName = GetMemberTypeName(member, namespaceName); + var memberTypeRef = GetMemberTypeRef(member); + var memberTypeName = memberTypeRef != null + ? resolver.Linkify(memberTypeRef, namespaceFolderPath, namespaceName, externalTypes, IsMemberTypeNullableAnnotated(member)) + : string.Empty; var memberDisplayName = GetMemberDisplayName(member); switch (member) @@ -459,7 +487,7 @@ private void WriteTypePage( // Each section is only emitted when at least one member of that kind is present. if (constructorRows.Count > 0) { - typeWriter.WriteHeading(3, "Constructors"); + typeWriter.WriteHeading(2, "Constructors"); // Constructors omit the Type/Returns column — they have no meaningful return type typeWriter.WriteTable(new[] { "Member", DescriptionColumnHeader }, constructorRows); @@ -467,13 +495,13 @@ private void WriteTypePage( if (propertyRows.Count > 0) { - typeWriter.WriteHeading(3, "Properties"); + typeWriter.WriteHeading(2, "Properties"); typeWriter.WriteTable(new[] { "Member", "Type", DescriptionColumnHeader }, propertyRows); } if (methodRows.Count > 0) { - typeWriter.WriteHeading(3, "Methods"); + typeWriter.WriteHeading(2, "Methods"); // Use "Returns" instead of "Type" for the method type column — more accurate for return values typeWriter.WriteTable(new[] { "Member", "Returns", DescriptionColumnHeader }, methodRows); @@ -481,15 +509,18 @@ private void WriteTypePage( if (fieldRows.Count > 0) { - typeWriter.WriteHeading(3, "Fields"); + typeWriter.WriteHeading(2, "Fields"); typeWriter.WriteTable(new[] { "Member", "Type", DescriptionColumnHeader }, fieldRows); } if (eventRows.Count > 0) { - typeWriter.WriteHeading(3, "Events"); + typeWriter.WriteHeading(2, "Events"); typeWriter.WriteTable(new[] { "Member", "Type", DescriptionColumnHeader }, eventRows); } + + // Emit the External Types section when any non-standard external types were referenced + WriteExternalTypesSection(typeWriter, externalTypes); } /// @@ -506,6 +537,7 @@ private void WriteTypePage( /// The member whose page is being written. /// XML documentation index. /// Pre-computed XML doc member identifier for . + /// Type link resolver used to linkify parameter type cells. private static void WriteMemberPage( IMarkdownWriterFactory factory, string namespaceName, @@ -513,17 +545,22 @@ private static void WriteMemberPage( TypeDefinition type, IMemberDefinition member, XmlDocReader xmlDocs, - string memberId) + string memberId, + TypeLinkResolver resolver) { var sanitizedName = GetSanitizedMemberFileName(member, type); - using var memberWriter = factory.CreateMarkdown($"{namespaceFolderPath}/{type.Name}", sanitizedName); + var memberCurrentFolder = $"{namespaceFolderPath}/{FlattenArity(type.Name)}"; + using var memberWriter = factory.CreateMarkdown(memberCurrentFolder, sanitizedName); var displayName = GetMemberDisplayName(member); - memberWriter.WriteHeading(3, displayName); + memberWriter.WriteHeading(1, displayName); if (member is MethodDefinition method) { - WriteMethodDocumentation(memberWriter, namespaceName, method, xmlDocs, memberId); + // Method pages use the resolver for parameter type cells + var externalTypes = new SortedSet(); + WriteMethodDocumentation(memberWriter, namespaceName, method, xmlDocs, memberId, resolver, memberCurrentFolder, externalTypes); + WriteExternalTypesSection(memberWriter, externalTypes); return; } @@ -568,18 +605,24 @@ private static void WriteMethodOverloadPage( string namespaceFolderPath, TypeDefinition type, IReadOnlyList overloads, - XmlDocReader xmlDocs) + XmlDocReader xmlDocs, + TypeLinkResolver resolver) { var sanitizedName = BuildMethodFileName(overloads[0], type); - using var memberWriter = factory.CreateMarkdown($"{namespaceFolderPath}/{type.Name}", sanitizedName); + var overloadCurrentFolder = $"{namespaceFolderPath}/{FlattenArity(type.Name)}"; + using var memberWriter = factory.CreateMarkdown(overloadCurrentFolder, sanitizedName); - memberWriter.WriteHeading(3, GetMethodGroupName(overloads[0])); + memberWriter.WriteHeading(1, GetMethodGroupName(overloads[0])); + // Accumulate external types across all overloads on this shared page + var externalTypes = new SortedSet(); foreach (var overload in overloads) { - memberWriter.WriteHeading(4, BuildMethodDisplayName(overload)); - WriteMethodDocumentation(memberWriter, namespaceName, overload, xmlDocs, BuildMemberId(overload)); + memberWriter.WriteHeading(2, BuildMethodDisplayName(overload)); + WriteMethodDocumentation(memberWriter, namespaceName, overload, xmlDocs, BuildMemberId(overload), resolver, overloadCurrentFolder, externalTypes); } + + WriteExternalTypesSection(memberWriter, externalTypes); } /// @@ -590,7 +633,7 @@ private static void WriteMethodOverloadPage( /// /// This handles the case where a field name and a property Name would /// map to the same file name on case-insensitive file systems. - /// All colliding members are documented together under H4 sub-headings that show + /// All colliding members are documented together under H2 sub-headings that show /// both the exact display name and the member kind (e.g., name (Field)). /// /// Factory for creating the output writer. @@ -603,7 +646,7 @@ private static void WriteMethodOverloadPage( /// /// The type that declares all members in . /// - /// The shared lowercase file name key. Used as both the page file name and the H3 + /// The shared lowercase file name key. Used as both the page file name and the H1 /// page heading so the combined page has a stable, predictable address. /// /// @@ -611,6 +654,7 @@ private static void WriteMethodOverloadPage( /// file systems. Must contain at least two elements. /// /// XML documentation index for summary and detail lookups. + /// Type link resolver used to linkify parameter type cells. private static void WriteCombinedMemberPage( IMarkdownWriterFactory factory, string namespaceName, @@ -618,26 +662,31 @@ private static void WriteCombinedMemberPage( TypeDefinition type, string lowerKey, IReadOnlyList members, - XmlDocReader xmlDocs) + XmlDocReader xmlDocs, + TypeLinkResolver resolver) { - using var writer = factory.CreateMarkdown($"{namespaceFolderPath}/{type.Name}", lowerKey); + var combinedCurrentFolder = $"{namespaceFolderPath}/{FlattenArity(type.Name)}"; + using var writer = factory.CreateMarkdown(combinedCurrentFolder, lowerKey); // The shared lowercase key serves as the page heading so every member in the group // can be found at the same predictable path regardless of filesystem case-sensitivity - writer.WriteHeading(3, lowerKey); + writer.WriteHeading(1, lowerKey); + + // Accumulate external types across all members on this shared page + var externalTypes = new SortedSet(); foreach (var member in members) { var displayName = GetMemberDisplayName(member); var kindLabel = GetMemberKindLabel(member); - writer.WriteHeading(4, $"{displayName} ({kindLabel})"); + writer.WriteHeading(2, $"{displayName} ({kindLabel})"); var memberId = BuildMemberId(member); if (member is MethodDefinition method) { // Reuse the method documentation writer so formatting is consistent // with single-method pages and overload pages - WriteMethodDocumentation(writer, namespaceName, method, xmlDocs, memberId); + WriteMethodDocumentation(writer, namespaceName, method, xmlDocs, memberId, resolver, combinedCurrentFolder, externalTypes); } else { @@ -675,6 +724,8 @@ private static void WriteCombinedMemberPage( } } } + + WriteExternalTypesSection(writer, externalTypes); } /// @@ -733,7 +784,10 @@ private static void WriteMethodDocumentation( string namespaceName, MethodDefinition method, XmlDocReader xmlDocs, - string memberId) + string memberId, + TypeLinkResolver resolver, + string currentFolder, + ISet externalTypes) { var signature = BuildMethodSignature(method, namespaceName); memberWriter.WriteSignature("csharp", signature); @@ -746,10 +800,12 @@ private static void WriteMethodDocumentation( { var paramDocs = xmlDocs.GetParams(memberId); var paramHeaders = new[] { "Parameter", "Type", DescriptionColumnHeader }; + + // Linkify parameter types — resolver tracks external types and emits links for intra-assembly types var paramRows = method.Parameters.Select(p => { var desc = paramDocs.FirstOrDefault(pd => pd.Name == p.Name).Description ?? string.Empty; - var typeName = TypeNameSimplifier.Simplify(p.ParameterType, namespaceName); + var typeName = resolver.Linkify(p.ParameterType, currentFolder, namespaceName, externalTypes); return new[] { p.Name, typeName, desc }; }); memberWriter.WriteTable(paramHeaders, paramRows); @@ -793,7 +849,7 @@ private static void WriteMethodDocumentation( /// and subsequent segments use forward slashes /// (e.g. ApiMark.DotNet.Fixtures/Inner). /// - private static string GetNamespaceFolderPath(string namespaceName, List rootNamespaces) + internal static string GetNamespaceFolderPath(string namespaceName, IReadOnlyList rootNamespaces) { var root = rootNamespaces.FirstOrDefault(r => string.Equals(r, namespaceName, StringComparison.Ordinal) || @@ -1017,11 +1073,87 @@ private static string BuildMethodId(MethodDefinition method) return $"M:{typeName}.{methodName}({paramList})"; } + /// Returns when is a delegate type. + /// + /// The C# compiler compiles every delegate declaration into a sealed class that + /// inherits System.MulticastDelegate. Testing the base type name is therefore the + /// reliable way to detect delegates from compiled metadata. + /// + /// The type to test. + /// + /// when derives directly from + /// System.MulticastDelegate. + /// + private static bool IsDelegate(TypeDefinition type) => + type.BaseType?.FullName == "System.MulticastDelegate"; + + /// + /// Builds the source-level public delegate signature for a delegate type, + /// derived from the compiler-injected Invoke method's return type and parameters. + /// + /// + /// The C# compiler injects three methods into every delegate type: Invoke, + /// BeginInvoke, and EndInvoke. Invoke carries the exact signature + /// the developer wrote in source, so it is used here to reconstruct the canonical + /// public delegate ReturnType Name(params) form. + /// + /// The delegate type (must satisfy ). + /// Used to simplify type names in the signature. + /// A string of the form public delegate void ServiceEvent(DateTime timestamp, ...). + private static string BuildDelegateSignature(TypeDefinition type, string contextNamespace) + { + var invoke = type.Methods.FirstOrDefault(m => m.Name == "Invoke"); + if (invoke == null) + { + // Malformed delegate — fall back to a void declaration without parameters + return $"public delegate void {StripArity(type.Name)}()"; + } + + var returnType = TypeNameSimplifier.Simplify(invoke.ReturnType, contextNamespace); + var name = StripArity(type.Name); + if (type.HasGenericParameters) + { + var args = string.Join(", ", type.GenericParameters.Select(p => p.Name)); + name = $"{name}<{args}>"; + } + + var parameters = string.Join(", ", invoke.Parameters.Select(p => + $"{TypeNameSimplifier.Simplify(p.ParameterType, contextNamespace)} {p.Name}")); + + return $"public delegate {returnType} {name}({parameters})"; + } + /// Builds a human-readable C# declaration signature for a type definition. + /// + /// Base types System.Object, System.ValueType, System.Enum, and + /// System.MulticastDelegate are suppressed because they are implicit compiler-assigned + /// roots that carry no information for readers; showing them would add noise to every class, + /// struct, enum, and delegate signature. The is forwarded + /// to so that types declared in the same namespace are + /// rendered without their namespace prefix, keeping signatures concise. + /// + /// Delegate types are detected and dispatched to + /// before the class/struct/enum/interface switch so the output always shows the + /// source-level delegate keyword rather than sealed class. + /// + /// /// The type definition to represent. - /// A string of the form public class Name or public interface Name<T>. - private static string BuildTypeSignature(TypeDefinition type) + /// Used to simplify base type and interface names in the signature. + /// + /// A string of the form public class Name, public interface Name<T>, + /// public delegate void Name(params), or + /// public class Name : BaseClass, IInterface when direct inheritance is present. + /// + private static string BuildTypeSignature(TypeDefinition type, string contextNamespace) { + // Delegates are compiled to sealed classes deriving from MulticastDelegate. + // Detect them before the generic class/struct/enum/interface switch so that + // the output shows the original source-level delegate syntax. + if (IsDelegate(type)) + { + return BuildDelegateSignature(type, contextNamespace); + } + var keyword = type switch { { IsInterface: true } => "interface", @@ -1050,6 +1182,31 @@ private static string BuildTypeSignature(TypeDefinition type) name = $"{name}<{args}>"; } + // Collect direct base class (excluding well-known root types) and directly declared interfaces + // so the signature shows inheritance at a glance without opening the source file + var bases = new List(); + if (type.BaseType != null) + { + var baseName = type.BaseType.FullName; + + // Skip the standard implicit base types that carry no information for readers + if (baseName is not ("System.Object" or "System.ValueType" or "System.Enum" or "System.MulticastDelegate")) + { + bases.Add(TypeNameSimplifier.Simplify(type.BaseType, contextNamespace)); + } + } + + // Include all directly declared interfaces — transitive inheritance is omitted + foreach (var interfaceRef in type.Interfaces) + { + bases.Add(TypeNameSimplifier.Simplify(interfaceRef.InterfaceType, contextNamespace)); + } + + if (bases.Count > 0) + { + name = $"{name} : {string.Join(", ", bases)}"; + } + return $"public {classModifier}{keyword} {name}"; } @@ -1075,7 +1232,10 @@ private static string BuildMemberSignature(IMemberDefinition member, string cont /// The method signature string. private static string BuildMethodSignature(MethodDefinition method, string contextNamespace) { - var returnType = TypeNameSimplifier.Simplify(method.ReturnType, contextNamespace); + var returnType = TypeNameSimplifier.Simplify( + method.ReturnType, + contextNamespace, + HasNullableAnnotation(method.MethodReturnType.CustomAttributes)); var isExtensionMethod = IsExtensionMethod(method); // Use the declaring type name for constructors rather than ConstructorMethodName @@ -1086,7 +1246,11 @@ private static string BuildMethodSignature(MethodDefinition method, string conte var parameters = string.Join(", ", method.Parameters.Select((p, index) => { var receiverPrefix = isExtensionMethod && index == 0 ? "this " : string.Empty; - return $"{receiverPrefix}{TypeNameSimplifier.Simplify(p.ParameterType, contextNamespace)} {p.Name}"; + var paramType = TypeNameSimplifier.Simplify( + p.ParameterType, + contextNamespace, + HasNullableAnnotation(p.CustomAttributes)); + return $"{receiverPrefix}{paramType} {p.Name}"; })); var accessibility = GetAccessibilityKeyword(method); @@ -1102,7 +1266,10 @@ private static string BuildMethodSignature(MethodDefinition method, string conte /// The property signature string. private static string BuildPropertySignature(PropertyDefinition prop, string contextNamespace) { - var typeName = TypeNameSimplifier.Simplify(prop.PropertyType, contextNamespace); + var typeName = TypeNameSimplifier.Simplify( + prop.PropertyType, + contextNamespace, + HasNullableAnnotation(prop.CustomAttributes)); var accessibility = GetAccessibilityKeyword(prop.GetMethod ?? prop.SetMethod!); var accessors = BuildPropertyAccessors(prop); return $"{accessibility} {typeName} {prop.Name} {{ {accessors} }}"; @@ -1137,7 +1304,10 @@ private static string BuildPropertyAccessors(PropertyDefinition prop) /// The field signature string. private static string BuildFieldSignature(FieldDefinition field, string contextNamespace) { - var typeName = TypeNameSimplifier.Simplify(field.FieldType, contextNamespace); + var typeName = TypeNameSimplifier.Simplify( + field.FieldType, + contextNamespace, + HasNullableAnnotation(field.CustomAttributes)); // Determine modifier(s) from compile-time flags; literals imply IsStatic so they // must be tested before the IsStatic arm to avoid showing "static const" @@ -1160,7 +1330,10 @@ private static string BuildFieldSignature(FieldDefinition field, string contextN /// The event signature string. private static string BuildEventSignature(EventDefinition evt, string contextNamespace) { - var typeName = TypeNameSimplifier.Simplify(evt.EventType, contextNamespace); + var typeName = TypeNameSimplifier.Simplify( + evt.EventType, + contextNamespace, + HasNullableAnnotation(evt.CustomAttributes)); return $"public event {typeName} {evt.Name}"; } @@ -1191,22 +1364,111 @@ private static string GetMemberDisplayName(IMemberDefinition member) }; } - /// Returns the simplified C# type name for a member as it should appear in a type column. - /// The member whose type name to compute. - /// Used to simplify the type name. - /// The simplified type name string. - private static string GetMemberTypeName(IMemberDefinition member, string contextNamespace) + /// + /// Returns the representing the type of a member, used to + /// linkify the type column in documentation tables. + /// + /// + /// Constructors have no meaningful return type and return so that + /// callers can omit the type column from constructor rows. + /// + /// The member whose type reference to retrieve. + /// + /// The type reference for properties, fields, events, and non-constructor methods; + /// for constructors and unrecognized member kinds. + /// + private static TypeReference? GetMemberTypeRef(IMemberDefinition member) => member switch { - return member switch + MethodDefinition m when m.Name == ConstructorMethodName => null, + MethodDefinition m => m.ReturnType, + PropertyDefinition p => p.PropertyType, + FieldDefinition f => f.FieldType, + EventDefinition e => e.EventType, + _ => null, + }; + + /// + /// Returns when the member's primary type (return type, property + /// type, field type, or event type) carries a NullableAttribute(2) annotation on + /// its outermost position, indicating a C# 8+ nullable reference type. + /// + /// + /// Mono.Cecil stores nullable-reference annotations on the containing member (method return + /// parameter, property, field, or event) rather than on the + /// itself. This method reads the correct for each + /// member kind and delegates to . + /// + private static bool IsMemberTypeNullableAnnotated(IMemberDefinition member) => member switch + { + MethodDefinition m when m.Name != ConstructorMethodName + => HasNullableAnnotation(m.MethodReturnType.CustomAttributes), + PropertyDefinition p => HasNullableAnnotation(p.CustomAttributes), + FieldDefinition f => HasNullableAnnotation(f.CustomAttributes), + EventDefinition e => HasNullableAnnotation(e.CustomAttributes), + _ => false, + }; + + /// + /// Returns when contains a + /// System.Runtime.CompilerServices.NullableAttribute whose first (or only) + /// byte argument is 2, which is the compiler-emitted encoding for a nullable + /// reference type annotation (?). + /// + /// + /// The attribute has two constructor forms: NullableAttribute(byte) for simple + /// types and NullableAttribute(byte[]) for composite types (arrays, generics). + /// In the composite form, byte index 0 represents the outermost type's nullability. + /// + private static bool HasNullableAnnotation(IEnumerable attrs) + { + var nullableAttr = attrs.FirstOrDefault( + a => a.AttributeType.FullName == "System.Runtime.CompilerServices.NullableAttribute"); + if (nullableAttr == null || nullableAttr.ConstructorArguments.Count == 0) { - // Constructors have no meaningful return type — the "type" column is omitted for them - MethodDefinition m when m.Name == ConstructorMethodName => string.Empty, - MethodDefinition m => TypeNameSimplifier.Simplify(m.ReturnType, contextNamespace), - PropertyDefinition p => TypeNameSimplifier.Simplify(p.PropertyType, contextNamespace), - FieldDefinition f => TypeNameSimplifier.Simplify(f.FieldType, contextNamespace), - EventDefinition e => TypeNameSimplifier.Simplify(e.EventType, contextNamespace), - _ => string.Empty, - }; + return false; + } + + var arg = nullableAttr.ConstructorArguments[0]; + + // Single-byte form: NullableAttribute(byte) + if (arg.Value is byte b) + { + return b == 2; + } + + // Byte-array form: NullableAttribute(byte[]) — index 0 is the outermost type + if (arg.Value is CustomAttributeArgument[] arr && arr.Length > 0 && arr[0].Value is byte first) + { + return first == 2; + } + + return false; + } + + /// + /// Writes the "External Types" section at the end of a generated Markdown page, + /// listing all non-standard types referenced in table cells on that page. + /// + /// + /// The section is emitted only when is non-empty. + /// Rows are sorted alphabetically by simplified name because + /// preserves the order defined by . + /// + /// The Markdown writer for the current page. + /// + /// The set of external types accumulated during table row generation. May be empty. + /// + private static void WriteExternalTypesSection(IMarkdownWriter writer, SortedSet externalTypes) + { + if (externalTypes.Count == 0) + { + return; + } + + writer.WriteHeading(2, "External Types"); + writer.WriteTable( + ["Type", "Namespace"], + externalTypes.Select(t => new[] { t.SimplifiedName, t.Namespace })); } /// @@ -1297,4 +1559,12 @@ private static string StripArity(string name) var tick = name.IndexOf('`'); return tick >= 0 ? name.Substring(0, tick) : name; } + + /// + /// Converts the IL backtick arity suffix to a plain numeric suffix for file-system-safe names. + /// For example, Foo`2 becomes Foo2; Foo is unchanged. + /// + /// The raw IL type name that may contain a backtick arity suffix. + /// The name with the backtick removed but the arity count preserved. + private static string FlattenArity(string name) => TypeNameSimplifier.FlattenArity(name); } diff --git a/src/ApiMark.DotNet/ExternalTypeInfo.cs b/src/ApiMark.DotNet/ExternalTypeInfo.cs new file mode 100644 index 0000000..9a8e3e1 --- /dev/null +++ b/src/ApiMark.DotNet/ExternalTypeInfo.cs @@ -0,0 +1,53 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +namespace ApiMark.DotNet; + +/// +/// Represents a non-standard external type reference found in the documentation. +/// +/// +/// Instances are collected per generated Markdown file and emitted in the +/// "External Types" section so that readers know which additional packages or +/// headers they must include to use the documented API. System-namespace types +/// and C# primitives are excluded; only types whose namespace does not start +/// with "System" and are not CLR value-type primitives are recorded here. +/// +/// The simplified type name as it appears in the documentation table. +/// The .NET namespace that declares the type. +internal sealed record ExternalTypeInfo(string SimplifiedName, string Namespace) + : IComparable +{ + /// + /// Compares this instance to by simplified name, + /// enabling deterministic alphabetical sorting of the External Types table. + /// + /// The other instance to compare against, or . + /// + /// A negative value when this instance sorts before , + /// zero when equal, or a positive value when it sorts after. + /// + public int CompareTo(ExternalTypeInfo? other) + { + var nameComparison = StringComparer.Ordinal.Compare(SimplifiedName, other?.SimplifiedName); + return nameComparison != 0 + ? nameComparison + : StringComparer.Ordinal.Compare(Namespace, other?.Namespace); + } + + /// Returns when sorts before . + public static bool operator <(ExternalTypeInfo left, ExternalTypeInfo right) => + left.CompareTo(right) < 0; + + /// Returns when sorts before or equal to . + public static bool operator <=(ExternalTypeInfo left, ExternalTypeInfo right) => + left.CompareTo(right) <= 0; + + /// Returns when sorts after . + public static bool operator >(ExternalTypeInfo left, ExternalTypeInfo right) => + left.CompareTo(right) > 0; + + /// Returns when sorts after or equal to . + public static bool operator >=(ExternalTypeInfo left, ExternalTypeInfo right) => + left.CompareTo(right) >= 0; +} diff --git a/src/ApiMark.DotNet/TypeLinkResolver.cs b/src/ApiMark.DotNet/TypeLinkResolver.cs new file mode 100644 index 0000000..195de95 --- /dev/null +++ b/src/ApiMark.DotNet/TypeLinkResolver.cs @@ -0,0 +1,301 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +using Mono.Cecil; + +namespace ApiMark.DotNet; + +/// +/// Resolves Mono.Cecil instances to Markdown link +/// text suitable for table cells in the generated API documentation. +/// +/// +/// Linkification is applied only in table cells — never inside fenced code blocks, +/// because Markdown links do not render inside fences. Three outcomes are possible +/// for each type reference: +/// +/// +/// Intra-assembly type +/// +/// Detected when is a +/// , meaning the type is declared inside the +/// assembly being documented. A relative Markdown link to the type's page is +/// emitted. +/// +/// +/// +/// C# primitive or System.* type +/// +/// Emitted as plain text using +/// and NOT tracked as an external type. +/// +/// +/// +/// Non-System external type +/// +/// Emitted as plain text and added to the caller-supplied +/// set for later emission in the +/// "External Types" section. +/// +/// +/// +/// Stateless with respect to the type-link resolution itself; mutable state is +/// carried only via the caller-supplied parameter. +/// Thread-safe for concurrent resolution of different type references when each +/// caller supplies its own instance. +/// +internal sealed class TypeLinkResolver +{ + /// + /// Full CLR names of C# primitive types that are always rendered as their keyword + /// alias and never tracked as external dependencies. + /// + private static readonly HashSet PrimitiveFullNames = new(StringComparer.Ordinal) + { + "System.Boolean", "System.Byte", "System.SByte", "System.Int16", "System.UInt16", + "System.Int32", "System.UInt32", "System.Int64", "System.UInt64", "System.Single", + "System.Double", "System.Decimal", "System.Char", "System.String", "System.Object", + "System.Void", "System.IntPtr", "System.UIntPtr", + }; + + /// Root namespaces of the assembly, used to derive relative documentation paths. + private readonly IReadOnlyList _rootNamespaces; + + /// + /// Initializes a new instance of with the assembly root namespaces. + /// + /// + /// The root namespaces identified in the assembly being documented. These are forwarded to + /// to derive the documentation path for + /// each referenced type. + /// + public TypeLinkResolver(IReadOnlyList rootNamespaces) + { + _rootNamespaces = rootNamespaces; + } + + /// + /// Resolves to a Markdown link when it is an intra-assembly + /// type, plain text when it is a primitive or System type, or plain text with external + /// tracking when it is a non-System type from another assembly. + /// + /// The Mono.Cecil type reference to resolve. Must not be null. + /// + /// The folder path of the Markdown file that will contain the link, relative to the + /// documentation output root (e.g. ApiMark.DotNet.Fixtures/SampleClass). + /// Used to compute relative path hrefs. Pass an empty string for root-level files. + /// + /// + /// The namespace of the type that owns this reference. Forwarded to + /// so that same-namespace names are + /// displayed without their namespace prefix. + /// + /// + /// Mutable set that accumulates non-System external type references found during + /// resolution. The caller creates this set per output file and emits the "External + /// Types" section after all table rows have been written. + /// + /// + /// When , the member carrying this type reference has a + /// nullable-reference annotation. See + /// for details on when to pass + /// . + /// + /// + /// A Markdown string: either a link of the form [Name](relative/path.md), + /// a plain simplified name, or a generic composite such as + /// [Container](path.md)\<Arg\>. + /// + public string Linkify( + TypeReference typeRef, + string currentFolder, + string contextNamespace, + ISet externalTypes, + bool isNullableAnnotated = false) + { + if (typeRef == null) + { + return string.Empty; + } + + // Generic type parameters (e.g. T, TKey) are not real types — render as plain text + if (typeRef is GenericParameter genericParam) + { + return genericParam.Name; + } + + // Handle Nullable → T? by recursing on the inner type with the nullable flag set + if (typeRef is GenericInstanceType gitNullable && + typeRef.FullName.StartsWith("System.Nullable`1<", StringComparison.Ordinal)) + { + var inner = gitNullable.GenericArguments[0]; + return Linkify(inner, currentFolder, contextNamespace, externalTypes, true); + } + + // Handle array types by resolving the element type and appending "[]" (plus "?" when nullable) + if (typeRef is ArrayType arrayType) + { + var elementText = Linkify(arrayType.ElementType, currentFolder, contextNamespace, externalTypes); + return isNullableAnnotated ? elementText + "[]?" : elementText + "[]"; + } + + // Handle generic instance types: linkify the container when intra-assembly, else track external + if (typeRef is GenericInstanceType genericType) + { + return LinkifyGenericType(genericType, currentFolder, contextNamespace, externalTypes, isNullableAnnotated); + } + + // Primitives and Nullable<> open type render as plain C# aliases, never as external types + if (PrimitiveFullNames.Contains(typeRef.FullName) || + typeRef.FullName == "System.Nullable`1") + { + var alias = TypeNameSimplifier.Simplify(typeRef, contextNamespace); + return isNullableAnnotated ? alias + "?" : alias; + } + + // Intra-assembly types get a relative Markdown link to their documentation page + if (IsIntraAssembly(typeRef)) + { + return LinkifyIntraAssemblyType(typeRef, currentFolder, isNullableAnnotated); + } + + // Non-System external types are tracked for the External Types section + TrackExternalType(typeRef, externalTypes); + var name = TypeNameSimplifier.StripArity(typeRef.Name); + return isNullableAnnotated ? name + "?" : name; + } + + /// + /// Resolves a generic instance type to a Markdown cell value, linking the container + /// when it is intra-assembly and tracking it as external otherwise. + /// + /// The generic instance type to resolve. + /// Folder path of the containing Markdown file. + /// Namespace context for name simplification. + /// Accumulator for external type references. + /// Whether the nullable-reference annotation is present. + /// A Markdown cell string for the generic type. + private string LinkifyGenericType( + GenericInstanceType genericType, + string currentFolder, + string contextNamespace, + ISet externalTypes, + bool isNullableAnnotated) + { + var containerName = TypeNameSimplifier.StripArity(genericType.Name); + var argTexts = genericType.GenericArguments + .Select(arg => Linkify(arg, currentFolder, contextNamespace, externalTypes)) + .ToList(); + var argsText = string.Join(", ", argTexts); + var suffix = isNullableAnnotated ? "?" : string.Empty; + + if (IsIntraAssembly(genericType)) + { + var pageKey = GetTypePageKey(genericType); + var relativePath = ComputeRelativePath(currentFolder, pageKey); + var containerLink = $"[{containerName}]({relativePath})"; + return $"{containerLink}\\<{argsText}\\>{suffix}"; + } + + TrackExternalType(genericType, externalTypes); + return $"{containerName}\\<{argsText}\\>{suffix}"; + } + + /// + /// Resolves a simple intra-assembly type reference to a Markdown link cell. + /// + /// The intra-assembly type reference. + /// Folder path of the containing Markdown file. + /// Whether the nullable-reference annotation is present. + /// A Markdown link of the form [Name](relative/path.md). + private string LinkifyIntraAssemblyType(TypeReference typeRef, string currentFolder, bool isNullableAnnotated) + { + var simpleName = TypeNameSimplifier.StripArity(typeRef.Name); + var pageKey = GetTypePageKey(typeRef); + var relativePath = ComputeRelativePath(currentFolder, pageKey); + return isNullableAnnotated + ? $"[{simpleName}]({relativePath})?" + : $"[{simpleName}]({relativePath})"; + } + + /// + /// Returns when is declared in the + /// assembly being documented, identified by its scope being a . + /// + /// The type reference to test. + /// + /// when the type is in the current module; + /// when it is from an external assembly. + /// + private static bool IsIntraAssembly(TypeReference typeRef) => + typeRef.Scope is ModuleDefinition; + + /// + /// Computes the documentation page key (relative path without extension) for a type. + /// + /// The type whose page key to compute. + /// A forward-slash-separated page key such as MyLib/MyType. + private string GetTypePageKey(TypeReference typeRef) + { + var folder = DotNetGenerator.GetNamespaceFolderPath(typeRef.Namespace, _rootNamespaces); + var name = TypeNameSimplifier.FlattenArity(typeRef.Name); + return folder.Length > 0 ? $"{folder}/{name}" : name; + } + + /// + /// Computes a relative path from a source folder to a target page file. + /// + /// + /// The folder containing the Markdown file that will contain the link. + /// An empty string is treated as the root (current directory .). + /// + /// + /// The page key of the link target (e.g. MyLib/MyType). The .md + /// extension is appended during path computation. + /// + /// + /// A forward-slash-separated relative path suitable for a Markdown link href + /// (e.g. ../MyType.md or MyType.md). + /// + private static string ComputeRelativePath(string currentFolder, string targetKey) + { + var from = currentFolder.Length > 0 ? currentFolder : "."; + return Path.GetRelativePath(from, targetKey + ".md").Replace('\\', '/'); + } + + /// + /// Records as an external type in + /// when its namespace does not start with System. + /// + /// + /// System-namespace types (e.g. System.IO.Stream) are excluded because they are + /// part of the .NET runtime and do not require readers to install separate packages. + /// Generic instance types record the open-generic form with escaped angle brackets so + /// that the External Types table shows a readable representation. + /// + /// The type reference to evaluate. + /// The set to add to when the type qualifies. + private static void TrackExternalType(TypeReference typeRef, ISet externalTypes) + { + if (typeRef.Namespace == "System" || + typeRef.Namespace.StartsWith("System.", StringComparison.Ordinal)) + { + return; + } + + // Build a display name including escaped generic arguments for readability in the table + string simpleName; + if (typeRef is GenericInstanceType git) + { + var baseName = TypeNameSimplifier.StripArity(git.Name); + var args = string.Join(", ", git.GenericArguments.Select(a => TypeNameSimplifier.StripArity(a.Name))); + simpleName = $"{baseName}\\<{args}\\>"; + } + else + { + simpleName = TypeNameSimplifier.StripArity(typeRef.Name); + } + + externalTypes.Add(new ExternalTypeInfo(simpleName, typeRef.Namespace)); + } +} diff --git a/src/ApiMark.DotNet/TypeNameSimplifier.cs b/src/ApiMark.DotNet/TypeNameSimplifier.cs index 34b41a4..298ae1f 100644 --- a/src/ApiMark.DotNet/TypeNameSimplifier.cs +++ b/src/ApiMark.DotNet/TypeNameSimplifier.cs @@ -124,12 +124,29 @@ private static string BuildGenericName(GenericInstanceType git, string contextNa /// Removes the generic arity suffix (e.g. `1) from a type name. /// The raw type name that may contain a backtick arity suffix. /// The name without the arity suffix. - private static string StripArity(string name) + internal static string StripArity(string name) { var tick = name.IndexOf('`'); return tick >= 0 ? name.Substring(0, tick) : name; } + /// + /// Converts the IL backtick arity suffix to a plain numeric suffix, producing a + /// file-system-safe name that still distinguishes generic types by parameter count. + /// + /// + /// For example, Foo`2 becomes Foo2 and Foo is unchanged. + /// This avoids the collision that would cause when both + /// Foo and Foo<T> exist in the same namespace. + /// + /// The raw IL type name that may contain a backtick arity suffix. + /// The name with the backtick removed but the arity count preserved. + internal static string FlattenArity(string name) + { + var tick = name.IndexOf('`'); + return tick >= 0 ? name.Substring(0, tick) + name.Substring(tick + 1) : name; + } + /// /// Returns the shortest unambiguous name for relative to /// , stripping that namespace's prefix if present. diff --git a/src/ApiMark.MSBuild/ApiMarkTask.cs b/src/ApiMark.MSBuild/ApiMarkTask.cs index 52a2ead..1f950de 100644 --- a/src/ApiMark.MSBuild/ApiMarkTask.cs +++ b/src/ApiMark.MSBuild/ApiMarkTask.cs @@ -108,7 +108,9 @@ public sealed class ApiMarkTask : Task /// /// /// Used for the cpp language only. Maps to $(ApiMarkIncludePaths). - /// Semicolons are converted to commas for the --includes tool argument. + /// Each semicolon-delimited entry is forwarded as an individual --includes flag; + /// all entries are passed to Clang as -I paths and serve as the base directories + /// for the default header glob when is not set. /// public string? ApiMarkIncludePaths { get; set; } @@ -149,6 +151,18 @@ public sealed class ApiMarkTask : Task /// public string? ApiMarkClangPath { get; set; } + /// Gets or sets the semicolon-separated, order-preserved list of glob and exclusion pattern strings for C++ header selection. + /// + /// Used for the cpp language only. Maps to $(ApiMarkApiHeaders). + /// Entries are forwarded as individual --api-headers flags in the order they appear. + /// Entries with a ! prefix are exclusion patterns; entries without are include + /// patterns. Gitignore semantics apply: the last matching pattern wins, enabling + /// include/exclude/re-include sequences. + /// Optional — when empty, all headers under $(ApiMarkIncludePaths) with recognized + /// C++ header extensions are documented. + /// + public string? ApiMarkApiHeaders { get; set; } + /// /// Gets or sets the full path to ApiMark.Tool.dll bundled inside the NuGet package. /// @@ -215,12 +229,44 @@ internal IReadOnlyList BuildArguments(string language) } else { - // Include paths use semicolons as the MSBuild list separator; convert to commas - // because the tool's --includes argument is parsed as a comma-separated list - var commaIncludes = ApiMarkIncludePaths?.Replace(';', ',') ?? string.Empty; args.Add("cpp"); - args.Add("--includes"); - args.Add(commaIncludes); + + // Emit one --includes flag per path entry — each semicolon-delimited entry becomes + // a separate repeatable --includes argument so paths with spaces are unambiguous + if (!string.IsNullOrEmpty(ApiMarkIncludePaths)) + { + foreach (var rawEntry in ApiMarkIncludePaths!.Split(';')) + { + // Manually trim each entry — StringSplitOptions.TrimEntries is not + // available in netstandard2.0 which this assembly targets + var entry = rawEntry.Trim(); + if (string.IsNullOrEmpty(entry)) + { + continue; + } + + args.Add("--includes"); + args.Add(entry); + } + } + + // Emit one --api-headers flag per pattern entry, order-preserved including ! exclusion patterns + if (!string.IsNullOrEmpty(ApiMarkApiHeaders)) + { + foreach (var rawEntry in ApiMarkApiHeaders!.Split(';')) + { + // Manually trim each entry — StringSplitOptions.TrimEntries is not + // available in netstandard2.0 which this assembly targets + var entry = rawEntry.Trim(); + if (string.IsNullOrEmpty(entry)) + { + continue; + } + + args.Add("--api-headers"); + args.Add(entry); + } + } // Library name (defaults to project name via .targets) if (!string.IsNullOrEmpty(ApiMarkLibraryName)) diff --git a/src/ApiMark.Tool/Cli/Context.cs b/src/ApiMark.Tool/Cli/Context.cs index 0d7f107..38f8bed 100644 --- a/src/ApiMark.Tool/Cli/Context.cs +++ b/src/ApiMark.Tool/Cli/Context.cs @@ -64,9 +64,19 @@ internal sealed class Context : IContext, IDisposable /// /// Gets the include directory paths for the C++ language subcommand. + /// Contains plain directory paths collected from repeated --includes invocations; + /// all entries are passed to Clang as -I paths. /// public string[] Includes { get; private init; } = []; + /// + /// Gets the ordered list of glob and exclusion pattern strings for the C++ language subcommand. + /// Collected from repeated --api-headers invocations; entries with a ! + /// prefix are exclusion patterns. Order is significant — gitignore semantics apply + /// (last matching pattern wins). + /// + public string[] ApiHeaders { get; private init; } = []; + /// /// Gets the output directory for generated Markdown files. /// @@ -153,7 +163,8 @@ public static Context Create(string[] args) Language = parser.Language, Assembly = parser.Assembly, XmlDoc = parser.XmlDoc, - Includes = parser.Includes, + Includes = [.. parser.Includes], + ApiHeaders = [.. parser.ApiHeaders], Output = parser.Output, Visibility = parser.Visibility, IncludeObsolete = parser.IncludeObsolete, @@ -308,9 +319,19 @@ private sealed class ArgumentParser public string? XmlDoc { get; private set; } /// - /// Gets the include directory paths for C++. + /// Gets the include directory paths for the C++ language subcommand. + /// Accumulated by repeated --includes invocations; each invocation appends + /// one plain directory path. /// - public string[] Includes { get; private set; } = []; + public List Includes { get; } = new List(); + + /// + /// Gets the ordered list of glob and exclusion pattern strings for the C++ language subcommand. + /// Accumulated by repeated --api-headers invocations; each invocation appends + /// one pattern (with or without a leading !). Order is preserved for + /// gitignore-style last-match-wins evaluation. + /// + public List ApiHeaders { get; } = new List(); /// /// Gets the output directory. @@ -432,9 +453,22 @@ private int ParseArgument(string arg, string[] args, int index) return index + 1; case "--includes": - Includes = GetRequiredStringArgument(arg, args, index, "a comma-separated path list argument") - .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - return index + 1; + { + // Append each --includes invocation as one plain directory path + // — repeated --includes flags accumulate the full list + var path = GetRequiredStringArgument(arg, args, index, "a directory path argument"); + Includes.Add(path); + return index + 1; + } + + case "--api-headers": + { + // Append each --api-headers invocation as one pattern string + // — may start with '!' for exclusion; order is preserved for gitignore evaluation + var pattern = GetRequiredStringArgument(arg, args, index, "a glob pattern argument"); + ApiHeaders.Add(pattern); + return index + 1; + } case "--output": Output = GetRequiredStringArgument(arg, args, index, "a directory path argument"); diff --git a/src/ApiMark.Tool/Program.cs b/src/ApiMark.Tool/Program.cs index a19fb56..1d8403a 100644 --- a/src/ApiMark.Tool/Program.cs +++ b/src/ApiMark.Tool/Program.cs @@ -246,6 +246,7 @@ private static IApiGenerator CreateGenerator(Context context) LibraryName = cppLibraryName, Description = context.LibraryDescription ?? string.Empty, PublicIncludeRoots = context.Includes, + ApiHeaderPatterns = context.ApiHeaders, Defines = context.Defines, CppStandard = context.CppStandard ?? "c++17", Visibility = (CppApiVisibility)(int)visibility, @@ -299,7 +300,8 @@ private static void PrintHelp(Context context) context.WriteLine(" --include-obsolete Include obsolete members in generated output"); context.WriteLine(""); context.WriteLine("cpp options:"); - context.WriteLine(" --includes Comma-separated list of public include directories (required)"); + context.WriteLine(" --includes Include directory for clang -I (repeatable, required)"); + context.WriteLine(" --api-headers Glob pattern for documented headers, supports ! exclusions (repeatable, ordered)"); context.WriteLine(" --output Output directory for Markdown files (required)"); context.WriteLine(" --library-name Library name used as the top-level heading (default: output directory name)"); context.WriteLine(" --library-description Optional description for the library api.md introduction"); diff --git a/test/ApiMark.Cpp.Fixtures/include/fixtures/DefaultParamFixtures.h b/test/ApiMark.Cpp.Fixtures/include/fixtures/DefaultParamFixtures.h new file mode 100644 index 0000000..fa437cc --- /dev/null +++ b/test/ApiMark.Cpp.Fixtures/include/fixtures/DefaultParamFixtures.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +namespace fixtures { + +/// @brief Computes the CRC-32 checksum of a buffer. +/// @note Result depends on the seed value; use seed=0 for a standard CRC-32. +/// @param data Pointer to the input data. +/// @param length Number of bytes to process. +/// @param seed Initial CRC value. +/// @return The computed CRC-32 checksum. +uint32_t crc32(const uint8_t* data, uint32_t length, uint32_t seed = 0U); + +/// @brief Counts occurrences with an optional maximum cap. +/// @param value The value to count. +/// @param max Maximum count before capping (default: no cap). +/// @return The capped count. +int count_capped(int value, int max = -1); + +/// @brief Configures the module with an optional initial-state flag. +/// @param enabled Whether to enable the module. +/// @param initial Whether to apply the initial state (default: false). +void configure(bool enabled, bool initial = false); + +/// @brief Scales a value by an optional factor. +/// @param value The input value. +/// @param factor Multiplier to apply (default: 1.5). +/// @return The scaled result. +float scale(float value, float factor = 1.5f); + +} // namespace fixtures diff --git a/test/ApiMark.Cpp.Fixtures/include/fixtures/DeletedMembersClass.h b/test/ApiMark.Cpp.Fixtures/include/fixtures/DeletedMembersClass.h new file mode 100644 index 0000000..0b62680 --- /dev/null +++ b/test/ApiMark.Cpp.Fixtures/include/fixtures/DeletedMembersClass.h @@ -0,0 +1,29 @@ +#pragma once + +namespace fixtures { + +/// @brief A class with explicitly deleted special member functions. +/// +/// Documents the pattern of a move-only type where copying is explicitly +/// forbidden. +class DeletedMembersClass { +public: + /// @brief Constructs a DeletedMembersClass with the given value. + /// @param value The initial value. + explicit DeletedMembersClass(int value); + + /// @brief Deleted copy constructor — this type is not copyable. + /// @param other The instance that would have been copied. + DeletedMembersClass(const DeletedMembersClass& other) = delete; + + /// @brief Deleted copy-assignment operator — this type is not copyable. + /// @param other The instance that would have been assigned from. + /// @return Reference to this instance. + DeletedMembersClass& operator=(const DeletedMembersClass& other) = delete; + + /// @brief Gets the stored value. + /// @return The value. + int GetValue() const; +}; + +} // namespace fixtures diff --git a/test/ApiMark.Cpp.Fixtures/include/fixtures/NestedClassFixtures.h b/test/ApiMark.Cpp.Fixtures/include/fixtures/NestedClassFixtures.h new file mode 100644 index 0000000..bfbd8bb --- /dev/null +++ b/test/ApiMark.Cpp.Fixtures/include/fixtures/NestedClassFixtures.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace fixtures { + +/// @brief Outer class that contains a nested class and a class-scoped type alias. +class Outer { +public: + /// @brief A size type scoped to Outer. + using size_type = uint32_t; + + /// @brief Inner nested class. + class Inner { + public: + /// @brief Gets the value. + int value() const; + }; +}; + +/// @brief Another class that also declares a size_type alias (different from Outer::size_type). +class Other { +public: + /// @brief A size type scoped to Other (should NOT collide with Outer::size_type in knownTypes). + using size_type = uint16_t; +}; + +} // namespace fixtures diff --git a/test/ApiMark.Cpp.Fixtures/include/fixtures/TypeAliasFixtures.h b/test/ApiMark.Cpp.Fixtures/include/fixtures/TypeAliasFixtures.h new file mode 100644 index 0000000..a0ad752 --- /dev/null +++ b/test/ApiMark.Cpp.Fixtures/include/fixtures/TypeAliasFixtures.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +namespace fixtures { + +/// @brief Alias for a 32-bit signed integer identifier. +using item_id_t = int32_t; + +/// @brief Alias for a UTF-8 string label. +using label_t = std::string; + +} // namespace fixtures diff --git a/test/ApiMark.Cpp.Fixtures/include/fixtures/TypeLinkClass.h b/test/ApiMark.Cpp.Fixtures/include/fixtures/TypeLinkClass.h new file mode 100644 index 0000000..d26a8c4 --- /dev/null +++ b/test/ApiMark.Cpp.Fixtures/include/fixtures/TypeLinkClass.h @@ -0,0 +1,18 @@ +#pragma once +#include "InheritanceClass.h" + +namespace fixtures { + +/// @brief A fixture class for testing intra-doc type links. +class TypeLinkClass { +public: + /// @brief Creates a Shape from a name string. + /// @param name The shape name. + /// @return A pointer to the created shape. + Shape* CreateShape(const std::string& name); + + /// @brief Resets the type link class state. + void Reset(); +}; + +} // namespace fixtures diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorFixture.cs b/test/ApiMark.Cpp.Tests/CppGeneratorFixture.cs new file mode 100644 index 0000000..5ee4271 --- /dev/null +++ b/test/ApiMark.Cpp.Tests/CppGeneratorFixture.cs @@ -0,0 +1,123 @@ +using ApiMark.Core.TestHelpers; +using ApiMark.Cpp; + +namespace ApiMark.Cpp.Tests; + +/// +/// xUnit class fixture that runs exactly once per +/// option combination per test run, caching the resulting +/// instances for every test in +/// to read. +/// +/// +/// Without this fixture each test would invoke clang independently, making the suite +/// proportionally slower as new tests are added. Sharing a single fixture instance +/// caps clang invocations at four regardless of test count, while keeping each test +/// focused on a single assertion over pre-built output. +/// The four option combinations covered are: +/// +/// , IncludeDeprecated = false +/// , IncludeDeprecated = true +/// , IncludeDeprecated = false +/// , IncludeDeprecated = false +/// +/// All factories are immutable after construction; tests must only read from them. +/// +public sealed class CppGeneratorFixture +{ + /// + /// Gets the factory produced by a configured for + /// with IncludeDeprecated = false. + /// + /// + /// A populated whose writers reflect + /// the public API of the fixture headers, excluding deprecated declarations. + /// + public InMemoryMarkdownWriterFactory PublicFactory { get; } + + /// + /// Gets the factory produced by a configured for + /// with IncludeDeprecated = true. + /// + /// + /// A populated whose writers reflect + /// the public API of the fixture headers, including deprecated declarations. + /// + public InMemoryMarkdownWriterFactory WithDeprecatedFactory { get; } + + /// + /// Gets the factory produced by a configured for + /// with IncludeDeprecated = false. + /// + /// + /// A populated whose writers reflect + /// the public and protected API of the fixture headers, excluding deprecated declarations. + /// + public InMemoryMarkdownWriterFactory PublicAndProtectedFactory { get; } + + /// + /// Gets the factory produced by a configured for + /// with IncludeDeprecated = false. + /// + /// + /// A populated whose writers reflect + /// the entire API surface (public, protected, and private) of the fixture headers, + /// excluding deprecated declarations. + /// + public InMemoryMarkdownWriterFactory AllFactory { get; } + + /// + /// Initializes the fixture by invoking once for + /// each of the four standard option combinations and storing the resulting factories. + /// + /// + /// xUnit constructs this fixture once per test class and shares it across all tests + /// in , so clang is invoked at most four times per run. + /// + public CppGeneratorFixture() + { + // Run with Public visibility, excluding deprecated declarations + var publicFactory = new InMemoryMarkdownWriterFactory(); + new CppGenerator(BuildOptions(ApiVisibility.Public, includeDeprecated: false)) + .Generate(publicFactory, new InMemoryContext()); + PublicFactory = publicFactory; + + // Run with Public visibility, including deprecated declarations + var withDeprecatedFactory = new InMemoryMarkdownWriterFactory(); + new CppGenerator(BuildOptions(ApiVisibility.Public, includeDeprecated: true)) + .Generate(withDeprecatedFactory, new InMemoryContext()); + WithDeprecatedFactory = withDeprecatedFactory; + + // Run with PublicAndProtected visibility, excluding deprecated declarations + var publicAndProtectedFactory = new InMemoryMarkdownWriterFactory(); + new CppGenerator(BuildOptions(ApiVisibility.PublicAndProtected, includeDeprecated: false)) + .Generate(publicAndProtectedFactory, new InMemoryContext()); + PublicAndProtectedFactory = publicAndProtectedFactory; + + // Run with All visibility, excluding deprecated declarations + var allFactory = new InMemoryMarkdownWriterFactory(); + new CppGenerator(BuildOptions(ApiVisibility.All, includeDeprecated: false)) + .Generate(allFactory, new InMemoryContext()); + AllFactory = allFactory; + } + + /// + /// Builds a pointing at the fixture include directory + /// with the specified visibility and deprecated settings. + /// + /// Which members to include in generated output. + /// Whether to include deprecated declarations. + /// A fully configured for the fixture headers. + private static CppGeneratorOptions BuildOptions( + ApiVisibility visibility, + bool includeDeprecated) + { + return new CppGeneratorOptions + { + LibraryName = "Fixtures", + PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], + Visibility = visibility, + IncludeDeprecated = includeDeprecated, + }; + } +} diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 980bc42..270874b 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -1,3 +1,4 @@ +// cspell:ignore deletedmembersclass using ApiMark.Core.TestHelpers; using ApiMark.Cpp; using ApiMark.Cpp.CppAst; @@ -5,9 +6,28 @@ namespace ApiMark.Cpp.Tests; -/// Integration tests for . -public class CppGeneratorTests +/// +/// Integration tests for using a shared +/// to avoid invoking clang more than 4 times per test run. +/// +public class CppGeneratorTests : IClassFixture { + /// The shared fixture providing pre-generated factories for common option configurations. + private readonly CppGeneratorFixture _fixture; + + /// + /// Initializes the test class with the shared . + /// + /// + /// The fixture providing pre-generated factories for each standard option combination. + /// Must not be null. + /// + public CppGeneratorTests(CppGeneratorFixture fixture) + { + // Store the shared fixture so every test can read from the pre-generated factories + _fixture = fixture; + } + /// /// Builds a pointing at the fixture include directory /// with the specified visibility and deprecated settings. @@ -105,11 +125,7 @@ public void CppGenerator_Generate_NonexistentIncludeRoot_ThrowsDirectoryNotFound public void CppGenerator_Generate_ValidHeaders_CreatesApiEntrypoint() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert Assert.True(factory.Writers.ContainsKey("api"), "Expected api.md to be created"); @@ -120,11 +136,7 @@ public void CppGenerator_Generate_ValidHeaders_CreatesApiEntrypoint() public void CppGenerator_Generate_ValidHeaders_CreatesNamespacePage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the fixtures namespace maps to key "fixtures" at the root level Assert.True( @@ -137,11 +149,7 @@ public void CppGenerator_Generate_ValidHeaders_CreatesNamespacePage() public void CppGenerator_Generate_ApiMd_ListsNamespacesWithTypeCount() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: namespace table is first in api.md and contains the fixtures namespace var apiWriter = factory.Writers["api"]; @@ -158,11 +166,7 @@ public void CppGenerator_Generate_ApiMd_ListsNamespacesWithTypeCount() public void CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleClass() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: type page key is "{namespace}/{typeName}" Assert.True( @@ -175,11 +179,7 @@ public void CppGenerator_Generate_ValidHeaders_CreatesTypePageForSampleClass() public void CppGenerator_Generate_IncludeDeprecatedFalse_ExcludesDeprecatedClass() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions(includeDeprecated: false)); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert Assert.False( @@ -192,11 +192,7 @@ public void CppGenerator_Generate_IncludeDeprecatedFalse_ExcludesDeprecatedClass public void CppGenerator_Generate_IncludeDeprecatedTrue_IncludesDeprecatedClass() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions(includeDeprecated: true)); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.WithDeprecatedFactory; // Assert Assert.True( @@ -209,11 +205,7 @@ public void CppGenerator_Generate_IncludeDeprecatedTrue_IncludesDeprecatedClass( public void CppGenerator_Generate_PublicVisibility_ExcludesProtectedMethod() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions(ApiVisibility.Public)); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the type page itself must exist (the class is public) Assert.True(factory.Writers.ContainsKey("fixtures/ProtectedMembersClass")); @@ -229,11 +221,7 @@ public void CppGenerator_Generate_PublicVisibility_ExcludesProtectedMethod() public void CppGenerator_Generate_PublicAndProtectedVisibility_IncludesProtectedMethod() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions(ApiVisibility.PublicAndProtected)); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicAndProtectedFactory; // Assert: ProtectedMethod has a parameter, so it gets its own page Assert.True( @@ -246,11 +234,7 @@ public void CppGenerator_Generate_PublicAndProtectedVisibility_IncludesProtected public void CppGenerator_Generate_AllVisibility_IncludesPrivateMethod() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions(ApiVisibility.All)); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.AllFactory; // Assert: PrivateMethod has a parameter so it gets its own page under All visibility Assert.True( @@ -263,11 +247,7 @@ public void CppGenerator_Generate_AllVisibility_IncludesPrivateMethod() public void CppGenerator_Generate_MethodWithParameters_CreatesMemberPage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: GetGreeting has a parameter so it gets its own page Assert.True( @@ -283,11 +263,7 @@ public void CppGenerator_Generate_MethodWithParameters_CreatesMemberPage() public void CppGenerator_Generate_SampleClass_TypePage_MethodRowShowsParameterType() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the SampleClass type page must include a method row whose link cell // starts with "[GetGreeting(" followed by a non-empty parameter type, confirming @@ -302,24 +278,20 @@ public void CppGenerator_Generate_SampleClass_TypePage_MethodRowShowsParameterTy } /// - /// Validates that an individual method member page uses an H3 heading, consistent - /// with the DotNet generator and the combined member page convention. + /// Validates that an individual method member page uses an H1 heading, consistent + /// with the page-as-standalone-document convention. /// [Fact] - public void CppGenerator_Generate_MemberPage_UsesH3Heading() + public void CppGenerator_Generate_MemberPage_UsesH1Heading() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); + var factory = _fixture.PublicFactory; - // Act - generator.Generate(factory, new InMemoryContext()); - - // Assert: the GetGreeting member page must open with an H3 heading containing the - // class and method name so the heading level matches combined member pages + // Assert: the GetGreeting member page must open with an H1 heading containing the + // class and method name so every generated page starts at H1 var writer = factory.Writers["fixtures/SampleClass/GetGreeting"]; var firstHeading = writer.Operations.OfType().First(); - Assert.Equal(3, firstHeading.Level); + Assert.Equal(1, firstHeading.Level); Assert.Contains("GetGreeting", firstHeading.Text, StringComparison.Ordinal); } @@ -328,11 +300,7 @@ public void CppGenerator_Generate_MemberPage_UsesH3Heading() public void CppGenerator_AllMembers_GetSeparateFiles() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: parameterless method in SampleClass gets own page Assert.True( @@ -355,11 +323,7 @@ public void CppGenerator_AllMembers_GetSeparateFiles() public void CppGenerator_OutputFiles_FollowNamingConvention() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: root entrypoint is "api" Assert.True(factory.Writers.ContainsKey("api")); @@ -379,11 +343,7 @@ public void CppGenerator_OutputFiles_FollowNamingConvention() public void CppGenerator_Generate_TypeWithDocComment_WritesSummaryToParagraph() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the SampleClass page must contain the key summary words from the @brief tag var writer = factory.Writers["fixtures/SampleClass"]; @@ -396,11 +356,7 @@ public void CppGenerator_Generate_TypeWithDocComment_WritesSummaryToParagraph() public void CppGenerator_Generate_MethodWithDocComment_WritesSummaryToParagraph() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the GetGreeting page must contain the key summary word from the @brief tag var writer = factory.Writers["fixtures/SampleClass/GetGreeting"]; @@ -413,11 +369,7 @@ public void CppGenerator_Generate_MethodWithDocComment_WritesSummaryToParagraph( public void CppGenerator_Generate_MissingDocComment_WritesPlaceholder() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: Refresh() has no Doxygen comment — the generator must emit the placeholder var writer = factory.Writers["fixtures/SampleClass/Refresh"]; @@ -430,11 +382,7 @@ public void CppGenerator_Generate_MissingDocComment_WritesPlaceholder() public void CppGenerator_Generate_FreeFunctions_GetOwnPages() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: free functions in the fixtures namespace get pages at "{namespace}/{functionName}" Assert.True( @@ -447,11 +395,7 @@ public void CppGenerator_Generate_FreeFunctions_GetOwnPages() public void CppGenerator_Generate_ValidHeaders_CreatesEnumPage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: enum page key follows the same "{namespace}/{typeName}" pattern as classes Assert.True( @@ -464,11 +408,7 @@ public void CppGenerator_Generate_ValidHeaders_CreatesEnumPage() public void CppGenerator_Generate_EnumPage_ContainsValues() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the enum values table must contain every declared SampleStatus value var writer = factory.Writers["fixtures/SampleStatus"]; @@ -484,11 +424,7 @@ public void CppGenerator_Generate_EnumPage_ContainsValues() public void CppGenerator_Generate_TemplateClass_CreatesTypePage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: Stack is a template class and must receive its own documentation page Assert.True( @@ -501,11 +437,7 @@ public void CppGenerator_Generate_TemplateClass_CreatesTypePage() public void CppGenerator_Generate_InheritanceClass_CreatesTypePage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: Circle inherits from Shape and must receive its own documentation page Assert.True( @@ -513,16 +445,35 @@ public void CppGenerator_Generate_InheritanceClass_CreatesTypePage() "Expected type page for Circle at 'fixtures/Circle'"); } + /// + /// Validates that the type page for a class with a base class includes the base class + /// name in the signature block so that the inheritance relationship is visible without + /// opening the header file. + /// + [Fact] + public void CppGenerator_Generate_InheritanceClass_EmitsBaseClassInSignature() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: the Circle type page must exist + Assert.True(factory.Writers.ContainsKey("fixtures/Circle"), "Expected type page for Circle"); + + // Assert: the Circle type page signature must contain ": public Shape" so readers can + // see the inheritance relationship at a glance without opening the header file + var writer = factory.Writers["fixtures/Circle"]; + var signatures = writer.Operations.OfType().Select(s => s.Code).ToList(); + Assert.Contains( + signatures, + s => s.Contains(": public Shape", StringComparison.Ordinal)); + } + /// Validates that the Circle constructor receives its own member detail page. [Fact] public void CppGenerator_Generate_Constructor_CreatesConstructorPage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: Circle has an explicit constructor that must receive its own page Assert.True( @@ -535,11 +486,7 @@ public void CppGenerator_Generate_Constructor_CreatesConstructorPage() public void CppGenerator_Generate_TypePage_ContainsQualifiedName() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the SampleClass type page must include "fixtures::SampleClass" in its signature // comment so an AI reader knows the exact qualified name to use in code @@ -555,11 +502,7 @@ public void CppGenerator_Generate_TypePage_ContainsQualifiedName() public void CppGenerator_Generate_MemberPage_ContainsQualifiedName() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the GetGreeting member page must include the fully-qualified name so that // an AI reader can call "fixtures::SampleClass::GetGreeting" without guessing the namespace @@ -575,11 +518,7 @@ public void CppGenerator_Generate_MemberPage_ContainsQualifiedName() public void CppGenerator_Generate_VariadicFunction_CreatesPage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: Format is a variadic free function and must receive its own documentation page Assert.True( @@ -595,11 +534,7 @@ public void CppGenerator_Generate_VariadicFunction_CreatesPage() public void CppGenerator_Generate_CaseCollisionClass_CreatesCombinedPage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the combined page is written at the lowercase key path Assert.True( @@ -615,11 +550,7 @@ public void CppGenerator_Generate_CaseCollisionClass_CreatesCombinedPage() public void CppGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: no page should be created using the exact-case name "Name" when a collision // exists — the combined lowercase page replaces all individual pages for the group @@ -629,31 +560,27 @@ public void CppGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedP } /// - /// Validates that the combined collision page contains H4 headings for both the + /// Validates that the combined collision page contains H2 headings for both the /// method Name() and the field name. /// [Fact] public void CppGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: combined page exists Assert.True(factory.Writers.ContainsKey("fixtures/CaseCollisionClass/name")); var writer = factory.Writers["fixtures/CaseCollisionClass/name"]; - // Assert: both members appear as distinct H4 headings on the combined page - var level4Headings = writer.Operations + // Assert: both members appear as distinct H2 headings on the combined page + var level2Headings = writer.Operations .OfType() - .Where(h => h.Level == 4) + .Where(h => h.Level == 2) .Select(h => h.Text) .ToList(); - Assert.Contains(level4Headings, h => h.StartsWith("Name", StringComparison.Ordinal)); - Assert.Contains(level4Headings, h => h.StartsWith("name", StringComparison.Ordinal)); + Assert.Contains(level2Headings, h => h.StartsWith("Name", StringComparison.Ordinal)); + Assert.Contains(level2Headings, h => h.StartsWith("name", StringComparison.Ordinal)); } /// @@ -664,11 +591,7 @@ public void CppGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMem public void CppGenerator_Generate_MethodWithDetails_WritesDetailsParagraph() { // Arrange: RemarksClass::Compute carries a @details block in its Doxygen comment - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the Compute member page must contain a paragraph with the @details text var writer = factory.Writers["fixtures/RemarksClass/Compute"]; @@ -687,11 +610,7 @@ public void CppGenerator_Generate_MethodWithDetails_WritesDetailsParagraph() public void CppGenerator_Generate_NamespaceWithoutDocComment_UsesPlaceholderDescription() { // Arrange: no fixture namespace carries a namespace-level Doxygen comment - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the namespace table in api.md must list the placeholder for the fixtures namespace, // confirming the description is taken from the namespace object (none present) and not @@ -710,11 +629,7 @@ public void CppGenerator_Generate_NamespaceWithoutDocComment_UsesPlaceholderDesc public void CppGenerator_Generate_EnumPage_ContainsIncludeDirective() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the SampleStatus enum page must contain an #include directive in its signature // block so readers can copy-paste the include path without consulting the header tree @@ -733,11 +648,7 @@ public void CppGenerator_Generate_EnumPage_ContainsIncludeDirective() public void CppGenerator_Generate_FreeFunctionPage_ContainsIncludeDirective() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the Add free-function page must contain an #include directive in its signature // block so readers can include the correct header without browsing the source tree @@ -749,18 +660,51 @@ public void CppGenerator_Generate_FreeFunctionPage_ContainsIncludeDirective() } /// - /// Validates that restricts header - /// enumeration to files matching at least one pattern. + /// Validates that when no are set, + /// all headers under the include roots with recognized C++ extensions are documented. /// [Fact] - public void CppGenerator_Generate_IncludePatterns_OnlyIncludesMatchingFiles() + public void CppGenerator_Generate_NoApiHeaderPatterns_DocumentsAllHeaders() + { + // Arrange: options with no ApiHeaderPatterns — the default glob should apply + var options = new CppGeneratorOptions + { + LibraryName = "Fixtures", + PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], + // ApiHeaderPatterns intentionally left empty to exercise the default behavior + }; + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new CppGenerator(options); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // Assert: pages from multiple fixture headers must all be present, confirming the + // default glob includes every recognized header under the include root + Assert.True( + factory.Writers.ContainsKey("fixtures/SampleClass"), + "Expected SampleClass page when no ApiHeaderPatterns are set"); + Assert.True( + factory.Writers.ContainsKey("fixtures/SampleStatus"), + "Expected SampleStatus page when no ApiHeaderPatterns are set"); + Assert.True( + factory.Writers.ContainsKey("fixtures/FinalClass"), + "Expected FinalClass page when no ApiHeaderPatterns are set"); + } + + /// + /// Validates that with a specific + /// include pattern restricts header enumeration to files matching that pattern. + /// + [Fact] + public void CppGenerator_Generate_ApiHeaderPatterns_IncludePattern_OnlyMatchingFilesDocumented() { // Arrange: include only SampleClass.h to isolate a single type var options = new CppGeneratorOptions { LibraryName = "Fixtures", PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], - IncludePatterns = ["**/SampleClass.h"], + ApiHeaderPatterns = ["**/SampleClass.h"], }; var factory = new InMemoryMarkdownWriterFactory(); var generator = new CppGenerator(options); @@ -780,18 +724,19 @@ public void CppGenerator_Generate_IncludePatterns_OnlyIncludesMatchingFiles() } /// - /// Validates that removes specific - /// files from the header enumeration while leaving all other headers intact. + /// Validates that a !-prefixed exclusion pattern in + /// excludes matching headers while + /// leaving all other headers documented. /// [Fact] - public void CppGenerator_Generate_ExcludePatterns_ExcludesMatchingFiles() + public void CppGenerator_Generate_ApiHeaderPatterns_ExcludePattern_ExcludesMatchingFiles() { - // Arrange: exclude SampleClass.h to verify its type disappears from output + // Arrange: catch-all followed by an exclusion pattern for SampleClass.h var options = new CppGeneratorOptions { LibraryName = "Fixtures", PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], - ExcludePatterns = ["**/SampleClass.h"], + ApiHeaderPatterns = ["**/*", "!**/SampleClass.h"], }; var factory = new InMemoryMarkdownWriterFactory(); var generator = new CppGenerator(options); @@ -799,10 +744,10 @@ public void CppGenerator_Generate_ExcludePatterns_ExcludesMatchingFiles() // Act generator.Generate(factory, new InMemoryContext()); - // Assert: SampleClass page must not exist because its header was excluded + // Assert: SampleClass page must not exist because its header was excluded by the exclusion pattern Assert.False( factory.Writers.ContainsKey("fixtures/SampleClass"), - "Expected SampleClass to be absent when its header is excluded"); + "Expected SampleClass to be absent when its header is excluded by exclusion pattern"); // Assert: SampleStatus page must still exist because SampleEnum.h was not excluded Assert.True( @@ -810,6 +755,62 @@ public void CppGenerator_Generate_ExcludePatterns_ExcludesMatchingFiles() "Expected SampleStatus to be present when only SampleClass.h is excluded"); } + /// + /// Validates gitignore-style re-include semantics: a header that is first excluded by + /// an exclusion pattern and then re-included by a later positive pattern is documented. + /// + [Fact] + public void CppGenerator_Generate_ApiHeaderPatterns_ReInclude_GitignoreSemantics_IncludesReIncludedHeader() + { + // Arrange: catch-all, exclude DeprecatedClass.h, then re-include it; + // IncludeDeprecated=true so the class appears in output even when its header is parsed + var options = new CppGeneratorOptions + { + LibraryName = "Fixtures", + PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], + ApiHeaderPatterns = ["**/*", "!**/DeprecatedClass.h", "**/DeprecatedClass.h"], + IncludeDeprecated = true, + }; + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new CppGenerator(options); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // Assert: DeprecatedClass must be documented because the re-include pattern wins + Assert.True( + factory.Writers.ContainsKey("fixtures/DeprecatedClass"), + "Expected DeprecatedClass page when re-included after exclusion"); + } + + /// + /// Validates that an exclusion pattern without a subsequent re-include permanently + /// removes a header from documentation, confirming last-match-wins semantics. + /// + [Fact] + public void CppGenerator_Generate_ApiHeaderPatterns_ExcludeWithoutReInclude_ExcludesHeader() + { + // Arrange: catch-all followed by exclusion pattern for DeprecatedClass.h (no re-include); + // IncludeDeprecated=true so the class would appear if the header were parsed + var options = new CppGeneratorOptions + { + LibraryName = "Fixtures", + PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], + ApiHeaderPatterns = ["**/*", "!**/DeprecatedClass.h"], + IncludeDeprecated = true, + }; + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new CppGenerator(options); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // Assert: DeprecatedClass must not be documented because the exclusion pattern is final + Assert.False( + factory.Writers.ContainsKey("fixtures/DeprecatedClass"), + "Expected DeprecatedClass to be absent when excluded without re-include"); + } + /// /// Validates that the type page for a final class contains the final /// keyword in its signature block so that AI readers immediately know the class @@ -819,11 +820,7 @@ public void CppGenerator_Generate_ExcludePatterns_ExcludesMatchingFiles() public void CppGenerator_Generate_FinalClass_EmitsFinalKeywordInSignature() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the FinalClass type page must exist Assert.True(factory.Writers.ContainsKey("fixtures/FinalClass"), "Expected type page for FinalClass"); @@ -846,11 +843,7 @@ public void CppGenerator_Generate_FinalClass_EmitsFinalKeywordInSignature() public void CppGenerator_Generate_NonFinalClass_DoesNotEmitFinalKeyword() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the SampleClass type page must exist and must not contain "final" // because SampleClass is not declared final @@ -871,11 +864,7 @@ public void CppGenerator_Generate_NonFinalClass_DoesNotEmitFinalKeyword() public void CppGenerator_Generate_ClassWithOperators_CreatesOperatorsPage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: a single shared operators page is written for OperatorClass instead of // individual pages that would collide because operator+, operator-, etc. all @@ -893,11 +882,7 @@ public void CppGenerator_Generate_ClassWithOperators_CreatesOperatorsPage() public void CppGenerator_Generate_ClassWithOperators_OperatorsPageContainsOperatorEntry() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the operators page must exist Assert.True(factory.Writers.ContainsKey("fixtures/OperatorClass/operators")); @@ -920,11 +905,7 @@ public void CppGenerator_Generate_ClassWithOperators_OperatorsPageContainsOperat public void CppGenerator_Generate_ClassWithOperators_TypePageLinksToOperatorsPage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the OperatorClass type page must exist Assert.True(factory.Writers.ContainsKey("fixtures/OperatorClass")); @@ -947,11 +928,7 @@ public void CppGenerator_Generate_ClassWithOperators_TypePageLinksToOperatorsPag public void CppGenerator_Generate_NamespaceFreeOperator_CreatesNamespaceOperatorsPage() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: a shared namespace operators page is written under the fixtures namespace // for the free operator<< declared in OperatorClass.h @@ -986,4 +963,391 @@ public void CppGenerator_CheckForErrors_SystemHeaderDiagnostic_RoutesToContextLi line => line.Contains("[CppGenerator] clang:", StringComparison.Ordinal) && line.Contains(systemError, StringComparison.Ordinal)); } + + /// + /// Validates that a method returning an intra-library type emits a Markdown link in + /// the Returns column of the type page's Methods table. + /// + [Fact] + public void CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInReturnsCell() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: TypeLinkClass type page must exist + Assert.True( + factory.Writers.ContainsKey("fixtures/TypeLinkClass"), + "Expected type page for TypeLinkClass"); + + // Assert: the Methods table on the TypeLinkClass page must have a Returns cell + // containing a Markdown link to Shape — the return type of CreateShape() + var writer = factory.Writers["fixtures/TypeLinkClass"]; + var operations = writer.Operations.ToList(); + var methodsIndex = operations.FindIndex(op => op is HeadingOperation h && h.Text == "Methods"); + Assert.True(methodsIndex >= 0, "Expected 'Methods' heading on TypeLinkClass page"); + var methodsTable = operations.Skip(methodsIndex + 1).OfType().First(); + + // Returns column (index 1) for CreateShape must contain a Markdown link, not plain text + var row = methodsTable.Rows.FirstOrDefault(r => r[0].Contains("CreateShape", StringComparison.Ordinal)); + Assert.NotNull(row); + Assert.Contains("[Shape]", row![1], StringComparison.Ordinal); + Assert.Contains("Shape.md", row[1], StringComparison.Ordinal); + } + + /// + /// Validates that emits plain text and records + /// the type in the external set when the type is not in the known-types dictionary. + /// + [Fact] + public void CppTypeLinkResolver_Linkify_UnknownNamespacedType_TracksExternalType() + { + // Arrange: a resolver with no known types, simulating a fully external reference + var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal)); + var externalTypes = new SortedSet(); + + // Act: resolve a type that belongs to a non-std namespace not in the known-types map + var result = resolver.Linkify("acme::Logger *", string.Empty, externalTypes); + + // Assert: the original type string is returned as-is (no link to an unknown page) + Assert.Equal("acme::Logger *", result); + + // Assert: the type is tracked in the external types set with the correct namespace + Assert.Single(externalTypes); + Assert.Equal("Logger", externalTypes.First().TypeString); + Assert.Equal("acme", externalTypes.First().Namespace); + } + + /// + /// Validates that a deleted copy constructor is still documented and that its + /// signature contains = delete so that readers know copying is explicitly + /// forbidden without needing to open the header file. + /// + [Fact] + public void CppGenerator_Generate_DeletedCopyConstructor_EmitsDeleteSuffix() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: the type page must exist + Assert.True( + factory.Writers.ContainsKey("fixtures/DeletedMembersClass"), + "Expected type page for DeletedMembersClass"); + + // Assert: both constructors share the same base name, so they are combined onto + // a single page keyed by the lowercase class name + Assert.True( + factory.Writers.ContainsKey("fixtures/DeletedMembersClass/deletedmembersclass"), + "Expected combined constructor page for DeletedMembersClass"); + + // Assert: the combined constructor page must contain "= delete" for the deleted overload + var writer = factory.Writers["fixtures/DeletedMembersClass/deletedmembersclass"]; + var signatures = writer.Operations.OfType().Select(s => s.Code).ToList(); + Assert.Contains( + signatures, + s => s.Contains("= delete", StringComparison.Ordinal)); + } + + /// + /// Validates that a deleted copy-assignment operator is still documented and that its + /// signature contains = delete so that readers know assignment is explicitly + /// forbidden without needing to open the header file. + /// + [Fact] + public void CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffix() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: the operators page for DeletedMembersClass must contain "= delete" + Assert.True( + factory.Writers.ContainsKey("fixtures/DeletedMembersClass/operators"), + "Expected operators page for DeletedMembersClass"); + + var writer = factory.Writers["fixtures/DeletedMembersClass/operators"]; + var signatures = writer.Operations.OfType().Select(s => s.Code).ToList(); + Assert.Contains( + signatures, + s => s.Contains("= delete", StringComparison.Ordinal)); + } + + /// + /// Validates that generating from valid fixture headers creates individual pages + /// for each using type alias declared in the TypeAliasFixtures.h header. + /// + [Fact] + public void CppGenerator_Generate_TypeAlias_CreatesAliasPages() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: each alias gets its own page under the namespace folder + Assert.True( + factory.Writers.ContainsKey("fixtures/item_id_t"), + "Expected type alias page for item_id_t at 'fixtures/item_id_t'"); + Assert.True( + factory.Writers.ContainsKey("fixtures/label_t"), + "Expected type alias page for label_t at 'fixtures/label_t'"); + } + + /// + /// Validates that the type alias page for item_id_t contains the correct + /// using declaration and the doc-comment summary. + /// + [Fact] + public void CppGenerator_Generate_TypeAliasPage_ContainsDeclarationAndSummary() + { + // Arrange + var factory = _fixture.PublicFactory; + var writer = factory.Writers["fixtures/item_id_t"]; + + // Assert: the signature block must include the using declaration + var signatures = writer.Operations.OfType().Select(s => s.Code).ToList(); + Assert.Contains( + signatures, + s => s.Contains("using item_id_t = int32_t", StringComparison.Ordinal)); + + // Assert: the summary paragraph must be present + var paragraphs = writer.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains( + paragraphs, + p => p.Contains("32-bit signed integer identifier", StringComparison.Ordinal)); + } + + /// + /// Validates that the fixtures namespace summary page lists each type alias + /// in a "Type Aliases" section so readers can discover them without opening individual pages. + /// + [Fact] + public void CppGenerator_Generate_NamespacePage_ListsTypeAliases() + { + // Arrange + var factory = _fixture.PublicFactory; + var writer = factory.Writers["fixtures"]; + + // Assert: the namespace page must contain a "Type Aliases" heading + var headings = writer.Operations.OfType().Select(h => h.Text).ToList(); + Assert.Contains(headings, h => h.Contains("Type Aliases", StringComparison.Ordinal)); + + // Assert: the namespace page table must contain links to both alias pages + var tables = writer.Operations.OfType().ToList(); + var allCells = tables.SelectMany(t => t.Rows).SelectMany(r => r).ToList(); + Assert.Contains(allCells, c => c.Contains("item_id_t", StringComparison.Ordinal)); + Assert.Contains(allCells, c => c.Contains("label_t", StringComparison.Ordinal)); + } + + /// + /// Validates that the type alias page for label_t shows a simplified underlying + /// type (std::string rather than the verbose clang form + /// std::basic_string<char, ...>), and that the namespace summary table + /// also uses the simplified form. + /// + [Fact] + public void CppGenerator_Generate_TypeAliasPage_SimplifiesUnderlyingType() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: the alias page declaration must use the simplified form (not the verbose clang form) + var aliasWriter = factory.Writers["fixtures/label_t"]; + var signatures = aliasWriter.Operations.OfType().Select(s => s.Code).ToList(); + Assert.Contains( + signatures, + s => s.Contains("using label_t = std::string", StringComparison.Ordinal)); + Assert.DoesNotContain( + signatures, + s => s.Contains("basic_string", StringComparison.Ordinal)); + + // Assert: the namespace summary table must also show the simplified underlying type + var nsWriter = factory.Writers["fixtures"]; + var allCells = nsWriter.Operations.OfType() + .SelectMany(t => t.Rows).SelectMany(r => r).ToList(); + var underlyingCell = allCells + .SkipWhile(c => !c.Contains("label_t", StringComparison.Ordinal)) + .Skip(1) + .FirstOrDefault(); + Assert.NotNull(underlyingCell); + Assert.DoesNotContain("basic_string", underlyingCell, StringComparison.Ordinal); + } + + /// + /// Validates that a free function with a default parameter value includes the default + /// in its signature block (e.g. uint32_t seed = 0). + /// + [Fact] + public void CppGenerator_Generate_DefaultParameter_SignatureContainsDefault() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: crc32 page must show the default value in its signature + Assert.True(factory.Writers.ContainsKey("fixtures/crc32")); + var writer = factory.Writers["fixtures/crc32"]; + var signatures = writer.Operations.OfType().Select(s => s.Code).ToList(); + Assert.Contains( + signatures, + s => s.Contains("seed = 0", StringComparison.Ordinal)); + } + + /// + /// Validates that a Doxygen @note tag is rendered as a blockquote paragraph + /// (prefixed with > **Note:**) on the function's detail page. + /// + [Fact] + public void CppGenerator_Generate_BoolDefaultParameter_SignatureContainsFalse() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: configure page must show the bool default in its signature + Assert.True(factory.Writers.ContainsKey("fixtures/configure")); + var writer = factory.Writers["fixtures/configure"]; + var signatures = writer.Operations.OfType().Select(s => s.Code).ToList(); + Assert.Contains( + signatures, + s => s.Contains("initial = false", StringComparison.Ordinal)); + } + + /// + /// Validates that a negative integer default argument (e.g. int max = -1) + /// is rendered correctly in the function signature. + /// + [Fact] + public void CppGenerator_Generate_NegativeIntDefaultParameter_SignatureContainsNegativeValue() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: count_capped page must show the negative default in its signature + Assert.True(factory.Writers.ContainsKey("fixtures/count_capped")); + var writer = factory.Writers["fixtures/count_capped"]; + var signatures = writer.Operations.OfType().Select(s => s.Code).ToList(); + Assert.Contains( + signatures, + s => s.Contains("max = -1", StringComparison.Ordinal)); + } + + /// + /// Validates that a floating-point default argument (e.g. float factor = 1.5f) + /// is rendered in the function signature without the type suffix. + /// + [Fact] + public void CppGenerator_Generate_FloatDefaultParameter_SignatureContainsValue() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: scale page must show the float default in its signature + Assert.True(factory.Writers.ContainsKey("fixtures/scale")); + var writer = factory.Writers["fixtures/scale"]; + var signatures = writer.Operations.OfType().Select(s => s.Code).ToList(); + Assert.Contains( + signatures, + s => s.Contains("factor = 1.5", StringComparison.Ordinal)); + } + + /// + /// Validates that a nested class declared inside a public outer class receives its + /// own type page under the outer class's folder. + /// + [Fact] + public void CppGenerator_Generate_NestedClass_CreatesNestedClassPage() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: Inner is a public nested class of Outer; its page must be under Outer's folder + Assert.True( + factory.Writers.ContainsKey("fixtures/Outer/Inner"), + "Expected nested class page at 'fixtures/Outer/Inner'"); + } + + /// + /// Validates that the outer class's type page lists the nested class in a + /// "Nested Classes" section so readers can discover inner types without opening the header. + /// + [Fact] + public void CppGenerator_Generate_NestedClass_ListedOnOuterClassPage() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: the Outer type page must exist + Assert.True(factory.Writers.ContainsKey("fixtures/Outer"), "Expected type page for Outer"); + var writer = factory.Writers["fixtures/Outer"]; + + // Assert: the page must include a "Nested Classes" heading + var headings = writer.Operations.OfType().Select(h => h.Text).ToList(); + Assert.Contains(headings, h => h.Contains("Nested Classes", StringComparison.Ordinal)); + + // Assert: the "Nested Classes" table must contain "Inner" in a link cell + var tables = writer.Operations.OfType().ToList(); + var allCells = tables.SelectMany(t => t.Rows).SelectMany(r => r).ToList(); + Assert.Contains(allCells, c => c.Contains("Inner", StringComparison.Ordinal)); + } + + /// + /// Validates that a using type alias declared inside a public class body receives + /// its own page under the class's folder so readers can navigate to it directly. + /// + [Fact] + public void CppGenerator_Generate_ClassScopedTypeAlias_CreatesAliasPage() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: Outer::size_type must receive its own page under the Outer class folder + Assert.True( + factory.Writers.ContainsKey("fixtures/Outer/size_type"), + "Expected class-scoped alias page at 'fixtures/Outer/size_type'"); + } + + /// + /// Validates that the outer class's type page lists its class-scoped type alias in a + /// "Type Aliases" section so readers can see the alias without navigating to a sub-page. + /// + [Fact] + public void CppGenerator_Generate_ClassScopedTypeAlias_ListedOnClassPage() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: the Outer type page must exist + Assert.True(factory.Writers.ContainsKey("fixtures/Outer"), "Expected type page for Outer"); + var writer = factory.Writers["fixtures/Outer"]; + + // Assert: the page must include a "Type Aliases" heading + var headings = writer.Operations.OfType().Select(h => h.Text).ToList(); + Assert.Contains(headings, h => h.Contains("Type Aliases", StringComparison.Ordinal)); + + // Assert: the "Type Aliases" table must contain "size_type" + var tables = writer.Operations.OfType().ToList(); + var allCells = tables.SelectMany(t => t.Rows).SelectMany(r => r).ToList(); + Assert.Contains(allCells, c => c.Contains("size_type", StringComparison.Ordinal)); + } + + /// + /// Validates that two classes declaring the same alias name (size_type) each + /// produce a distinct page keyed by their fully-qualified class scope, confirming that + /// the knownTypes map does not collide across different owning classes. + /// + [Fact] + public void CppGenerator_Generate_ClassScopedTypeAlias_DoesNotCollideAcrossClasses() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: Outer::size_type and Other::size_type must each get their own distinct page; + // a collision would cause only one of them to exist + Assert.True( + factory.Writers.ContainsKey("fixtures/Outer/size_type"), + "Expected alias page for Outer::size_type at 'fixtures/Outer/size_type'"); + Assert.True( + factory.Writers.ContainsKey("fixtures/Other/size_type"), + "Expected alias page for Other::size_type at 'fixtures/Other/size_type'"); + + // Assert: the two pages must be distinct writer instances so they contain different content + Assert.NotSame( + factory.Writers["fixtures/Outer/size_type"], + factory.Writers["fixtures/Other/size_type"]); + } } diff --git a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs new file mode 100644 index 0000000..add6e5d --- /dev/null +++ b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using Xunit; + +namespace ApiMark.Cpp.Tests; + +/// Unit tests for . +public class CppTypeLinkResolverTests +{ + /// + /// Validates that an exact qualified-name match in knownTypes produces a Markdown link. + /// + [Fact] + public void CppTypeLinkResolver_Linkify_ExactQualifiedMatch_EmitsLink() + { + // Arrange + var knownTypes = new Dictionary(StringComparer.Ordinal) + { + { "ns::Foo", "ns/Foo" }, + }; + var resolver = new CppTypeLinkResolver(knownTypes); + var externalTypes = new SortedSet(); + + // Act + var result = resolver.Linkify("ns::Foo", "ns", externalTypes); + + // Assert: exact qualified match produces a link + Assert.Contains("[Foo]", result, StringComparison.Ordinal); + Assert.Contains("Foo.md", result, StringComparison.Ordinal); + } + + /// + /// Validates that an unqualified short-name reference that matches exactly one known type + /// produces a Markdown link via the short-name fallback. + /// + [Fact] + public void CppTypeLinkResolver_Linkify_UnambiguousShortName_EmitsLink() + { + // Arrange: only one type has the unqualified name "Bar" + var knownTypes = new Dictionary(StringComparer.Ordinal) + { + { "ns::Bar", "ns/Bar" }, + }; + var resolver = new CppTypeLinkResolver(knownTypes); + var externalTypes = new SortedSet(); + + // Act + var result = resolver.Linkify("Bar", "ns", externalTypes); + + // Assert: unambiguous short name produces a link + Assert.Contains("[Bar]", result, StringComparison.Ordinal); + } + + /// + /// Validates that when two documented types share the same unqualified name (e.g. + /// Outer::size_type and Other::size_type), an unqualified reference to + /// size_type does NOT produce a link — the short-name fallback must not return + /// a non-deterministic result. + /// + [Fact] + public void CppTypeLinkResolver_Linkify_AmbiguousShortName_EmitsPlainText() + { + // Arrange: two types share the unqualified name "size_type" + var knownTypes = new Dictionary(StringComparer.Ordinal) + { + { "ns::Outer::size_type", "ns/Outer/size_type" }, + { "ns::Other::size_type", "ns/Other/size_type" }, + }; + var resolver = new CppTypeLinkResolver(knownTypes); + var externalTypes = new SortedSet(); + + // Act + var result = resolver.Linkify("size_type", "ns", externalTypes); + + // Assert: ambiguous short name must not produce a link + Assert.DoesNotContain("[size_type]", result, StringComparison.Ordinal); + Assert.Equal("size_type", result); + } + + /// + /// Validates that a fully-qualified reference to one of two ambiguously-named types + /// still resolves correctly via the exact-match path. + /// + [Fact] + public void CppTypeLinkResolver_Linkify_QualifiedReferenceToAmbiguousType_EmitsCorrectLink() + { + // Arrange: two types share the unqualified name "size_type" + var knownTypes = new Dictionary(StringComparer.Ordinal) + { + { "ns::Outer::size_type", "ns/Outer/size_type" }, + { "ns::Other::size_type", "ns/Other/size_type" }, + }; + var resolver = new CppTypeLinkResolver(knownTypes); + var externalTypes = new SortedSet(); + + // Act: use the fully-qualified name to disambiguate + var result = resolver.Linkify("ns::Outer::size_type", "ns", externalTypes); + + // Assert: qualified reference resolves to the correct page + Assert.Contains("[size_type]", result, StringComparison.Ordinal); + Assert.Contains("Outer/size_type.md", result, StringComparison.Ordinal); + } +} diff --git a/test/ApiMark.DotNet.Fixtures/ApiMark.DotNet.Fixtures.csproj b/test/ApiMark.DotNet.Fixtures/ApiMark.DotNet.Fixtures.csproj index 98d0132..368f784 100644 --- a/test/ApiMark.DotNet.Fixtures/ApiMark.DotNet.Fixtures.csproj +++ b/test/ApiMark.DotNet.Fixtures/ApiMark.DotNet.Fixtures.csproj @@ -13,4 +13,7 @@ DEMA Consulting DEMA Consulting + + + diff --git a/test/ApiMark.DotNet.Fixtures/ArrayAndNullableClass.cs b/test/ApiMark.DotNet.Fixtures/ArrayAndNullableClass.cs index 0a56a97..f87f651 100644 --- a/test/ApiMark.DotNet.Fixtures/ArrayAndNullableClass.cs +++ b/test/ApiMark.DotNet.Fixtures/ArrayAndNullableClass.cs @@ -18,4 +18,8 @@ public static class ArrayAndNullableClass /// Gets a value asynchronously. /// A task that resolves to a boolean value. public static Task GetAsync() => Task.FromResult(false); + + /// Gets an optional array of names; the array reference itself may be null. + /// An array of name strings, or null when unavailable. + public static string[]? GetNullableNames() => null; } diff --git a/test/ApiMark.DotNet.Fixtures/SampleDelegate.cs b/test/ApiMark.DotNet.Fixtures/SampleDelegate.cs new file mode 100644 index 0000000..3c178d9 --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/SampleDelegate.cs @@ -0,0 +1,7 @@ +namespace ApiMark.DotNet.Fixtures; + +/// A sample delegate that reports a service event. +public delegate void ServiceEvent(DateTime timestamp, string service, string name, object[] arguments); + +/// A sample generic delegate that transforms a value. +public delegate TResult SampleTransform(TInput input); diff --git a/test/ApiMark.DotNet.Fixtures/SampleImplementation.cs b/test/ApiMark.DotNet.Fixtures/SampleImplementation.cs new file mode 100644 index 0000000..2952180 --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/SampleImplementation.cs @@ -0,0 +1,11 @@ +namespace ApiMark.DotNet.Fixtures; + +/// A sample implementation of ISampleInterface. +public class SampleImplementation : ISampleInterface +{ + /// + public string Name => "Sample"; + + /// + public void Execute(string input) { } +} diff --git a/test/ApiMark.DotNet.Fixtures/TypeLinkFixture.cs b/test/ApiMark.DotNet.Fixtures/TypeLinkFixture.cs new file mode 100644 index 0000000..d7c2f0e --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/TypeLinkFixture.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; + +namespace ApiMark.DotNet.Fixtures; + +/// +/// A fixture class for testing intra-doc type links and the External Types section. +/// +public class TypeLinkFixture +{ + /// + /// Gets a sample class instance. + /// + /// A new instance. + public SampleClass GetSampleClass() => new(); + + /// + /// Logs a message using the supplied logger. + /// + /// The logger to write to. + /// The message to log. + public void Log(ILogger logger, string message) => + logger.LogInformation("{Message}", message); +} diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index 233d756..6882a09 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1112,7 +1112,7 @@ public void DotNetGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCas } /// - /// Validates that the combined collision page contains H4 headings for both the + /// Validates that the combined collision page contains H2 headings for both the /// field name and the property Name. /// [Fact] @@ -1129,13 +1129,285 @@ public void DotNetGenerator_Generate_CaseCollisionClass_CombinedPageContainsBoth Assert.True(factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/CaseCollisionClass/name")); var writer = factory.Writers["ApiMark.DotNet.Fixtures/CaseCollisionClass/name"]; - // Assert: both members appear as distinct H4 headings on the combined page - var level4Headings = writer.Operations + // Assert: both members appear as distinct H2 headings on the combined page + var level2Headings = writer.Operations .OfType() - .Where(h => h.Level == 4) + .Where(h => h.Level == 2) .Select(h => h.Text) .ToList(); - Assert.Contains(level4Headings, h => h.StartsWith("name", StringComparison.Ordinal)); - Assert.Contains(level4Headings, h => h.StartsWith("Name", StringComparison.Ordinal)); + Assert.Contains(level2Headings, h => h.StartsWith("name", StringComparison.Ordinal)); + Assert.Contains(level2Headings, h => h.StartsWith("Name", StringComparison.Ordinal)); + } + + /// + /// Validates that a class implementing an interface shows the interface name in its + /// type signature code block so readers can see the inheritance relationship at a + /// glance without opening the source file. + /// + [Fact] + public void DotNetGenerator_Generate_SampleImplementation_TypeSignatureShowsInterface() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new DotNetGenerator(BuildOptions()); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // Assert: SampleImplementation type page must exist + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/SampleImplementation"), + "Expected type page for SampleImplementation"); + + // Assert: the signature code block must include ": ISampleInterface" so readers + // can see the interface contract without navigating to the source file + var writer = factory.Writers["ApiMark.DotNet.Fixtures/SampleImplementation"]; + var signature = writer.Operations.OfType().FirstOrDefault(); + Assert.NotNull(signature); + Assert.Contains(": ISampleInterface", signature.Code, StringComparison.Ordinal); + } + + /// + /// Validates that an enum type signature does not include a base class (such as + /// System.Enum) so that well-known implicit bases are suppressed and the + /// signature remains clean and readable. + /// + [Fact] + public void DotNetGenerator_Generate_EnumTypeSignature_HasNoBaseClass() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new DotNetGenerator(BuildOptions()); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // Assert: SampleStatus enum type page must exist + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/SampleStatus"), + "Expected type page for SampleStatus"); + + // Assert: the signature must not contain ": System.Enum" or any base class annotation — + // well-known implicit enum bases must be suppressed to keep the signature clean + var writer = factory.Writers["ApiMark.DotNet.Fixtures/SampleStatus"]; + var signature = writer.Operations.OfType().FirstOrDefault(); + Assert.NotNull(signature); + Assert.DoesNotContain("System.Enum", signature.Code, StringComparison.Ordinal); + Assert.DoesNotContain(" : ", signature.Code, StringComparison.Ordinal); + } + + /// + /// Validates that a method returning an intra-assembly type emits a Markdown link in + /// the Returns column of the type page's Methods table. + /// + [Fact] + public void DotNetGenerator_Generate_IntraAssemblyReturnType_EmitsMarkdownLinkInReturnsCell() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new DotNetGenerator(BuildOptions()); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // Assert: TypeLinkFixture type page must exist + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/TypeLinkFixture"), + "Expected type page for TypeLinkFixture"); + + // Assert: the Methods table on the TypeLinkFixture page must have a Returns cell containing + // a Markdown link to SampleClass — the return type of GetSampleClass() + var writer = factory.Writers["ApiMark.DotNet.Fixtures/TypeLinkFixture"]; + var operations = writer.Operations.ToList(); + var methodsIndex = operations.FindIndex(op => op is HeadingOperation h && h.Text == "Methods"); + Assert.True(methodsIndex >= 0, "Expected 'Methods' heading on TypeLinkFixture page"); + var methodsTable = operations.Skip(methodsIndex + 1).OfType().First(); + + // Returns column (index 1) for GetSampleClass() must contain a Markdown link, not plain text + var row = methodsTable.Rows.FirstOrDefault(r => r[0].Contains("GetSampleClass", StringComparison.Ordinal)); + Assert.NotNull(row); + Assert.Contains("[SampleClass]", row[1], StringComparison.Ordinal); + Assert.Contains("SampleClass.md", row[1], StringComparison.Ordinal); + } + + /// + /// Validates that a method accepting an external non-System type causes an "External Types" + /// section to appear at the bottom of the member's detail page. + /// + [Fact] + public void DotNetGenerator_Generate_ExternalNonSystemParameterType_EmitsExternalTypesSection() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new DotNetGenerator(BuildOptions()); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // Assert: Log member page must exist + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/TypeLinkFixture/Log"), + "Expected member page for TypeLinkFixture.Log"); + + // Assert: the member page must contain an "External Types" heading because + // ILogger is from Microsoft.Extensions.Logging (non-System namespace) + var writer = factory.Writers["ApiMark.DotNet.Fixtures/TypeLinkFixture/Log"]; + var headings = writer.Operations.OfType().Select(h => h.Text).ToList(); + Assert.Contains("External Types", headings, StringComparer.Ordinal); + + // Assert: the External Types table must list ILogger with the Microsoft.Extensions.Logging namespace + var externalTable = writer.Operations + .OfType() + .FirstOrDefault(t => t.Headers.Length == 2 && t.Headers[0] == "Type" && t.Headers[1] == "Namespace"); + Assert.NotNull(externalTable); + Assert.Contains( + externalTable!.Rows, + row => row[0].Contains("ILogger", StringComparison.Ordinal) && + row[1].Contains("Microsoft.Extensions.Logging", StringComparison.Ordinal)); + } + + /// + /// Validates that a delegate type produces a page with a public delegate + /// signature rather than public sealed class, using the parameter list + /// from the compiler-injected Invoke method. + /// + [Fact] + public void DotNetGenerator_Generate_DelegateType_EmitsDelegateSignature() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new DotNetGenerator(BuildOptions()); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // Assert: type page must exist + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/ServiceEvent"), + "Expected type page for ServiceEvent delegate"); + + var writer = factory.Writers["ApiMark.DotNet.Fixtures/ServiceEvent"]; + var signature = writer.Operations.OfType().FirstOrDefault(); + Assert.NotNull(signature); + + // Must use the delegate keyword, not sealed class + Assert.Contains("public delegate", signature.Code, StringComparison.Ordinal); + Assert.DoesNotContain("sealed class", signature.Code, StringComparison.Ordinal); + + // Must include the return type and each parameter + Assert.Contains("void", signature.Code, StringComparison.Ordinal); + Assert.Contains("DateTime timestamp", signature.Code, StringComparison.Ordinal); + Assert.Contains("string service", signature.Code, StringComparison.Ordinal); + Assert.Contains("object[] arguments", signature.Code, StringComparison.Ordinal); + } + + /// + /// Validates that a delegate type page contains no member tables — the + /// compiler-injected Invoke, BeginInvoke, EndInvoke, and + /// synthetic constructor must not appear in the output. + /// + [Fact] + public void DotNetGenerator_Generate_DelegateType_HasNoMemberTables() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new DotNetGenerator(BuildOptions()); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // Assert: type page must exist + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/ServiceEvent"), + "Expected type page for ServiceEvent delegate"); + + var writer = factory.Writers["ApiMark.DotNet.Fixtures/ServiceEvent"]; + + // No table rows must mention compiler-injected member names + var allTableText = writer.Operations + .OfType() + .SelectMany(t => t.Rows) + .SelectMany(r => r) + .ToList(); + + Assert.DoesNotContain(allTableText, cell => + cell.Contains("Invoke", StringComparison.Ordinal) || + cell.Contains("BeginInvoke", StringComparison.Ordinal) || + cell.Contains("EndInvoke", StringComparison.Ordinal)); + + // No member detail pages must be created for delegate synthetic members + Assert.False( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/ServiceEvent/Invoke"), + "Delegate Invoke method must not produce a detail page"); + } + + /// + /// Validates that a generic delegate type signature includes the type parameter list, + /// e.g. public delegate TResult SampleTransform<TInput, TResult>(TInput input). + /// + [Fact] + public void DotNetGenerator_Generate_GenericDelegateType_IncludesTypeParameters() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new DotNetGenerator(BuildOptions()); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // Assert: type page must exist (arity flattened: SampleTransform`2 → SampleTransform2) + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/SampleTransform2"), + "Expected type page for SampleTransform generic delegate"); + + var writer = factory.Writers["ApiMark.DotNet.Fixtures/SampleTransform2"]; + var signature = writer.Operations.OfType().FirstOrDefault(); + Assert.NotNull(signature); + + Assert.Contains("public delegate", signature.Code, StringComparison.Ordinal); + Assert.Contains("TResult", signature.Code, StringComparison.Ordinal); + Assert.Contains("TInput", signature.Code, StringComparison.Ordinal); + Assert.Contains("TInput input", signature.Code, StringComparison.Ordinal); + } + + /// + /// Validates that a method returning string[]? (a nullable array reference) + /// renders the return type as string[]? — not string[] — in both the + /// type page's Methods table and the member's detail page signature. + /// + [Fact] + public void DotNetGenerator_Generate_NullableArrayReturnType_RendersWithQuestionMark() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new DotNetGenerator(BuildOptions()); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // Assert: ArrayAndNullableClass type page must exist + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/ArrayAndNullableClass"), + "Expected type page for ArrayAndNullableClass"); + + // Assert: the Methods table on the type page shows string[]? in the Returns column + var typePage = factory.Writers["ApiMark.DotNet.Fixtures/ArrayAndNullableClass"]; + var methodsTable = typePage.Operations + .OfType() + .FirstOrDefault(t => t.Headers.Contains("Returns", StringComparer.Ordinal)); + Assert.NotNull(methodsTable); + var nullableRow = methodsTable.Rows.FirstOrDefault( + r => r[0].Contains("GetNullableNames", StringComparison.Ordinal)); + Assert.NotNull(nullableRow); + Assert.Contains("string[]?", nullableRow[1], StringComparison.Ordinal); + + // Assert: the member detail page signature also shows string[]? + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/ArrayAndNullableClass/GetNullableNames"), + "Expected detail page for GetNullableNames"); + var detailPage = factory.Writers["ApiMark.DotNet.Fixtures/ArrayAndNullableClass/GetNullableNames"]; + var signature = detailPage.Operations.OfType().FirstOrDefault(); + Assert.NotNull(signature); + Assert.Contains("string[]?", signature.Code, StringComparison.Ordinal); } } diff --git a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs index b87182d..1f542bc 100644 --- a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs +++ b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs @@ -79,9 +79,10 @@ public void ApiMarkTask_DotNet_SpawnsToolWithCorrectAssemblyAndXmlDocArguments() } /// - /// Validates that produces an argument string - /// containing the --includes flag with include paths converted from the - /// MSBuild semicolon-separated format to the comma-separated format expected by the tool. + /// Validates that emits a separate + /// --includes flag for each path in + /// when the property is semicolon-separated, rather than joining them into a single + /// comma-separated value. /// [Fact] public void ApiMarkTask_Cpp_SpawnsToolWithCorrectIncludePathArguments() @@ -97,9 +98,15 @@ public void ApiMarkTask_Cpp_SpawnsToolWithCorrectIncludePathArguments() // Act var args = task.BuildArguments("cpp"); - // Assert: the --includes flag must appear and semicolons must be converted to commas - Assert.Contains("--includes", args); - Assert.Contains("/include1,/include2", args); + // Assert: each path must appear as a separate --includes pair + var argList = args.ToList(); + var firstIdx = argList.IndexOf("--includes"); + var lastIdx = argList.LastIndexOf("--includes"); + Assert.True(firstIdx >= 0, "--includes must be present"); + Assert.NotEqual(firstIdx, lastIdx); + Assert.Equal("/include1", argList[firstIdx + 1]); + Assert.Equal("/include2", argList[lastIdx + 1]); + Assert.DoesNotContain("/include1,/include2", args); Assert.DoesNotContain("/include1;/include2", args); } @@ -377,5 +384,38 @@ public void ApiMarkTask_BuildArguments_LibraryDescriptionWithDoubleQuote_PassedV // so the caller must not apply any backslash escaping Assert.Contains("My library (v\"2\")", args); } + + /// + /// Validates that emits a separate + /// --api-headers flag for each pattern in , + /// preserving order and forwarding !-prefixed exclusion patterns verbatim. + /// + [Fact] + public void ApiMarkTask_Cpp_ApiHeaders_ForwardedAsIndividualFlags() + { + // Arrange: two patterns — a catch-all followed by an exclusion pattern + var task = new ApiMarkTask + { + ProjectExtension = ".vcxproj", + ToolDllPath = "dummy.dll", + ApiMarkIncludePaths = "/include", + ApiMarkApiHeaders = "**/*.h;!**/detail/**", + }; + + // Act + var args = task.BuildArguments("cpp"); + + // Assert: each pattern must appear as its own --api-headers pair in order + var argList = args.ToList(); + var firstIdx = argList.IndexOf("--api-headers"); + var lastIdx = argList.LastIndexOf("--api-headers"); + Assert.True(firstIdx >= 0, "--api-headers must be present"); + Assert.NotEqual(firstIdx, lastIdx); + Assert.Equal("**/*.h", argList[firstIdx + 1]); + + // The exclusion pattern must be forwarded verbatim with the ! prefix intact + Assert.Equal("!**/detail/**", argList[lastIdx + 1]); + Assert.DoesNotContain("**/*.h;!**/detail/**", args); + } } diff --git a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs index b0b78c0..660a71f 100644 --- a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs +++ b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs @@ -251,37 +251,36 @@ public void Context_Create_WithResultsOption_SetsResultsFile(string flag) } /// - /// Validates that --includes sets the Includes property to the comma-split path array. + /// Validates that --includes with a single path sets the + /// property to a one-element array containing that path. /// [Fact] public void Context_Create_WithIncludesOption_SetsIncludes() { - // Arrange: supply a comma-separated list of include directory paths - var args = new[] { "--includes", "path/a,path/b" }; + // Arrange: supply a single plain directory path via --includes + var args = new[] { "--includes", "path/a" }; // Act using var context = Context.Create(args); - // Assert: Includes property must contain the two split paths - string[] expectedIncludes = ["path/a", "path/b"]; + // Assert: Includes property must contain the single supplied path + string[] expectedIncludes = ["path/a"]; Assert.Equal(expectedIncludes, context.Includes); } /// - /// Validates that --includes trims whitespace and removes empty entries produced - /// by trailing commas or extra spaces in the comma-separated list. + /// Validates that repeated --includes flags accumulate all paths in order. /// [Fact] - public void Context_Create_WithIncludesOption_TrimsWhitespaceAndRemovesEmptyEntries() + public void Context_Create_WithRepeatedIncludesFlags_AccumulatesAllPathsInOrder() { - // Arrange: a comma-separated list with surrounding whitespace and an empty entry - var args = new[] { "--includes", " path/a , path/b , , path/c " }; + // Arrange: three separate --includes flags, each with a plain path + var args = new[] { "--includes", "path/a", "--includes", "path/b", "--includes", "path/c" }; // Act using var context = Context.Create(args); - // Assert: Includes must contain only the three non-empty trimmed paths, - // with no empty strings from the trailing comma or surrounding whitespace + // Assert: all three paths must appear in Includes in the supplied order string[] expectedIncludes = ["path/a", "path/b", "path/c"]; Assert.Equal(expectedIncludes, context.Includes); } @@ -313,6 +312,7 @@ public void Context_Create_WithNoArguments_HasDefaultValues() () => Assert.False(context.IncludeObsolete), () => Assert.Equal(1, context.HeadingDepth), () => Assert.Empty(context.Includes), + () => Assert.Empty(context.ApiHeaders), () => Assert.Equal(0, context.ExitCode)); } @@ -397,4 +397,97 @@ public void Context_Cli_ParsesAllGlobalFlags() () => Assert.True(context.Silent), () => Assert.True(context.Validate)); } + + /// + /// Validates that repeated --includes flags accumulate into the + /// array in order. + /// + [Fact] + public void Context_Create_WithRepeatedIncludes_AccumulatesAllPaths() + { + // Arrange: supply --includes twice, each with one plain directory path + var args = new[] { "--includes", "/usr/include", "--includes", "/opt/include" }; + + // Act + using var context = Context.Create(args); + + // Assert: both paths must appear in Includes in the order they were given + Assert.Equal(["/usr/include", "/opt/include"], context.Includes); + Assert.Empty(context.ApiHeaders); + } + + /// + /// Validates that repeated --api-headers flags accumulate into the + /// array in order, preserving ! exclusion patterns. + /// + [Fact] + public void Context_Create_WithRepeatedApiHeaders_AccumulatesAllPatternsInOrder() + { + // Arrange: supply --api-headers three times to establish an include/exclude/re-include sequence + var args = new[] + { + "--api-headers", "**/*", + "--api-headers", "!**/detail/**", + "--api-headers", "**/detail/public_api.h", + }; + + // Act + using var context = Context.Create(args); + + // Assert: all three patterns must appear in ApiHeaders in the supplied order + string[] expected = ["**/*", "!**/detail/**", "**/detail/public_api.h"]; + Assert.Equal(expected, context.ApiHeaders); + } + + /// + /// Validates that a !-prefixed exclusion pattern is forwarded verbatim through + /// --api-headers without stripping the ! prefix. + /// + [Fact] + public void Context_Create_WithApiHeadersExclusionPattern_ForwardsVerbatim() + { + // Arrange: supply an exclusion pattern via --api-headers + var args = new[] { "--api-headers", "!**/internal/**" }; + + // Act + using var context = Context.Create(args); + + // Assert: the '!' prefix must be preserved so CppGenerator can apply gitignore semantics + Assert.Equal(["!**/internal/**"], context.ApiHeaders); + } + + /// + /// Validates that --includes no longer accepts comma-separated lists and each + /// invocation appends exactly one plain directory path. + /// + [Fact] + public void Context_Create_WithSingleInclude_SetsSinglePath() + { + // Arrange: supply a single plain directory path via --includes + var args = new[] { "--includes", "/usr/include" }; + + // Act + using var context = Context.Create(args); + + // Assert: exactly one path must be in Includes with no ApiHeaders + Assert.Equal(["/usr/include"], context.Includes); + Assert.Empty(context.ApiHeaders); + } + + /// + /// Validates that defaults + /// to an empty array when no --api-headers flags are supplied. + /// + [Fact] + public void Context_Create_WithNoArguments_HasEmptyApiHeaders() + { + // Arrange: empty argument array + var args = Array.Empty(); + + // Act + using var context = Context.Create(args); + + // Assert: ApiHeaders must default to empty when not specified + Assert.Empty(context.ApiHeaders); + } } diff --git a/test/ApiMark.Tool.Tests/ProgramTests.cs b/test/ApiMark.Tool.Tests/ProgramTests.cs index ed50baf..ead6b81 100644 --- a/test/ApiMark.Tool.Tests/ProgramTests.cs +++ b/test/ApiMark.Tool.Tests/ProgramTests.cs @@ -343,4 +343,99 @@ public void Program_Main_WithHelpAfterSubcommand_PrintsHelpAndExitsZero() Console.SetOut(originalOut); } } + + /// + /// Validates that the removed --search-paths flag is no longer recognized and + /// produces an "Unsupported argument" diagnostic on stderr. + /// + [Fact] + public void Program_Main_CppWithSearchPathsFlag_ReturnsNonZeroExitCode() + { + // Arrange: provide --search-paths which was removed in the redesign + var outputDir = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + var originalError = Console.Error; + using var errorWriter = new StringWriter(); + + try + { + Console.SetError(errorWriter); + + // Act + var exitCode = Program.Main(["cpp", "--search-paths", "/sdk", "--output", outputDir]); + + // Assert: must fail with the unsupported-argument diagnostic so the test + // confirms the specific flag was rejected rather than some other failure + Assert.NotEqual(0, exitCode); + Assert.Contains("--search-paths", errorWriter.ToString(), StringComparison.Ordinal); + } + finally + { + Console.SetError(originalError); + } + } + + /// + /// Validates that the --api-headers flag is recognized by the parser and + /// does not cause an argument exception; when --includes is absent the tool + /// still fails with the expected includes-required diagnostic. + /// + [Fact] + public void Program_Main_CppWithApiHeadersFlag_FlagIsAccepted() + { + // Arrange: provide --api-headers but omit --includes so parsing completes but validation fails + var outputDir = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + var originalError = Console.Error; + using var errorWriter = new StringWriter(); + + try + { + Console.SetError(errorWriter); + + // Act + var exitCode = Program.Main(["cpp", "--api-headers", "**/*.h", "--output", outputDir]); + + // Assert: fails due to missing --includes (not due to unknown flag), + // confirming --api-headers was accepted by the parser + Assert.NotEqual(0, exitCode); + Assert.Contains("--includes", errorWriter.ToString(), StringComparison.Ordinal); + } + finally + { + Console.SetError(originalError); + } + } + + /// + /// Validates that the removed --include-patterns flag is no longer recognized + /// and produces an "Unsupported argument" diagnostic on stderr. + /// + [Fact] + public void Program_Main_CppWithIncludePatternsFlag_ReturnsNonZeroExitCode() + { + // Arrange: provide --include-patterns which was removed in the redesign + var outputDir = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + var originalError = Console.Error; + using var errorWriter = new StringWriter(); + + try + { + Console.SetError(errorWriter); + + // Act + var exitCode = Program.Main([ + "cpp", + "--include-patterns", "*.h", + "--output", outputDir, + ]); + + // Assert: must fail with the unsupported-argument diagnostic so the test + // confirms the specific flag was rejected rather than some other failure + Assert.NotEqual(0, exitCode); + Assert.Contains("--include-patterns", errorWriter.ToString(), StringComparison.Ordinal); + } + finally + { + Console.SetError(originalError); + } + } }