From 2e81a4faeba7d551a61947e5eda63ecdf01ad4d1 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 10:25:48 -0400 Subject: [PATCH 01/31] Show direct base classes and interfaces in type signatures DotNetGenerator.BuildTypeSignature now appends ': BaseClass, IInterface' for classes/interfaces/structs that have direct base types or interfaces, skipping System.Object, System.ValueType, System.Enum, and System.MulticastDelegate. CppGenerator class signatures now include direct base classes from CppClass.BaseTypes. ClangAstParser updated to read base class information from the top-level 'bases' array introduced in clang 18 (in addition to the legacy CXXBaseSpecifier nodes in 'inner' for backward compatibility with older clang versions). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp/cpp-generator.md | 8 +-- .../api-mark-dot-net/dot-net-generator.md | 18 ++++++ .../reqstream/api-mark-cpp/cpp-generator.yaml | 13 +++++ .../api-mark-dot-net/dot-net-generator.yaml | 12 ++++ src/ApiMark.Cpp/CppAst/ClangAstParser.cs | 30 +++++++++- src/ApiMark.Cpp/CppGenerator.cs | 18 +++--- src/ApiMark.DotNet/DotNetGenerator.cs | 37 +++++++++++-- test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 27 +++++++++ .../SampleImplementation.cs | 11 ++++ .../DotNetGeneratorTests.cs | 55 +++++++++++++++++++ 10 files changed, 211 insertions(+), 18 deletions(-) create mode 100644 test/ApiMark.DotNet.Fixtures/SampleImplementation.cs diff --git a/docs/design/api-mark-cpp/cpp-generator.md b/docs/design/api-mark-cpp/cpp-generator.md index ae1fec3..e329278 100644 --- a/docs/design/api-mark-cpp/cpp-generator.md +++ b/docs/design/api-mark-cpp/cpp-generator.md @@ -148,10 +148,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. 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..8c06afc 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. diff --git a/docs/reqstream/api-mark-cpp/cpp-generator.yaml b/docs/reqstream/api-mark-cpp/cpp-generator.yaml index 969e0a5..baed748 100644 --- a/docs/reqstream/api-mark-cpp/cpp-generator.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-generator.yaml @@ -135,3 +135,16 @@ 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 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..2f73da1 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,15 @@ 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 diff --git a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs index 900927b..b7ffbf3 100644 --- a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs +++ b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs @@ -718,6 +718,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()) @@ -760,8 +782,12 @@ private void ParseClass( } 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(); diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 2828253..ec68583 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -679,9 +679,10 @@ private void WriteTypePage( 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)); } @@ -1520,14 +1521,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) { diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index ae166c3..41de82c 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -269,8 +269,8 @@ private void WriteTypePage( using var typeWriter = factory.CreateMarkdown(namespaceFolderPath, type.Name); typeWriter.WriteHeading(2, 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); @@ -1019,8 +1019,12 @@ private static string BuildMethodId(MethodDefinition method) /// Builds a human-readable C# declaration signature for a type definition. /// 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>, + /// or public class Name : BaseClass, IInterface when direct inheritance is present. + /// + private static string BuildTypeSignature(TypeDefinition type, string contextNamespace) { var keyword = type switch { @@ -1050,6 +1054,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 iface in type.Interfaces) + { + bases.Add(TypeNameSimplifier.Simplify(iface.InterfaceType, contextNamespace)); + } + + if (bases.Count > 0) + { + name = $"{name} : {string.Join(", ", bases)}"; + } + return $"public {classModifier}{keyword} {name}"; } diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 980bc42..5c204f6 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -986,4 +986,31 @@ public void CppGenerator_CheckForErrors_SystemHeaderDiagnostic_RoutesToContextLi line => line.Contains("[CppGenerator] clang:", StringComparison.Ordinal) && line.Contains(systemError, StringComparison.Ordinal)); } + + /// + /// Validates that the type page for Circle contains the base class in its + /// signature block so that AI readers immediately know the inheritance chain without + /// opening the header file. + /// + [Fact] + public void CppGenerator_Generate_InheritanceClass_EmitsBaseClassInSignature() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new CppGenerator(BuildOptions()); + + // Act + generator.Generate(factory, new InMemoryContext()); + + // 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 that + // readers know the inheritance chain without opening the header + 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)); + } } 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.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index 233d756..430ce2c 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1138,4 +1138,59 @@ public void DotNetGenerator_Generate_CaseCollisionClass_CombinedPageContainsBoth Assert.Contains(level4Headings, h => h.StartsWith("name", StringComparison.Ordinal)); Assert.Contains(level4Headings, 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 colon because + /// System.Enum is a well-known implicit base that adds no information for readers. + /// + [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 enum signature must not contain a colon — System.Enum must be + // suppressed because it is an implicit well-known base that adds no information + var writer = factory.Writers["ApiMark.DotNet.Fixtures/SampleStatus"]; + var signature = writer.Operations.OfType().FirstOrDefault(); + Assert.NotNull(signature); + Assert.DoesNotContain(":", signature.Code, StringComparison.Ordinal); + } } From 4ed528c8308c457e8db3a22c1aae9226d8e324f2 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 10:41:05 -0400 Subject: [PATCH 02/31] Update README badges to match project template Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 44168e9..ef4b4cb 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 From b2b7073982c6935b09ea0e146317ec23f3f54019 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 10:45:22 -0400 Subject: [PATCH 03/31] Remove architecture qualifiers from Platform Support table Now using system clang (AnyCPU) rather than libclang native DLL, so architecture-specific entries are no longer needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ef4b4cb..4f95df0 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ type page — consuming only as much context as the task requires. | Platform | .NET | C++ | | --- | --- | --- | -| Windows x64 | ✅ | ✅ | -| Linux x64 | ✅ | ✅ | -| macOS (Apple Silicon) | ✅ | ✅ | +| Windows | ✅ | ✅ | +| Linux | ✅ | ✅ | +| macOS | ✅ | ✅ | ## Prerequisites From f446d4112bf822eaed368ef4ddbcdb444724b9d3 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 10:47:28 -0400 Subject: [PATCH 04/31] Make Features section iconic with emoji icons Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 16 +++++++++------- src/ApiMark.DotNet/DotNetGenerator.cs | 12 ++++++++++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4f95df0..c1bca07 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,15 @@ 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 diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index 41de82c..fa2fb53 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -1018,6 +1018,14 @@ private static string BuildMethodId(MethodDefinition method) } /// 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. + /// /// The type definition to represent. /// Used to simplify base type and interface names in the signature. /// @@ -1069,9 +1077,9 @@ private static string BuildTypeSignature(TypeDefinition type, string contextName } // Include all directly declared interfaces — transitive inheritance is omitted - foreach (var iface in type.Interfaces) + foreach (var interfaceRef in type.Interfaces) { - bases.Add(TypeNameSimplifier.Simplify(iface.InterfaceType, contextNamespace)); + bases.Add(TypeNameSimplifier.Simplify(interfaceRef.InterfaceType, contextNamespace)); } if (bases.Count > 0) From 0aaabdabefd7728c15b671be25f12e00a79ce562 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 11:21:04 -0400 Subject: [PATCH 05/31] Cache CppGenerator clang runs in IClassFixture to speed up tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/ApiMark.Cpp.Tests/CppGeneratorFixture.cs | 123 ++++++++ test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 263 ++++-------------- 2 files changed, 184 insertions(+), 202 deletions(-) create mode 100644 test/ApiMark.Cpp.Tests/CppGeneratorFixture.cs 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 5c204f6..5827231 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -5,9 +5,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 +124,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 +135,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 +148,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 +165,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 +178,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 +191,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 +204,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 +220,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 +233,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 +246,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 +262,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 @@ -309,11 +284,7 @@ public void CppGenerator_Generate_SampleClass_TypePage_MethodRowShowsParameterTy public void CppGenerator_Generate_MemberPage_UsesH3Heading() { // 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 open with an H3 heading containing the // class and method name so the heading level matches combined member pages @@ -328,11 +299,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 +322,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 +342,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 +355,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 +368,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 +381,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 +394,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 +407,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 +423,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 +436,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( @@ -518,11 +449,7 @@ public void CppGenerator_Generate_InheritanceClass_CreatesTypePage() 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 +462,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 +478,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 +494,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 +510,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 +526,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 @@ -636,11 +543,7 @@ public void CppGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedP 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")); @@ -664,11 +567,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 +586,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 +605,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 +624,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 @@ -819,11 +706,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 +729,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 +750,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 +768,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 +791,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 +814,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 @@ -996,11 +859,7 @@ public void CppGenerator_CheckForErrors_SystemHeaderDiagnostic_RoutesToContextLi public void CppGenerator_Generate_InheritanceClass_EmitsBaseClassInSignature() { // Arrange - var factory = new InMemoryMarkdownWriterFactory(); - var generator = new CppGenerator(BuildOptions()); - - // Act - generator.Generate(factory, new InMemoryContext()); + var factory = _fixture.PublicFactory; // Assert: the Circle type page must exist Assert.True(factory.Writers.ContainsKey("fixtures/Circle"), "Expected type page for Circle"); From c54b26551036fab2429a92342b556ed26ed04c21 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 12:03:21 -0400 Subject: [PATCH 06/31] Add intra-doc links and External Types section for C# and C++ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 4 + docs/design/api-mark-cpp/cpp-generator.md | 38 +++ .../api-mark-dot-net/dot-net-generator.md | 35 +++ .../reqstream/api-mark-cpp/cpp-generator.yaml | 19 ++ .../api-mark-dot-net/dot-net-generator.yaml | 19 ++ src/ApiMark.Cpp/CppExternalTypeInfo.cs | 48 +++ src/ApiMark.Cpp/CppGenerator.cs | 185 ++++++++--- src/ApiMark.Cpp/CppTypeLinkResolver.cs | 245 +++++++++++++++ src/ApiMark.DotNet/DotNetGenerator.cs | 151 ++++++--- src/ApiMark.DotNet/ExternalTypeInfo.cs | 48 +++ src/ApiMark.DotNet/TypeLinkResolver.cs | 294 ++++++++++++++++++ src/ApiMark.DotNet/TypeNameSimplifier.cs | 2 +- .../include/fixtures/TypeLinkClass.h | 18 ++ test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 56 +++- .../ApiMark.DotNet.Fixtures.csproj | 3 + .../TypeLinkFixture.cs | 23 ++ .../DotNetGeneratorTests.cs | 67 +++- 17 files changed, 1157 insertions(+), 98 deletions(-) create mode 100644 src/ApiMark.Cpp/CppExternalTypeInfo.cs create mode 100644 src/ApiMark.Cpp/CppTypeLinkResolver.cs create mode 100644 src/ApiMark.DotNet/ExternalTypeInfo.cs create mode 100644 src/ApiMark.DotNet/TypeLinkResolver.cs create mode 100644 test/ApiMark.Cpp.Fixtures/include/fixtures/TypeLinkClass.h create mode 100644 test/ApiMark.DotNet.Fixtures/TypeLinkFixture.cs diff --git a/.cspell.yaml b/.cspell.yaml index 39b5aa5..ac5d1fe 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -24,12 +24,16 @@ words: - fileassert - frontmatter - GHDL + - hrefs - isysroot - isystem - libc - libclang - libclangsharp - libext + - Linkification + - Linkify + - linkify - misattributed - msbuild - MSBuild diff --git a/docs/design/api-mark-cpp/cpp-generator.md b/docs/design/api-mark-cpp/cpp-generator.md index e329278..5bbe537 100644 --- a/docs/design/api-mark-cpp/cpp-generator.md +++ b/docs/design/api-mark-cpp/cpp-generator.md @@ -261,6 +261,44 @@ 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`). + ### Error Handling CppGenerator throws `DirectoryNotFoundException` when a path in 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 8c06afc..ca6f00c 100644 --- a/docs/design/api-mark-dot-net/dot-net-generator.md +++ b/docs/design/api-mark-dot-net/dot-net-generator.md @@ -138,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/reqstream/api-mark-cpp/cpp-generator.yaml b/docs/reqstream/api-mark-cpp/cpp-generator.yaml index baed748..99ac203 100644 --- a/docs/reqstream/api-mark-cpp/cpp-generator.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-generator.yaml @@ -148,3 +148,22 @@ sections: 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-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 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 2f73da1..5ab64b5 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml @@ -97,3 +97,22 @@ sections: 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/src/ApiMark.Cpp/CppExternalTypeInfo.cs b/src/ApiMark.Cpp/CppExternalTypeInfo.cs new file mode 100644 index 0000000..3ae6e06 --- /dev/null +++ b/src/ApiMark.Cpp/CppExternalTypeInfo.cs @@ -0,0 +1,48 @@ +// 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) => + StringComparer.Ordinal.Compare(TypeString, other?.TypeString); + + /// 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 ec68583..009f44b 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -114,6 +114,18 @@ 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. + var knownTypes = namespaceDecls.SelectMany(kv => + { + var nsDisplay = kv.Key.Replace(".", "::", StringComparison.Ordinal); + var nsPath = kv.Key.Replace(".", "/", StringComparison.Ordinal); + return kv.Value.Classes.Select(cls => (Key: $"{nsDisplay}::{cls.Name}", Value: $"{nsPath}/{cls.Name}")) + .Concat(kv.Value.Enums.Select(enm => (Key: $"{nsDisplay}::{enm.Name}", Value: $"{nsPath}/{enm.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 +133,10 @@ 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); } // Partition free functions into regular functions and operator overloads; @@ -137,12 +149,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 @@ -551,14 +563,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) { @@ -609,7 +626,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 +645,8 @@ private static void WriteNamespacePage( new[] { "Operators", DescriptionColumnHeader }, new[] { new[] { $"[operators]({nsKey}/operators.md)", "Operator overloads" } }); } + + WriteExternalTypesSection(writer, externalTypes); } /// @@ -641,11 +663,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); @@ -767,6 +791,9 @@ private void WriteTypePage( var methodRows = new List(); var fieldRows = new List(); + // Accumulate external type references found in type-column cells on this page + var externalTypes = new SortedSet(); + // Process constructors — emitted first because instantiation is the first thing // a consumer needs to understand about a type foreach (var ctor in visibleCtors) @@ -791,11 +818,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); } } @@ -810,17 +837,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); } } @@ -840,17 +869,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); } } @@ -881,12 +912,14 @@ 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); + WriteClassOperatorsPage(factory, nsKey, nsDisplayName, cls, operatorMethods, cppResolver); writer.WriteHeading(3, "Operators"); writer.WriteTable( new[] { "Operators", DescriptionColumnHeader }, new[] { new[] { $"[operators]({cls.Name}/operators.md)", "Operator overloads" } }); } + + WriteExternalTypesSection(writer, externalTypes); } /// @@ -910,14 +943,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 @@ -934,13 +970,17 @@ private void WriteClassOperatorsPage( 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); } + + WriteExternalTypesSection(writer, externalTypes); } /// @@ -965,12 +1005,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"); @@ -989,13 +1032,17 @@ private void WriteNamespaceOperatorsPage( 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); } + + WriteExternalTypesSection(writer, externalTypes); } /// @@ -1014,15 +1061,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); } /// @@ -1045,10 +1097,16 @@ 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. private void WriteFreeFunctionContent( IMarkdownWriter writer, string nsDisplayName, - CppFunction fn) + CppFunction fn, + CppTypeLinkResolver cppResolver, + string currentFolder, + ISet externalTypes) { // 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 @@ -1083,8 +1141,10 @@ private void WriteFreeFunctionContent( { writer.WriteHeading(4, "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); } @@ -1113,27 +1173,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); } /// @@ -1147,14 +1214,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); + WriteFunctionContent(writer, nsDisplayName, className, method, cppResolver, currentFolder, externalTypes); } /// @@ -1173,11 +1246,17 @@ 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. private static void WriteFunctionContent( IMarkdownWriter writer, string nsDisplayName, string className, - CppFunction method) + CppFunction method, + CppTypeLinkResolver cppResolver, + string currentFolder, + ISet externalTypes) { // 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 @@ -1203,8 +1282,10 @@ private static void WriteFunctionContent( { writer.WriteHeading(4, "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); } @@ -1664,20 +1745,26 @@ 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); + // Accumulate external types across all members on this shared page + var externalTypes = new SortedSet(); + foreach (var member in members) { switch (member) @@ -1690,7 +1777,7 @@ private static void WriteCombinedMemberPage( ", ", fn.Parameters.Select(p => SimplifyTypeName(p.TypeName))); writer.WriteHeading(4, $"{fn.Name}({fnParamTypes}) ({fnKind})"); - WriteFunctionContent(writer, nsDisplayName, cls.Name, fn); + WriteFunctionContent(writer, nsDisplayName, cls.Name, fn, cppResolver, combinedCurrentFolder, externalTypes); break; case CppField field: @@ -1699,6 +1786,8 @@ private static void WriteCombinedMemberPage( break; } } + + WriteExternalTypesSection(writer, externalTypes); } /// @@ -1724,6 +1813,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 // ========================================================================= diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs new file mode 100644 index 0000000..9847878 --- /dev/null +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -0,0 +1,245 @@ +// 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 + if (stripped.StartsWith("std::", StringComparison.Ordinal) || + cppTypeString.Contains("std::")) + { + 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: emit a relative Markdown link using the stripped (short) name + var from = currentFolder.Length > 0 ? currentFolder : "."; + var relativePath = Path.GetRelativePath(from, pageKey + ".md").Replace('\\', '/'); + return $"[{stripped}]({relativePath})"; + } + + // External type with a namespace: track for the External Types section + var ns = ExtractNamespace(stripped); + if (!string.IsNullOrEmpty(ns) && !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: match when the stripped value is the unqualified part of a known name + foreach (var (knownQualified, knownKey) in _knownTypes) + { + var lastSep = knownQualified.LastIndexOf("::", StringComparison.Ordinal); + var shortName = lastSep >= 0 ? knownQualified[(lastSep + 2)..] : knownQualified; + if (shortName == stripped) + { + return knownKey; + } + } + + return null; + } + + /// + /// 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 fa2fb53..7e6cdc7 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); @@ -243,7 +247,7 @@ private void WriteNamespacePage( foreach (var type in nsTypes) { - WriteTypePage(factory, namespaceName, folderPath, type, xmlDocs); + WriteTypePage(factory, namespaceName, folderPath, type, xmlDocs, resolver); } } @@ -259,12 +263,14 @@ 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); @@ -323,6 +329,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 +345,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) + : string.Empty; var memberDisplayName = GetMemberDisplayName(member); var sanitizedName = GetSanitizedMemberFileName(member, type); var memberPageLink = $"{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 +368,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 +405,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) + : string.Empty; var overloadDisplayName = GetMethodGroupDisplayName(representative, orderedOverloads.Count); var overloadFileName = GetSanitizedMemberFileName(representative, type); var memberLink = $"{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) { @@ -418,14 +433,17 @@ private void WriteTypePage( 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) + : string.Empty; var memberDisplayName = GetMemberDisplayName(member); switch (member) @@ -490,6 +508,9 @@ private void WriteTypePage( typeWriter.WriteHeading(3, "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 +527,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 +535,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}/{type.Name}"; + using var memberWriter = factory.CreateMarkdown(memberCurrentFolder, sanitizedName); var displayName = GetMemberDisplayName(member); memberWriter.WriteHeading(3, 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 +595,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}/{type.Name}"; + using var memberWriter = factory.CreateMarkdown(overloadCurrentFolder, sanitizedName); memberWriter.WriteHeading(3, 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)); + WriteMethodDocumentation(memberWriter, namespaceName, overload, xmlDocs, BuildMemberId(overload), resolver, overloadCurrentFolder, externalTypes); } + + WriteExternalTypesSection(memberWriter, externalTypes); } /// @@ -611,6 +644,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,14 +652,19 @@ 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}/{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); + // Accumulate external types across all members on this shared page + var externalTypes = new SortedSet(); + foreach (var member in members) { var displayName = GetMemberDisplayName(member); @@ -637,7 +676,7 @@ private static void WriteCombinedMemberPage( { // 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 +714,8 @@ private static void WriteCombinedMemberPage( } } } + + WriteExternalTypesSection(writer, externalTypes); } /// @@ -733,7 +774,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 +790,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 +839,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) || @@ -1228,22 +1274,53 @@ 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, + }; + + /// + /// 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) { - // 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; + } + + writer.WriteHeading(2, "External Types"); + writer.WriteTable( + ["Type", "Namespace"], + externalTypes.Select(t => new[] { t.SimplifiedName, t.Namespace })); } /// diff --git a/src/ApiMark.DotNet/ExternalTypeInfo.cs b/src/ApiMark.DotNet/ExternalTypeInfo.cs new file mode 100644 index 0000000..e84549d --- /dev/null +++ b/src/ApiMark.DotNet/ExternalTypeInfo.cs @@ -0,0 +1,48 @@ +// 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) => + StringComparer.Ordinal.Compare(SimplifiedName, other?.SimplifiedName); + + /// 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..ec55625 --- /dev/null +++ b/src/ApiMark.DotNet/TypeLinkResolver.cs @@ -0,0 +1,294 @@ +// 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; + } + + // 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 "[]" + if (typeRef is ArrayType arrayType) + { + var elementText = Linkify(arrayType.ElementType, currentFolder, contextNamespace, externalTypes); + return 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.StripArity(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.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..6da94f7 100644 --- a/src/ApiMark.DotNet/TypeNameSimplifier.cs +++ b/src/ApiMark.DotNet/TypeNameSimplifier.cs @@ -124,7 +124,7 @@ 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; 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/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 5827231..1e47ed3 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -851,25 +851,55 @@ public void CppGenerator_CheckForErrors_SystemHeaderDiagnostic_RoutesToContextLi } /// - /// Validates that the type page for Circle contains the base class in its - /// signature block so that AI readers immediately know the inheritance chain without - /// opening the header file. + /// 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_InheritanceClass_EmitsBaseClassInSignature() + public void CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInReturnsCell() { // 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: TypeLinkClass type page must exist + Assert.True( + factory.Writers.ContainsKey("fixtures/TypeLinkClass"), + "Expected type page for TypeLinkClass"); - // Assert: the Circle type page signature must contain ": public Shape" so that - // readers know the inheritance chain without opening the header - 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)); + // 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); } } 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/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 430ce2c..b9a1864 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1168,11 +1168,11 @@ public void DotNetGenerator_Generate_SampleImplementation_TypeSignatureShowsInte } /// - /// Validates that an enum type signature does not include a base class colon because - /// System.Enum is a well-known implicit base that adds no information for readers. + /// 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_EnumTypeSignature_HasNoBaseClass() + public void DotNetGenerator_Generate_IntraAssemblyReturnType_EmitsMarkdownLinkInReturnsCell() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); @@ -1181,16 +1181,59 @@ public void DotNetGenerator_Generate_EnumTypeSignature_HasNoBaseClass() // Act generator.Generate(factory, new InMemoryContext()); - // Assert: SampleStatus enum type page must exist + // Assert: TypeLinkFixture type page must exist Assert.True( - factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/SampleStatus"), - "Expected type page for SampleStatus"); + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/TypeLinkFixture"), + "Expected type page for TypeLinkFixture"); - // Assert: the enum signature must not contain a colon — System.Enum must be - // suppressed because it is an implicit well-known base that adds no information - var writer = factory.Writers["ApiMark.DotNet.Fixtures/SampleStatus"]; - var signature = writer.Operations.OfType().FirstOrDefault(); - Assert.NotNull(signature); - Assert.DoesNotContain(":", signature.Code, StringComparison.Ordinal); + // 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)); } } From e0a52eef729114c3c31fc94d4214531789ebb754 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 12:36:15 -0400 Subject: [PATCH 07/31] Fix GenericParameter types being incorrectly linked in intra-doc resolver Type parameters (T, TKey, TValue) are not real assembly types and must render as plain text rather than generating broken links like [T](../../T.md). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.DotNet/TypeLinkResolver.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ApiMark.DotNet/TypeLinkResolver.cs b/src/ApiMark.DotNet/TypeLinkResolver.cs index ec55625..9674114 100644 --- a/src/ApiMark.DotNet/TypeLinkResolver.cs +++ b/src/ApiMark.DotNet/TypeLinkResolver.cs @@ -118,6 +118,12 @@ public string Linkify( 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)) From 90d17f4c1c0245ce6257ea77c0ec9c478c437339 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 13:03:41 -0400 Subject: [PATCH 08/31] Fix PR review issues: qualifier preservation, CompareTo tie-breaker, heading levels - CppTypeLinkResolver: preserve const/*/& qualifiers around intra-doc links e.g. 'const Shape *' becomes 'const [Shape](path.md) *' not '[Shape](path.md)' - ExternalTypeInfo.CompareTo: add Namespace as tie-breaker so SortedSet retains distinct types that share a short name but differ in namespace - CppExternalTypeInfo.CompareTo: same tie-breaker fix for C++ - WriteExternalTypesSection: accept headingLevel parameter; type pages use H3 (alongside Methods/Properties), member pages use H4 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppExternalTypeInfo.cs | 9 +++++++-- src/ApiMark.Cpp/CppTypeLinkResolver.cs | 9 +++++++-- src/ApiMark.DotNet/DotNetGenerator.cs | 16 ++++++++++------ src/ApiMark.DotNet/ExternalTypeInfo.cs | 9 +++++++-- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/ApiMark.Cpp/CppExternalTypeInfo.cs b/src/ApiMark.Cpp/CppExternalTypeInfo.cs index 3ae6e06..49c19c0 100644 --- a/src/ApiMark.Cpp/CppExternalTypeInfo.cs +++ b/src/ApiMark.Cpp/CppExternalTypeInfo.cs @@ -27,8 +27,13 @@ internal sealed record CppExternalTypeInfo(string TypeString, string Namespace) /// A negative value when this instance sorts before , /// zero when equal, or a positive value when it sorts after. /// - public int CompareTo(CppExternalTypeInfo? other) => - StringComparer.Ordinal.Compare(TypeString, other?.TypeString); + 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) => diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs index 9847878..826738f 100644 --- a/src/ApiMark.Cpp/CppTypeLinkResolver.cs +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -131,10 +131,15 @@ public string Linkify( if (pageKey != null) { - // Intra-library type: emit a relative Markdown link using the stripped (short) name + // 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('\\', '/'); - return $"[{stripped}]({relativePath})"; + 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 diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index 7e6cdc7..1b0adeb 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -510,7 +510,7 @@ private void WriteTypePage( } // Emit the External Types section when any non-standard external types were referenced - WriteExternalTypesSection(typeWriter, externalTypes); + WriteExternalTypesSection(typeWriter, externalTypes, 3); } /// @@ -550,7 +550,7 @@ private static void WriteMemberPage( // 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); + WriteExternalTypesSection(memberWriter, externalTypes, 4); return; } @@ -612,7 +612,7 @@ private static void WriteMethodOverloadPage( WriteMethodDocumentation(memberWriter, namespaceName, overload, xmlDocs, BuildMemberId(overload), resolver, overloadCurrentFolder, externalTypes); } - WriteExternalTypesSection(memberWriter, externalTypes); + WriteExternalTypesSection(memberWriter, externalTypes, 4); } /// @@ -715,7 +715,7 @@ private static void WriteCombinedMemberPage( } } - WriteExternalTypesSection(writer, externalTypes); + WriteExternalTypesSection(writer, externalTypes, 4); } /// @@ -1310,14 +1310,18 @@ private static string GetMemberDisplayName(IMemberDefinition member) /// /// The set of external types accumulated during table row generation. May be empty. /// - private static void WriteExternalTypesSection(IMarkdownWriter writer, SortedSet externalTypes) + /// + /// Heading depth for the "External Types" section heading. Use 3 for type pages + /// (which open with H2) and 4 for member pages (which open with H3). + /// + private static void WriteExternalTypesSection(IMarkdownWriter writer, SortedSet externalTypes, int headingLevel = 3) { if (externalTypes.Count == 0) { return; } - writer.WriteHeading(2, "External Types"); + writer.WriteHeading(headingLevel, "External Types"); writer.WriteTable( ["Type", "Namespace"], externalTypes.Select(t => new[] { t.SimplifiedName, t.Namespace })); diff --git a/src/ApiMark.DotNet/ExternalTypeInfo.cs b/src/ApiMark.DotNet/ExternalTypeInfo.cs index e84549d..9a8e3e1 100644 --- a/src/ApiMark.DotNet/ExternalTypeInfo.cs +++ b/src/ApiMark.DotNet/ExternalTypeInfo.cs @@ -27,8 +27,13 @@ internal sealed record ExternalTypeInfo(string SimplifiedName, string Namespace) /// A negative value when this instance sorts before , /// zero when equal, or a positive value when it sorts after. /// - public int CompareTo(ExternalTypeInfo? other) => - StringComparer.Ordinal.Compare(SimplifiedName, other?.SimplifiedName); + 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) => From e4733f41c492ee45b148e34970efe511aae38cf8 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 15:14:00 -0400 Subject: [PATCH 09/31] Add --search-paths and glob/exclude pattern support to cpp subcommand - --search-paths exposes AdditionalIncludePaths (compiler-only -I paths) - --include-patterns and --exclude-patterns for explicit glob filtering - Inline ! and glob entries in --includes auto-classified into the correct bucket (roots / include-patterns / exclude-patterns) - MSBuild ApiMarkSearchPaths property wires up --search-paths - Same inline classification applied to ApiMarkIncludePaths in MSBuild Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-msbuild/api-mark-task.md | 39 +++-- docs/design/api-mark-tool/cli.md | 6 +- docs/design/api-mark-tool/cli/context.md | 11 +- docs/design/api-mark-tool/program.md | 5 + .../api-mark-msbuild/api-mark-task.yaml | 22 +++ docs/reqstream/api-mark-tool/cli/context.yaml | 28 +++- docs/reqstream/api-mark-tool/program.yaml | 9 +- docs/user_guide/cli-reference.md | 25 ++++ docs/user_guide/msbuild-integration.md | 9 +- src/ApiMark.MSBuild/ApiMarkTask.cs | 73 ++++++++- src/ApiMark.Tool/Cli/Context.cs | 110 +++++++++++++- src/ApiMark.Tool/Program.cs | 6 + .../ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs | 139 ++++++++++++++++++ test/ApiMark.Tool.Tests/Cli/ContextTests.cs | 130 ++++++++++++++++ test/ApiMark.Tool.Tests/ProgramTests.cs | 96 ++++++++++++ 15 files changed, 684 insertions(+), 24 deletions(-) diff --git a/docs/design/api-mark-msbuild/api-mark-task.md b/docs/design/api-mark-msbuild/api-mark-task.md index 0027972..0d20aa8 100644 --- a/docs/design/api-mark-msbuild/api-mark-task.md +++ b/docs/design/api-mark-msbuild/api-mark-task.md @@ -55,9 +55,10 @@ 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 paths. Each entry is classified before forwarding: entries containing +`*` or `?` wildcards are forwarded via `--include-patterns`; entries starting +with `!` are stripped and forwarded via `--exclude-patterns`; plain directory +entries are forwarded via `--includes`. **ApiMarkTask.ApiMarkLibraryName**: `string` — MSBuild property `$(ApiMarkLibraryName)`; for the `cpp` language, the library name used as the @@ -78,6 +79,18 @@ 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 automatically via PATH, +xcrun (macOS), or vswhere (Windows). + +**ApiMarkTask.ApiMarkSearchPaths**: `string` — MSBuild property +`$(ApiMarkSearchPaths)`; for the `cpp` language, a semicolon-separated list +of compiler-only search paths passed to Clang as `-I` flags for `#include` +resolution. Declarations from these paths are never documented. Semicolons are +converted to commas when forwarding to the `--search-paths` argument. Optional +— omitted when empty or not set. + **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 +118,18 @@ 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`, classify each `ApiMarkIncludePaths` entry: wildcard entries +with `*` or `?` are forwarded via `--include-patterns`; `!`-prefixed entries are +stripped and forwarded via `--exclude-patterns`; plain directory entries are +forwarded via `--includes`; 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`; if `ApiMarkSearchPaths` is +set, convert semicolons to commas and append `--search-paths`); 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/cli.md b/docs/design/api-mark-tool/cli.md index 6ade99e..2493df1 100644 --- a/docs/design/api-mark-tool/cli.md +++ b/docs/design/api-mark-tool/cli.md @@ -25,7 +25,8 @@ 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`, + `Assembly`, `XmlDoc`, `Includes`, `SearchPaths`, `IncludePatterns`, + `ExcludePatterns`, `Output`, `Visibility`, `IncludeObsolete`, `ResultsFile`, `HeadingDepth`, `ExitCode`) and provides `WriteLine` and `WriteError` for all program output routing. @@ -43,7 +44,8 @@ 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`) +`--xml-doc`, `--includes`, `--search-paths`, `--include-patterns`, +`--exclude-patterns`, `--output`, `--visibility`, `--include-obsolete`) may appear anywhere in the argument list after the language token is recognized. diff --git a/docs/design/api-mark-tool/cli/context.md b/docs/design/api-mark-tool/cli/context.md index 31abe98..8f658f1 100644 --- a/docs/design/api-mark-tool/cli/context.md +++ b/docs/design/api-mark-tool/cli/context.md @@ -27,7 +27,10 @@ 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 path entries from `--includes` (no wildcards, no `!`) | +| `SearchPaths` | `string[]` | `[]` | Comma-split values from `--search-paths` | +| `IncludePatterns` | `string[]` | `[]` | Glob patterns from `--include-patterns` or wildcard entries in `--includes` | +| `ExcludePatterns` | `string[]` | `[]` | Exclusion patterns from `--exclude-patterns` or `!`-prefixed entries in `--includes` | | `Output` | `string?` | `null` | Directory from `--output` | | `Visibility` | `string` | `"Public"` | Value from `--visibility` | | `IncludeObsolete` | `bool` | `false` | `--include-obsolete` flag | @@ -52,7 +55,11 @@ 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. When processing `--includes`, the private + static `ClassifyIncludeEntries` helper is used to split entries into three + buckets: plain root paths (to `Includes`), wildcard entries (to + `IncludePatterns`), and `!`-prefixed exclusions (stripped and placed in + `ExcludePatterns`). - *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..792cd5f 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`), `AdditionalIncludePaths` + (from `context.SearchPaths`), `IncludePatterns` (from + `context.IncludePatterns`), and `ExcludePatterns` (from + `context.ExcludePatterns`) in addition to the other cpp-specific options. **Program.PrintBanner** (private static): Prints the application banner (tool name, version, copyright line, and a blank line). diff --git a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml index fbb4dcf..a280027 100644 --- a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml +++ b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml @@ -95,3 +95,25 @@ sections: build, parallel to the dotnet behavior when ApiMarkXmlDocPath is absent. tests: - ApiMarkTask_Cpp_EmptyIncludePaths_SkipsExecution + - id: ApiMarkMsbuild-ApiMarkTask-ForwardCppSearchPaths + title: >- + ApiMarkTask shall forward the ApiMarkSearchPaths property to the tool as the --search-paths + argument for C++ builds, converting semicolons to commas. + justification: | + C++ projects need compiler-only include paths for SDK or third-party headers that are + referenced by public headers but should not appear in the generated documentation. + tests: + - ApiMarkTask_Cpp_SearchPaths_ForwardedToTool + - ApiMarkTask_Cpp_SearchPaths_Empty_NotIncludedInArgs + - id: ApiMarkMsbuild-ApiMarkTask-ClassifyIncludePathsIntoThreeBuckets + title: >- + ApiMarkTask shall classify ApiMarkIncludePaths entries into --includes (plain paths), + --include-patterns (glob wildcards), and --exclude-patterns ('!'-prefixed entries). + justification: | + MSBuild property lists use semicolons as separators and may mix plain directory roots + with glob patterns and exclusion entries. The task must classify and route each entry + to the correct tool argument so that the generator receives the right configuration. + tests: + - ApiMarkTask_Cpp_IncludePaths_GlobEntries_ForwardedAsIncludePatterns + - ApiMarkTask_Cpp_IncludePaths_ExcludeEntries_ForwardedAsExcludePatterns + - ApiMarkTask_Cpp_IncludePaths_Mixed_SplitIntoThreeBuckets diff --git a/docs/reqstream/api-mark-tool/cli/context.yaml b/docs/reqstream/api-mark-tool/cli/context.yaml index 9e5fa67..1f5a53d 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, --search-paths, --include-patterns, and --exclude-patterns. 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,32 @@ sections: - Context_Create_WithVisibilityOption_SetsVisibility - Context_Create_WithIncludeObsoleteFlag_SetsIncludeObsoleteTrue - Context_Create_WithIncludesOption_SetsIncludes + - Context_Create_WithSearchPathsOption_SetsSearchPaths + - Context_Create_WithIncludePatternsOption_SetsIncludePatterns + - Context_Create_WithExcludePatternsOption_SetsExcludePatterns + - Context_Create_WithIncludesContainingGlobEntry_ClassifiesAsIncludePattern + - Context_Create_WithIncludesContainingExcludeEntry_ClassifiesAsExcludePattern + - Context_Create_WithIncludesMixed_ClassifiesIntoThreeBuckets + - id: ApiMarkTool-Cli-Context-ParseCppSearchPaths + title: Context shall parse --search-paths and set SearchPaths to the comma-split array. + justification: | + C++ projects need to specify compiler-only -I paths for #include resolution + without those paths contributing to the documented API surface. + tests: + - Context_Create_WithSearchPathsOption_SetsSearchPaths + - id: ApiMarkTool-Cli-Context-ParseIncludeExcludePatterns + title: Context shall parse --include-patterns and --exclude-patterns and classify inline wildcards and '!'-prefixed entries in --includes into the correct buckets. + justification: | + Projects need fine-grained control over which headers contribute to the documented + API surface. Inline classification in --includes provides a concise syntax while + explicit --include-patterns and --exclude-patterns flags allow MSBuild to pass + pre-classified values directly. + tests: + - Context_Create_WithIncludePatternsOption_SetsIncludePatterns + - Context_Create_WithExcludePatternsOption_SetsExcludePatterns + - Context_Create_WithIncludesContainingGlobEntry_ClassifiesAsIncludePattern + - Context_Create_WithIncludesContainingExcludeEntry_ClassifiesAsExcludePattern + - Context_Create_WithIncludesMixed_ClassifiesIntoThreeBuckets - 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..fb5fdb1 100644 --- a/docs/reqstream/api-mark-tool/program.yaml +++ b/docs/reqstream/api-mark-tool/program.yaml @@ -30,15 +30,20 @@ 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, --search-paths, --include-patterns, and --exclude-patterns 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 --search-paths option allows compiler-only -I paths to be + specified without contributing to the documented API surface. The --include-patterns + and --exclude-patterns options provide fine-grained glob filtering of headers. tests: - Program_Main_WithCppSubcommand_MissingIncludes_ReturnsNonZeroExitCode + - Program_Main_CppWithSearchPathsFlag_FlagIsAccepted + - Program_Main_CppWithGlobOnlyIncludes_FailsMissingRootValidation + - Program_Main_CppWithIncludeAndExcludePatternFlags_FlagsAreAccepted - id: ApiMarkTool-Program-SupportHelpOption title: Program shall support -?, -h, and --help options that display usage information and exit zero. justification: | diff --git a/docs/user_guide/cli-reference.md b/docs/user_guide/cli-reference.md index 3c20f7f..d66fb47 100644 --- a/docs/user_guide/cli-reference.md +++ b/docs/user_guide/cli-reference.md @@ -49,9 +49,34 @@ apimark cpp [options] | `--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) | +| `--search-paths ` | Comma-separated compiler-only `-I` paths; used for `#include` resolution only, not documented | +| `--include-patterns ` | Comma-separated glob patterns (relative to each root) selecting which headers to document; when absent all headers are included | +| `--exclude-patterns ` | Comma-separated glob patterns (relative to each root) for headers to exclude; evaluated after `--include-patterns` | | `--visibility ` | Visibility filter: `Public`, `PublicAndProtected`, `All` (default: `Public`) | | `--include-obsolete` | Include deprecated members in generated output | +#### Inline Wildcard and Exclusion Syntax in `--includes` + +The `--includes` list accepts three kinds of entries mixed freely: + +| Entry form | Effect | +| --- | --- | +| `path/to/dir` | Added as a public include root directory | +| `*.h` or `**/*.hpp` | Added as an include-filter pattern (same as `--include-patterns`) | +| `!test/**` | Added as an exclusion pattern with `!` stripped (same as `--exclude-patterns`) | + +Example: + +```text +--includes /usr/local/include,*.h,!detail/** +``` + +is equivalent to: + +```text +--includes /usr/local/include --include-patterns *.h --exclude-patterns detail/** +``` + ## Platform Support | Platform | `dotnet` | `cpp` | diff --git a/docs/user_guide/msbuild-integration.md b/docs/user_guide/msbuild-integration.md index de1ed8a..20c5e8c 100644 --- a/docs/user_guide/msbuild-integration.md +++ b/docs/user_guide/msbuild-integration.md @@ -38,12 +38,13 @@ the project file extension. | Property | Default | Description | | --- | --- | --- | -| `ApiMarkIncludePaths` | _(required)_ | Semicolon-separated list of public include directories | +| `ApiMarkIncludePaths` | _(required)_ | Semicolon-separated list of public include directories. Entries with `*` or `?` are forwarded as `--include-patterns`; entries starting with `!` are forwarded as `--exclude-patterns` (with `!` stripped). | | `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`) | | `ApiMarkCppStandard` | `c++17` | C++ language standard passed to Clang | | `ApiMarkClangPath` | _(auto-discovered)_ | Path to clang executable; overrides PATH / xcrun / vswhere discovery | +| `ApiMarkSearchPaths` | _(unset)_ | Semicolon-separated compiler-only `-I` paths for `#include` resolution; declarations are never documented | ## Configuration Examples @@ -89,6 +90,12 @@ the project file extension. + + + + + + ``` diff --git a/src/ApiMark.MSBuild/ApiMarkTask.cs b/src/ApiMark.MSBuild/ApiMarkTask.cs index 52a2ead..d038cc5 100644 --- a/src/ApiMark.MSBuild/ApiMarkTask.cs +++ b/src/ApiMark.MSBuild/ApiMarkTask.cs @@ -149,6 +149,16 @@ public sealed class ApiMarkTask : Task /// public string? ApiMarkClangPath { get; set; } + /// Gets or sets the semicolon-separated list of compiler-only search paths for C++. + /// + /// Used for the cpp language only. Maps to $(ApiMarkSearchPaths). + /// These paths are passed to Clang as -I flags for #include resolution only; + /// declarations from these paths are never documented. + /// Semicolons are converted to commas for the --search-paths tool argument. + /// Optional — omitted when empty or not set. + /// + public string? ApiMarkSearchPaths { get; set; } + /// /// Gets or sets the full path to ApiMark.Tool.dll bundled inside the NuGet package. /// @@ -215,12 +225,59 @@ 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; + // Parse ApiMarkIncludePaths into three buckets: plain roots, glob patterns, exclusions. + // MSBuild uses semicolons as its list separator; each entry is trimmed before classification. + var roots = new List(); + var includePatterns = new List(); + var excludePatterns = new List(); + + 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; + } + + if (entry.StartsWith("!")) + { + // Strip the '!' prefix and treat the remainder as an exclusion glob + excludePatterns.Add(entry.Substring(1)); + } + else if (entry.Contains("*") || entry.Contains("?")) + { + // Glob wildcards mark the entry as an include-filter pattern + includePatterns.Add(entry); + } + else + { + // Plain paths are public include root directories + roots.Add(entry); + } + } + } + args.Add("cpp"); args.Add("--includes"); - args.Add(commaIncludes); + args.Add(string.Join(",", roots)); + + // Forward include-filter patterns only when present — absent means all headers are included + if (includePatterns.Count > 0) + { + args.Add("--include-patterns"); + args.Add(string.Join(",", includePatterns)); + } + + // Forward exclusion patterns only when present + if (excludePatterns.Count > 0) + { + args.Add("--exclude-patterns"); + args.Add(string.Join(",", excludePatterns)); + } // Library name (defaults to project name via .targets) if (!string.IsNullOrEmpty(ApiMarkLibraryName)) @@ -257,6 +314,14 @@ internal IReadOnlyList BuildArguments(string language) args.Add("--clang-path"); args.Add(ApiMarkClangPath!); } + + // Compiler-only search paths — semicolons converted to commas + if (!string.IsNullOrEmpty(ApiMarkSearchPaths)) + { + var commaSearchPaths = ApiMarkSearchPaths!.Replace(';', ','); + args.Add("--search-paths"); + args.Add(commaSearchPaths); + } } // Optional: output directory diff --git a/src/ApiMark.Tool/Cli/Context.cs b/src/ApiMark.Tool/Cli/Context.cs index 0d7f107..8ce0705 100644 --- a/src/ApiMark.Tool/Cli/Context.cs +++ b/src/ApiMark.Tool/Cli/Context.cs @@ -64,9 +64,31 @@ internal sealed class Context : IContext, IDisposable /// /// Gets the include directory paths for the C++ language subcommand. + /// Contains only plain path entries from --includes (no wildcards, no !). /// public string[] Includes { get; private init; } = []; + /// + /// Gets the compiler-only search path directories for the C++ language subcommand. + /// Passed to Clang as -I paths; declarations from these paths are never documented. + /// + public string[] SearchPaths { get; private init; } = []; + + /// + /// Gets the glob patterns selecting which header files contribute to the documented API, + /// relative to each root. + /// Populated from --include-patterns or from wildcard entries inline in --includes. + /// When empty, all headers under the roots are included. + /// + public string[] IncludePatterns { get; private init; } = []; + + /// + /// Gets the glob patterns for header files to exclude from the documented API, + /// relative to each root. Evaluated after . + /// Populated from --exclude-patterns or from !-prefixed entries inline in --includes. + /// + public string[] ExcludePatterns { get; private init; } = []; + /// /// Gets the output directory for generated Markdown files. /// @@ -154,6 +176,9 @@ public static Context Create(string[] args) Assembly = parser.Assembly, XmlDoc = parser.XmlDoc, Includes = parser.Includes, + SearchPaths = parser.SearchPaths, + IncludePatterns = parser.IncludePatterns, + ExcludePatterns = parser.ExcludePatterns, Output = parser.Output, Visibility = parser.Visibility, IncludeObsolete = parser.IncludeObsolete, @@ -309,9 +334,30 @@ private sealed class ArgumentParser /// /// Gets the include directory paths for C++. + /// Contains only plain path entries (no wildcards, no !). /// public string[] Includes { get; private set; } = []; + /// + /// Gets the compiler-only search paths parsed from the --search-paths comma-separated list. + /// Passed to Clang as -I paths for #include resolution; declarations from these + /// paths are never documented. + /// + public string[] SearchPaths { get; private set; } = []; + + /// + /// Gets the include glob patterns parsed from --include-patterns or from + /// glob entries inline in --includes. Passed to CppGeneratorOptions.IncludePatterns. + /// + public string[] IncludePatterns { get; private set; } = []; + + /// + /// Gets the exclude glob patterns parsed from --exclude-patterns or from + /// !-prefixed entries inline in --includes. Passed to + /// CppGeneratorOptions.ExcludePatterns. + /// + public string[] ExcludePatterns { get; private set; } = []; + /// /// Gets the output directory. /// @@ -432,7 +478,28 @@ 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") + { + // Classify each entry into: plain root paths, include-filter globs, or exclusion patterns + var raw = GetRequiredStringArgument(arg, args, index, "a comma-separated path list argument"); + ClassifyIncludeEntries(raw, out var roots, out var includePatterns, out var excludePatterns); + Includes = roots; + IncludePatterns = includePatterns; + ExcludePatterns = excludePatterns; + return index + 1; + } + + case "--search-paths": + SearchPaths = GetRequiredStringArgument(arg, args, index, "a comma-separated path list argument") + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + return index + 1; + + case "--include-patterns": + IncludePatterns = GetRequiredStringArgument(arg, args, index, "a comma-separated glob pattern argument") + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + return index + 1; + + case "--exclude-patterns": + ExcludePatterns = GetRequiredStringArgument(arg, args, index, "a comma-separated glob pattern argument") .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); return index + 1; @@ -481,6 +548,47 @@ private int ParseArgument(string arg, string[] args, int index) } } + /// + /// Classifies a comma-separated include list into three buckets. + /// + /// Raw comma-separated string from --includes. + /// Receives plain directory paths (no wildcards, no !). + /// Receives glob patterns (containing * or ?). + /// Receives !-stripped exclusion patterns. + private static void ClassifyIncludeEntries( + string value, + out string[] roots, + out string[] includePatterns, + out string[] excludePatterns) + { + var rootList = new List(); + var includeList = new List(); + var excludeList = new List(); + + foreach (var entry in value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (entry.StartsWith('!')) + { + // Strip the '!' prefix and add the remainder to the exclusion list + excludeList.Add(entry[1..]); + } + else if (entry.Contains('*') || entry.Contains('?')) + { + // Entries with glob wildcards select which headers to document + includeList.Add(entry); + } + else + { + // Plain paths are public include root directories + rootList.Add(entry); + } + } + + roots = [.. rootList]; + includePatterns = [.. includeList]; + excludePatterns = [.. excludeList]; + } + /// /// Gets a required string argument value from the argument array. /// diff --git a/src/ApiMark.Tool/Program.cs b/src/ApiMark.Tool/Program.cs index a19fb56..86cffff 100644 --- a/src/ApiMark.Tool/Program.cs +++ b/src/ApiMark.Tool/Program.cs @@ -246,11 +246,14 @@ private static IApiGenerator CreateGenerator(Context context) LibraryName = cppLibraryName, Description = context.LibraryDescription ?? string.Empty, PublicIncludeRoots = context.Includes, + IncludePatterns = context.IncludePatterns, + ExcludePatterns = context.ExcludePatterns, Defines = context.Defines, CppStandard = context.CppStandard ?? "c++17", Visibility = (CppApiVisibility)(int)visibility, IncludeDeprecated = context.IncludeObsolete, ClangPath = context.ClangPath, + AdditionalIncludePaths = context.SearchPaths, }), // Any other token is an unrecognized subcommand @@ -306,6 +309,9 @@ private static void PrintHelp(Context context) context.WriteLine(" --defines Comma-separated preprocessor definitions (e.g. MYLIB_API=,NDEBUG)"); context.WriteLine(" --cpp-standard C++ language standard passed to Clang (default: c++17)"); context.WriteLine(" --clang-path Path to clang executable (default: auto-discovered via PATH / xcrun / vswhere)"); + context.WriteLine(" --search-paths Comma-separated compiler-only -I paths (not documented)"); + context.WriteLine(" --include-patterns

Comma-separated glob patterns selecting headers to document"); + context.WriteLine(" --exclude-patterns

Comma-separated glob patterns for headers to exclude"); context.WriteLine(" --visibility Visibility filter: Public, PublicAndProtected, All (default: Public)"); context.WriteLine(" --include-obsolete Include deprecated members in generated output"); } diff --git a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs index b87182d..d6d840b 100644 --- a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs +++ b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs @@ -377,5 +377,144 @@ public void ApiMarkTask_BuildArguments_LibraryDescriptionWithDoubleQuote_PassedV // so the caller must not apply any backslash escaping Assert.Contains("My library (v\"2\")", args); } + + ///

+ /// Validates that appends the --search-paths + /// flag with semicolons converted to commas when + /// is set. + /// + [Fact] + public void ApiMarkTask_Cpp_SearchPaths_ForwardedToTool() + { + // Arrange: configure semicolon-separated search paths for a cpp invocation + var task = new ApiMarkTask + { + ProjectExtension = ".vcxproj", + ToolDllPath = "dummy.dll", + ApiMarkIncludePaths = "/include", + ApiMarkSearchPaths = "/sdk/include;/third-party/include", + }; + + // Act + var args = task.BuildArguments("cpp"); + + // Assert: the --search-paths flag must appear and semicolons must be converted to commas + Assert.Contains("--search-paths", args); + Assert.Contains("/sdk/include,/third-party/include", args); + Assert.DoesNotContain("/sdk/include;/third-party/include", args); + } + + /// + /// Validates that does not include the + /// --search-paths flag when is empty. + /// + [Fact] + public void ApiMarkTask_Cpp_SearchPaths_Empty_NotIncludedInArgs() + { + // Arrange: no search paths configured + var task = new ApiMarkTask + { + ProjectExtension = ".vcxproj", + ToolDllPath = "dummy.dll", + ApiMarkIncludePaths = "/include", + ApiMarkSearchPaths = string.Empty, + }; + + // Act + var args = task.BuildArguments("cpp"); + + // Assert: --search-paths must be absent when the property is empty + Assert.DoesNotContain("--search-paths", args); + } + + /// + /// Validates that glob entries in are forwarded + /// as --include-patterns and not included in --includes. + /// + [Fact] + public void ApiMarkTask_Cpp_IncludePaths_GlobEntries_ForwardedAsIncludePatterns() + { + // Arrange: a mix of a plain root and a glob pattern, semicolon-separated + var task = new ApiMarkTask + { + ProjectExtension = ".vcxproj", + ToolDllPath = "dummy.dll", + ApiMarkIncludePaths = "/include;*.h", + }; + + // Act + var args = task.BuildArguments("cpp"); + + // Assert: the glob is forwarded via --include-patterns, not embedded in --includes + Assert.Contains("--include-patterns", args); + Assert.Contains("*.h", args); + + // The --includes value must contain only the plain root + var includesIndex = args.ToList().IndexOf("--includes"); + Assert.True(includesIndex >= 0, "--includes must be present"); + Assert.Equal("/include", args[includesIndex + 1]); + } + + /// + /// Validates that !-prefixed entries in + /// are forwarded as --exclude-patterns (with the ! stripped) and not included + /// in --includes. + /// + [Fact] + public void ApiMarkTask_Cpp_IncludePaths_ExcludeEntries_ForwardedAsExcludePatterns() + { + // Arrange: a plain root and an exclusion pattern + var task = new ApiMarkTask + { + ProjectExtension = ".vcxproj", + ToolDllPath = "dummy.dll", + ApiMarkIncludePaths = "/include;!test/**", + }; + + // Act + var args = task.BuildArguments("cpp"); + + // Assert: the exclusion is forwarded via --exclude-patterns with '!' stripped + Assert.Contains("--exclude-patterns", args); + Assert.Contains("test/**", args); + Assert.DoesNotContain("!test/**", args); + } + + /// + /// Validates that a mixed value is correctly + /// split into --includes (plain roots), --include-patterns (globs), and + /// --exclude-patterns (exclusions). + /// + [Fact] + public void ApiMarkTask_Cpp_IncludePaths_Mixed_SplitIntoThreeBuckets() + { + // Arrange: plain root, glob pattern, and exclusion in a single semicolon-separated list + var task = new ApiMarkTask + { + ProjectExtension = ".vcxproj", + ToolDllPath = "dummy.dll", + ApiMarkIncludePaths = "/include;*.h;!test/**", + }; + + // Act + var args = task.BuildArguments("cpp"); + + // Assert: each entry must appear via its correct flag + Assert.Contains("--include-patterns", args); + Assert.Contains("--exclude-patterns", args); + + var argList = args.ToList(); + var includesIndex = argList.IndexOf("--includes"); + Assert.True(includesIndex >= 0, "--includes must be present"); + Assert.Equal("/include", argList[includesIndex + 1]); + + var includePatternIndex = argList.IndexOf("--include-patterns"); + Assert.True(includePatternIndex >= 0, "--include-patterns must be present"); + Assert.Equal("*.h", argList[includePatternIndex + 1]); + + var excludePatternIndex = argList.IndexOf("--exclude-patterns"); + Assert.True(excludePatternIndex >= 0, "--exclude-patterns must be present"); + Assert.Equal("test/**", argList[excludePatternIndex + 1]); + } } diff --git a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs index b0b78c0..d62e5f4 100644 --- a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs +++ b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs @@ -313,6 +313,9 @@ public void Context_Create_WithNoArguments_HasDefaultValues() () => Assert.False(context.IncludeObsolete), () => Assert.Equal(1, context.HeadingDepth), () => Assert.Empty(context.Includes), + () => Assert.Empty(context.SearchPaths), + () => Assert.Empty(context.IncludePatterns), + () => Assert.Empty(context.ExcludePatterns), () => Assert.Equal(0, context.ExitCode)); } @@ -397,4 +400,131 @@ public void Context_Cli_ParsesAllGlobalFlags() () => Assert.True(context.Silent), () => Assert.True(context.Validate)); } + + /// + /// Validates that --search-paths sets the SearchPaths property to the comma-split array. + /// + [Fact] + public void Context_Create_WithSearchPathsOption_SetsSearchPaths() + { + // Arrange: supply a comma-separated list of search paths + var args = new[] { "--search-paths", "/sdk/include,/third-party/include" }; + + // Act + using var context = Context.Create(args); + + // Assert: SearchPaths must contain the two split entries + string[] expected = ["/sdk/include", "/third-party/include"]; + Assert.Equal(expected, context.SearchPaths); + } + + /// + /// Validates that --include-patterns sets the IncludePatterns property. + /// + [Fact] + public void Context_Create_WithIncludePatternsOption_SetsIncludePatterns() + { + // Arrange: supply a comma-separated glob pattern list + var args = new[] { "--include-patterns", "*.h,**/*.hpp" }; + + // Act + using var context = Context.Create(args); + + // Assert: IncludePatterns must contain the two split patterns + string[] expected = ["*.h", "**/*.hpp"]; + Assert.Equal(expected, context.IncludePatterns); + } + + /// + /// Validates that --exclude-patterns sets the ExcludePatterns property. + /// + [Fact] + public void Context_Create_WithExcludePatternsOption_SetsExcludePatterns() + { + // Arrange: supply a comma-separated exclusion glob list + var args = new[] { "--exclude-patterns", "test/**,*_internal.h" }; + + // Act + using var context = Context.Create(args); + + // Assert: ExcludePatterns must contain the two split patterns + string[] expected = ["test/**", "*_internal.h"]; + Assert.Equal(expected, context.ExcludePatterns); + } + + /// + /// Validates that a wildcard entry inline in --includes is classified as an IncludePattern, + /// not added to Includes. + /// + [Fact] + public void Context_Create_WithIncludesContainingGlobEntry_ClassifiesAsIncludePattern() + { + // Arrange: --includes value with a plain root and a glob wildcard + var args = new[] { "--includes", "/usr/include,*.h" }; + + // Act + using var context = Context.Create(args); + + // Assert: plain path goes to Includes; the glob goes to IncludePatterns + Assert.Equal(["/usr/include"], context.Includes); + Assert.Equal(["*.h"], context.IncludePatterns); + Assert.Empty(context.ExcludePatterns); + } + + /// + /// Validates that a '!'-prefixed entry inline in --includes is classified as an + /// ExcludePattern (with '!' stripped) and not added to Includes. + /// + [Fact] + public void Context_Create_WithIncludesContainingExcludeEntry_ClassifiesAsExcludePattern() + { + // Arrange: --includes value with a plain root and an exclusion pattern + var args = new[] { "--includes", "/usr/include,!test/**" }; + + // Act + using var context = Context.Create(args); + + // Assert: plain path goes to Includes; '!' entry with prefix stripped goes to ExcludePatterns + Assert.Equal(["/usr/include"], context.Includes); + Assert.Empty(context.IncludePatterns); + Assert.Equal(["test/**"], context.ExcludePatterns); + } + + /// + /// Validates that a mixed --includes value is correctly classified into all three buckets. + /// + [Fact] + public void Context_Create_WithIncludesMixed_ClassifiesIntoThreeBuckets() + { + // Arrange: --includes with one plain root, one glob, and one exclusion + var args = new[] { "--includes", "/usr/include,*.h,!test/**" }; + + // Act + using var context = Context.Create(args); + + // Assert: each entry must land in its correct bucket + Assert.Equal(["/usr/include"], context.Includes); + Assert.Equal(["*.h"], context.IncludePatterns); + Assert.Equal(["test/**"], context.ExcludePatterns); + } + + /// + /// Validates that Context.Create with no arguments leaves SearchPaths, IncludePatterns, + /// and ExcludePatterns as empty arrays. + /// + [Fact] + public void Context_Create_WithNoArguments_HasEmptySearchPathsAndPatterns() + { + // Arrange: empty argument array + var args = Array.Empty(); + + // Act + using var context = Context.Create(args); + + // Assert: new properties must default to empty arrays + Assert.Multiple( + () => Assert.Empty(context.SearchPaths), + () => Assert.Empty(context.IncludePatterns), + () => Assert.Empty(context.ExcludePatterns)); + } } diff --git a/test/ApiMark.Tool.Tests/ProgramTests.cs b/test/ApiMark.Tool.Tests/ProgramTests.cs index ed50baf..c082ced 100644 --- a/test/ApiMark.Tool.Tests/ProgramTests.cs +++ b/test/ApiMark.Tool.Tests/ProgramTests.cs @@ -343,4 +343,100 @@ public void Program_Main_WithHelpAfterSubcommand_PrintsHelpAndExitsZero() Console.SetOut(originalOut); } } + + /// + /// Validates that the --search-paths flag is recognized and does not cause an + /// argument-parsing exception; when --includes is absent the tool still fails + /// with the expected includes-required diagnostic. + /// + [Fact] + public void Program_Main_CppWithSearchPathsFlag_FlagIsAccepted() + { + // Arrange: provide --search-paths 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", "--search-paths", "/sdk", "--output", outputDir]); + + // Assert: fails due to missing --includes, confirming --search-paths was accepted by the parser + Assert.NotEqual(0, exitCode); + Assert.Contains("--includes", errorWriter.ToString(), StringComparison.Ordinal); + } + finally + { + Console.SetError(originalError); + } + } + + /// + /// Validates that a glob-only --includes value is classified as an include pattern + /// rather than a root directory, so validation still fails with the includes-required diagnostic. + /// + [Fact] + public void Program_Main_CppWithGlobOnlyIncludes_FailsMissingRootValidation() + { + // Arrange: --includes contains only a glob wildcard entry (no plain root directory) + 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", "--includes", "*.h", "--output", outputDir]); + + // Assert: because *.h is classified as an include pattern (not a root), validation + // fails with the same --includes-required diagnostic as if --includes were absent + Assert.NotEqual(0, exitCode); + Assert.Contains("--includes", errorWriter.ToString(), StringComparison.Ordinal); + } + finally + { + Console.SetError(originalError); + } + } + + /// + /// Validates that --include-patterns and --exclude-patterns flags are + /// recognized by the parser and do not cause an argument exception. + /// + [Fact] + public void Program_Main_CppWithIncludeAndExcludePatternFlags_FlagsAreAccepted() + { + // Arrange: provide pattern flags but omit --includes so the tool fails on validation, + // confirming the new flags were accepted without throwing an ArgumentException + 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", + "--exclude-patterns", "test/**", + "--output", outputDir, + ]); + + // Assert: fails due to missing --includes (not due to unknown flag), + // confirming both pattern flags were accepted by the parser + Assert.NotEqual(0, exitCode); + Assert.Contains("--includes", errorWriter.ToString(), StringComparison.Ordinal); + } + finally + { + Console.SetError(originalError); + } + } } From 03fd978d4da47cb369eadaf96cedd44d55d4b0bd Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 16:22:35 -0400 Subject: [PATCH 10/31] fix: normalize all generated markdown pages to start with H1 headings Every generated Markdown file is a standalone document and must start with an H1 heading. Previously, type pages opened at H2 and member pages opened at H3, causing incorrect document structure. Also fix pending PR review issues: - Fix --search-paths help text to clarify it is for #include resolution - Fix bare '!' entry handling in ClassifyIncludeEntries (Context.cs) - Fix bare '!' entry handling in BuildArguments (ApiMarkTask.cs) Changes: - DotNet type pages: H2 -> H1 title; H3 sections -> H2 - DotNet member pages: H3 -> H1 title; H4 overload sections -> H2 - DotNet combined/overload pages: H3 -> H1 title; H4 sections -> H2 - C++ class sections: H3 -> H2 (class page already at H1) - C++ member/field pages: H3 -> H1 title - C++ enum Values section: H3 -> H2 - C++ combined member pages: H3 -> H1; H4 sections -> H2 - WriteFunctionContent/WriteFreeFunctionContent: parametersHeadingLevel param added (H2 standalone, H3 in combined/operators pages) - WriteExternalTypesSection (DotNet): removed headingLevel param; always H2 since all pages now start at H1 - Updated all test fixtures to reflect new heading structure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppGenerator.cs | 58 +++++++++++-------- src/ApiMark.DotNet/DotNetGenerator.cs | 42 ++++++-------- src/ApiMark.MSBuild/ApiMarkTask.cs | 8 ++- src/ApiMark.Tool/Cli/Context.cs | 8 ++- src/ApiMark.Tool/Program.cs | 2 +- test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 24 ++++---- .../DotNetGeneratorTests.cs | 12 ++-- 7 files changed, 85 insertions(+), 69 deletions(-) diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 009f44b..13629d2 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -892,19 +892,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); } @@ -913,7 +913,7 @@ private void WriteTypePage( if (operatorMethods.Count > 0) { WriteClassOperatorsPage(factory, nsKey, nsDisplayName, cls, operatorMethods, cppResolver); - writer.WriteHeading(3, "Operators"); + writer.WriteHeading(2, "Operators"); writer.WriteTable( new[] { "Operators", DescriptionColumnHeader }, new[] { new[] { $"[operators]({cls.Name}/operators.md)", "Operator overloads" } }); @@ -977,7 +977,7 @@ private void WriteClassOperatorsPage( { var paramTypes = string.Join(", ", op.Parameters.Select(p => SimplifyTypeName(p.TypeName))); writer.WriteHeading(2, $"{op.Name}({paramTypes})"); - WriteFunctionContent(writer, nsDisplayName, cls.Name, op, cppResolver, opsCurrentFolder, externalTypes); + WriteFunctionContent(writer, nsDisplayName, cls.Name, op, cppResolver, opsCurrentFolder, externalTypes, parametersHeadingLevel: 3); } WriteExternalTypesSection(writer, externalTypes); @@ -1039,7 +1039,7 @@ private void WriteNamespaceOperatorsPage( { var paramTypes = string.Join(", ", op.Parameters.Select(p => SimplifyTypeName(p.TypeName))); writer.WriteHeading(2, $"{op.Name}({paramTypes})"); - WriteFreeFunctionContent(writer, nsDisplayName, op, cppResolver, opsCurrentFolder, externalTypes); + WriteFreeFunctionContent(writer, nsDisplayName, op, cppResolver, opsCurrentFolder, externalTypes, parametersHeadingLevel: 3); } WriteExternalTypesSection(writer, externalTypes); @@ -1100,13 +1100,19 @@ private void WriteFreeFunctionPage( /// 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, CppTypeLinkResolver cppResolver, string currentFolder, - ISet externalTypes) + 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 @@ -1139,7 +1145,7 @@ private void WriteFreeFunctionContent( // 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 @@ -1152,7 +1158,7 @@ private void WriteFreeFunctionContent( var returnTypeName = SimplifyTypeName(fn.ReturnTypeName); if (!string.Equals(returnTypeName, "void", StringComparison.Ordinal)) { - writer.WriteHeading(4, "Returns"); + writer.WriteHeading(parametersHeadingLevel, "Returns"); var returnDescription = GetReturnDescription(fn.Doc); writer.WriteParagraph(!string.IsNullOrEmpty(returnDescription) ? returnDescription : returnTypeName); } @@ -1226,7 +1232,7 @@ private static void WriteFunctionPage( string currentFolder, ISet externalTypes) { - writer.WriteHeading(3, $"{className}.{method.Name}"); + writer.WriteHeading(1, $"{className}.{method.Name}"); WriteFunctionContent(writer, nsDisplayName, className, method, cppResolver, currentFolder, externalTypes); } @@ -1237,7 +1243,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. /// @@ -1249,6 +1255,11 @@ private static void WriteFunctionPage( /// 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, @@ -1256,7 +1267,8 @@ private static void WriteFunctionContent( CppFunction method, CppTypeLinkResolver cppResolver, string currentFolder, - ISet externalTypes) + 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 @@ -1280,7 +1292,7 @@ private static void WriteFunctionContent( // 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 @@ -1296,7 +1308,7 @@ private static void WriteFunctionContent( var returnTypeName = SimplifyTypeName(method.ReturnTypeName); if (!string.Equals(returnTypeName, "void", StringComparison.Ordinal)) { - writer.WriteHeading(4, "Returns"); + writer.WriteHeading(parametersHeadingLevel, "Returns"); var returnDescription = GetReturnDescription(method.Doc); writer.WriteParagraph(!string.IsNullOrEmpty(returnDescription) ? returnDescription : returnTypeName); } @@ -1320,7 +1332,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); } @@ -1332,7 +1344,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. /// @@ -1416,7 +1428,7 @@ private void WriteEnumPage( // 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 => { @@ -1726,7 +1738,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. @@ -1737,7 +1749,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. /// /// @@ -1760,7 +1772,7 @@ private static void WriteCombinedMemberPage( // 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(); @@ -1776,12 +1788,12 @@ 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, cppResolver, combinedCurrentFolder, externalTypes); + 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; } diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index 1b0adeb..061a8a9 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -273,7 +273,7 @@ private void WriteTypePage( TypeLinkResolver resolver) { using var typeWriter = factory.CreateMarkdown(namespaceFolderPath, type.Name); - typeWriter.WriteHeading(2, type.Name); + typeWriter.WriteHeading(1, type.Name); // Emit the C# declaration signature so readers can see the type kind, modifiers, and direct inheritance var typeSignature = BuildTypeSignature(type, namespaceName); @@ -477,7 +477,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); @@ -485,13 +485,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); @@ -499,18 +499,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, 3); + WriteExternalTypesSection(typeWriter, externalTypes); } /// @@ -543,14 +543,14 @@ private static void WriteMemberPage( using var memberWriter = factory.CreateMarkdown(memberCurrentFolder, sanitizedName); var displayName = GetMemberDisplayName(member); - memberWriter.WriteHeading(3, displayName); + memberWriter.WriteHeading(1, displayName); if (member is MethodDefinition method) { // 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, 4); + WriteExternalTypesSection(memberWriter, externalTypes); return; } @@ -602,17 +602,17 @@ private static void WriteMethodOverloadPage( var overloadCurrentFolder = $"{namespaceFolderPath}/{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)); + memberWriter.WriteHeading(2, BuildMethodDisplayName(overload)); WriteMethodDocumentation(memberWriter, namespaceName, overload, xmlDocs, BuildMemberId(overload), resolver, overloadCurrentFolder, externalTypes); } - WriteExternalTypesSection(memberWriter, externalTypes, 4); + WriteExternalTypesSection(memberWriter, externalTypes); } /// @@ -623,7 +623,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. @@ -636,7 +636,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. /// /// @@ -660,7 +660,7 @@ private static void WriteCombinedMemberPage( // 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(); @@ -669,7 +669,7 @@ private static void WriteCombinedMemberPage( { 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) @@ -715,7 +715,7 @@ private static void WriteCombinedMemberPage( } } - WriteExternalTypesSection(writer, externalTypes, 4); + WriteExternalTypesSection(writer, externalTypes); } /// @@ -1310,18 +1310,14 @@ private static string GetMemberDisplayName(IMemberDefinition member) /// /// The set of external types accumulated during table row generation. May be empty. /// - /// - /// Heading depth for the "External Types" section heading. Use 3 for type pages - /// (which open with H2) and 4 for member pages (which open with H3). - /// - private static void WriteExternalTypesSection(IMarkdownWriter writer, SortedSet externalTypes, int headingLevel = 3) + private static void WriteExternalTypesSection(IMarkdownWriter writer, SortedSet externalTypes) { if (externalTypes.Count == 0) { return; } - writer.WriteHeading(headingLevel, "External Types"); + writer.WriteHeading(2, "External Types"); writer.WriteTable( ["Type", "Namespace"], externalTypes.Select(t => new[] { t.SimplifiedName, t.Namespace })); diff --git a/src/ApiMark.MSBuild/ApiMarkTask.cs b/src/ApiMark.MSBuild/ApiMarkTask.cs index d038cc5..96a515d 100644 --- a/src/ApiMark.MSBuild/ApiMarkTask.cs +++ b/src/ApiMark.MSBuild/ApiMarkTask.cs @@ -245,8 +245,12 @@ internal IReadOnlyList BuildArguments(string language) if (entry.StartsWith("!")) { - // Strip the '!' prefix and treat the remainder as an exclusion glob - excludePatterns.Add(entry.Substring(1)); + // Strip the '!' prefix, trim whitespace, and skip bare '!' entries + var pattern = entry.Substring(1).Trim(); + if (pattern.Length > 0) + { + excludePatterns.Add(pattern); + } } else if (entry.Contains("*") || entry.Contains("?")) { diff --git a/src/ApiMark.Tool/Cli/Context.cs b/src/ApiMark.Tool/Cli/Context.cs index 8ce0705..4e83abf 100644 --- a/src/ApiMark.Tool/Cli/Context.cs +++ b/src/ApiMark.Tool/Cli/Context.cs @@ -569,8 +569,12 @@ private static void ClassifyIncludeEntries( { if (entry.StartsWith('!')) { - // Strip the '!' prefix and add the remainder to the exclusion list - excludeList.Add(entry[1..]); + // Strip the '!' prefix, trim whitespace, and skip bare '!' entries + var pattern = entry[1..].Trim(); + if (pattern.Length > 0) + { + excludeList.Add(pattern); + } } else if (entry.Contains('*') || entry.Contains('?')) { diff --git a/src/ApiMark.Tool/Program.cs b/src/ApiMark.Tool/Program.cs index 86cffff..088fe2d 100644 --- a/src/ApiMark.Tool/Program.cs +++ b/src/ApiMark.Tool/Program.cs @@ -309,7 +309,7 @@ private static void PrintHelp(Context context) context.WriteLine(" --defines Comma-separated preprocessor definitions (e.g. MYLIB_API=,NDEBUG)"); context.WriteLine(" --cpp-standard C++ language standard passed to Clang (default: c++17)"); context.WriteLine(" --clang-path Path to clang executable (default: auto-discovered via PATH / xcrun / vswhere)"); - context.WriteLine(" --search-paths Comma-separated compiler-only -I paths (not documented)"); + context.WriteLine(" --search-paths (for #include resolution only; not included in generated output)"); context.WriteLine(" --include-patterns

Comma-separated glob patterns selecting headers to document"); context.WriteLine(" --exclude-patterns

Comma-separated glob patterns for headers to exclude"); context.WriteLine(" --visibility Visibility filter: Public, PublicAndProtected, All (default: Public)"); diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 1e47ed3..58b711a 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -277,20 +277,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 = _fixture.PublicFactory; - // 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); } @@ -536,7 +536,7 @@ 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] @@ -549,14 +549,14 @@ public void CppGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMem 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)); } /// diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index b9a1864..80e2f47 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,14 +1129,14 @@ 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)); } /// From 44eabe6ead8da82b738a5c7a48dfa5a86056fe93 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 16:56:03 -0400 Subject: [PATCH 11/31] fix: add missing inheritance signature tests for reqstream coverage Add two tests required by unsatisfied reqstream requirements: - CppGenerator_Generate_InheritanceClass_EmitsBaseClassInSignature satisfies ApiMarkCpp-CppGenerator-ShowDirectInheritanceInTypeSignature - DotNetGenerator_Generate_EnumTypeSignature_HasNoBaseClass satisfies ApiMarkDotNet-DotNetGenerator-ShowDirectInheritanceInTypeSignature Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 23 +++++++++++++++ .../DotNetGeneratorTests.cs | 29 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 58b711a..a9d9c57 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -444,6 +444,29 @@ 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() diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index 80e2f47..ce9f102 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1167,6 +1167,35 @@ public void DotNetGenerator_Generate_SampleImplementation_TypeSignatureShowsInte 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. From bdcaa8ed5f16c5d43cde4000ba8200c220c7257f Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 19:54:01 -0400 Subject: [PATCH 12/31] fix: only check stripped base name for std:: prefix in CppTypeLinkResolver The full-string cppTypeString.Contains check incorrectly suppressed linkification of intra-library types whose signatures include std:: template arguments. Only the stripped base name needs the std:: check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppTypeLinkResolver.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs index 826738f..b309d6b 100644 --- a/src/ApiMark.Cpp/CppTypeLinkResolver.cs +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -119,9 +119,11 @@ public string Linkify( return cppTypeString; } - // std:: types are always rendered as plain text and never tracked as external - if (stripped.StartsWith("std::", StringComparison.Ordinal) || - cppTypeString.Contains("std::")) + // 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; } From 6b3e70b7c3b87dc0e3968edcbd33829033f1e763 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 21:10:55 -0400 Subject: [PATCH 13/31] refactor: replace --search-paths/--include-patterns/--exclude-patterns with --includes/--api-headers Redesign C++ header selection flags for clarity and power: - --includes (repeatable) replaces both --includes and --search-paths; all directories are passed to clang as -I paths - --api-headers (repeatable, ordered) replaces --include-patterns and --exclude-patterns; supports ! antipatterns with gitignore semantics (last matching pattern wins, enabling include/exclude/re-include) - Default when no --api-headers: glob **/*.h, **/*.hpp, **/*.hxx, **/*.h++ against all --includes directories (preserves old behavior) - MSBuild: ApiMarkApiHeaders replaces ApiMarkSearchPaths + pattern properties; semicolon-separated, order-preserved - CppGeneratorOptions: ApiHeaderPatterns replaces IncludePatterns + ExcludePatterns + AdditionalIncludePaths - Tests added for default, include, exclude, and re-include scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp.md | 15 +- docs/design/api-mark-cpp/cpp-generator.md | 47 +++--- docs/design/api-mark-msbuild/api-mark-task.md | 37 ++--- docs/design/api-mark-tool.md | 5 +- docs/design/api-mark-tool/cli/context.md | 15 +- docs/user_guide/cli-reference.md | 39 +++-- docs/user_guide/msbuild-integration.md | 14 +- src/ApiMark.Cpp/CppAst/ClangAstParser.cs | 11 +- src/ApiMark.Cpp/CppGenerator.cs | 138 ++++++++++----- src/ApiMark.Cpp/CppGeneratorOptions.cs | 50 +++--- src/ApiMark.MSBuild/ApiMarkTask.cs | 85 ++++------ src/ApiMark.Tool/Cli/Context.cs | 138 ++++----------- src/ApiMark.Tool/Program.cs | 10 +- test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 79 +++++++-- .../ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs | 157 ++++-------------- test/ApiMark.Tool.Tests/Cli/ContextTests.cs | 140 ++++++---------- test/ApiMark.Tool.Tests/ProgramTests.cs | 82 +++------ 17 files changed, 448 insertions(+), 614 deletions(-) diff --git a/docs/design/api-mark-cpp.md b/docs/design/api-mark-cpp.md index e933348..151b8eb 100644 --- a/docs/design/api-mark-cpp.md +++ b/docs/design/api-mark-cpp.md @@ -79,14 +79,14 @@ 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. + applying ApiHeaderPatterns with gitignore-style last-match-wins semantics, + to produce the candidate file set. 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 +96,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 5bbe537..85116d5 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 @@ -44,16 +44,17 @@ property serves a dual purpose: 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 antipattern 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 antipatterns (the `!` +is stripped before glob matching). When empty, the default patterns +`["**/*.h", "**/*.hpp", "**/*.hxx", "**/*.h++"]` are applied, which selects all +headers with recognized C++ extensions under all roots. -**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: +`["**/*", "!**/detail/**", "**/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)` @@ -166,9 +160,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 @@ -190,9 +184,8 @@ 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 +196,9 @@ 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. Test the relative path against ApiHeaderPatterns using gitignore-style + last-match-wins evaluation. Return owned=true only when the final + evaluated state is included. **CppGenerator.WriteCombinedMemberPage** (private): Writes a single combined Markdown page for a group of members whose base names collide on case-insensitive diff --git a/docs/design/api-mark-msbuild/api-mark-task.md b/docs/design/api-mark-msbuild/api-mark-task.md index 0d20aa8..bbb2707 100644 --- a/docs/design/api-mark-msbuild/api-mark-task.md +++ b/docs/design/api-mark-msbuild/api-mark-task.md @@ -55,12 +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. Each entry is classified before forwarding: entries containing -`*` or `?` wildcards are forwarded via `--include-patterns`; entries starting -with `!` are stripped and forwarded via `--exclude-patterns`; plain directory -entries are forwarded via `--includes`. - -**ApiMarkTask.ApiMarkLibraryName**: `string` — MSBuild property +include directory paths. Each entry is forwarded as an individual `--includes` +flag; all paths are passed to Clang as `-I` flags and serve as the base for the +default header glob when `ApiMarkApiHeaders` is not set. + +**ApiMarkTask.ApiMarkApiHeaders**: `string` — MSBuild property +`$(ApiMarkApiHeaders)`; for the `cpp` language, a semicolon-separated, +order-preserved list of glob and antipattern strings forwarded as individual +`--api-headers` flags. Entries with a `!` prefix are exclusion antipatterns; +gitignore-style last-match-wins semantics apply. Optional — when empty or not +set, all headers with recognized C++ extensions under `ApiMarkIncludePaths` are +documented. `$(ApiMarkLibraryName)`; for the `cpp` language, the library name used as the top-level heading in `api.md`. The `.targets` file defaults this to `$(MSBuildProjectName)` when not explicitly set. @@ -84,12 +89,8 @@ passed to Clang (e.g. `c++17`, `c++20`). The `.targets` file defaults this to executable. Optional — when empty, clang is located automatically via PATH, xcrun (macOS), or vswhere (Windows). -**ApiMarkTask.ApiMarkSearchPaths**: `string` — MSBuild property -`$(ApiMarkSearchPaths)`; for the `cpp` language, a semicolon-separated list -of compiler-only search paths passed to Clang as `-I` flags for `#include` -resolution. Declarations from these paths are never documented. Semicolons are -converted to commas when forwarding to the `--search-paths` argument. Optional -— omitted when empty or not set. +**ApiMarkTask.ApiMarkSearchPaths**: `string` — removed in this version. Use +`ApiMarkIncludePaths` to pass all include directories to Clang. **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. @@ -118,15 +119,13 @@ 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`, classify each `ApiMarkIncludePaths` entry: wildcard entries -with `*` or `?` are forwarded via `--include-patterns`; `!`-prefixed entries are -stripped and forwarded via `--exclude-patterns`; plain directory entries are -forwarded via `--includes`; if `ApiMarkLibraryName` is set, append -`--library-name`; if `ApiMarkLibraryDescription` is set, append +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 `!` antipatterns; 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`; if `ApiMarkSearchPaths` is -set, convert semicolons to commas and append `--search-paths`); start the child +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. diff --git a/docs/design/api-mark-tool.md b/docs/design/api-mark-tool.md index 8971c67..d07ed0e 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 antipatterns), + `--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/context.md b/docs/design/api-mark-tool/cli/context.md index 8f658f1..9d1b5f1 100644 --- a/docs/design/api-mark-tool/cli/context.md +++ b/docs/design/api-mark-tool/cli/context.md @@ -27,10 +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[]` | `[]` | Plain path entries from `--includes` (no wildcards, no `!`) | -| `SearchPaths` | `string[]` | `[]` | Comma-split values from `--search-paths` | -| `IncludePatterns` | `string[]` | `[]` | Glob patterns from `--include-patterns` or wildcard entries in `--includes` | -| `ExcludePatterns` | `string[]` | `[]` | Exclusion patterns from `--exclude-patterns` or `!`-prefixed entries in `--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 | @@ -55,11 +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. When processing `--includes`, the private - static `ClassifyIncludeEntries` helper is used to split entries into three - buckets: plain root paths (to `Includes`), wildcard entries (to - `IncludePatterns`), and `!`-prefixed exclusions (stripped and placed in - `ExcludePatterns`). + 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/user_guide/cli-reference.md b/docs/user_guide/cli-reference.md index d66fb47..1f6608b 100644 --- a/docs/user_guide/cli-reference.md +++ b/docs/user_guide/cli-reference.md @@ -42,40 +42,45 @@ 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) | -| `--search-paths ` | Comma-separated compiler-only `-I` paths; used for `#include` resolution only, not documented | -| `--include-patterns ` | Comma-separated glob patterns (relative to each root) selecting which headers to document; when absent all headers are included | -| `--exclude-patterns ` | Comma-separated glob patterns (relative to each root) for headers to exclude; evaluated after `--include-patterns` | | `--visibility ` | Visibility filter: `Public`, `PublicAndProtected`, `All` (default: `Public`) | | `--include-obsolete` | Include deprecated members in generated output | -#### Inline Wildcard and Exclusion Syntax in `--includes` +#### `--includes` and `--api-headers` -The `--includes` list accepts three kinds of entries mixed freely: +`--includes ` is repeatable — provide it once per include directory. All directories +are passed to Clang as `-I` paths and serve as the base for the default `--api-headers` glob. -| Entry form | Effect | -| --- | --- | -| `path/to/dir` | Added as a public include root directory | -| `*.h` or `**/*.hpp` | Added as an include-filter pattern (same as `--include-patterns`) | -| `!test/**` | Added as an exclusion pattern with `!` stripped (same as `--exclude-patterns`) | +`--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 antipatterns. + +When `--api-headers` is not specified, all headers under the `--includes` directories +with recognized C++ header extensions (`.h`, `.hpp`, `.hxx`, `.h++`) are documented. -Example: +Example — document all headers except a `detail/` subtree, then re-include one header: ```text ---includes /usr/local/include,*.h,!detail/** +--includes /usr/local/include \ +--api-headers "**/*" \ +--api-headers "!**/detail/**" \ +--api-headers "**/detail/public_api.h" ``` -is equivalent to: +This is equivalent to the gitignore rule sequence: -```text ---includes /usr/local/include --include-patterns *.h --exclude-patterns detail/** -``` +| Pattern | Effect | +| --- | --- | +| `**/*` | Include all headers | +| `!**/detail/**` | Exclude all headers under `detail/` | +| `**/detail/public_api.h` | Re-include `detail/public_api.h` specifically | ## Platform Support diff --git a/docs/user_guide/msbuild-integration.md b/docs/user_guide/msbuild-integration.md index 20c5e8c..bd67c66 100644 --- a/docs/user_guide/msbuild-integration.md +++ b/docs/user_guide/msbuild-integration.md @@ -38,13 +38,13 @@ the project file extension. | Property | Default | Description | | --- | --- | --- | -| `ApiMarkIncludePaths` | _(required)_ | Semicolon-separated list of public include directories. Entries with `*` or `?` are forwarded as `--include-patterns`; entries starting with `!` are forwarded as `--exclude-patterns` (with `!` stripped). | +| `ApiMarkIncludePaths` | _(required)_ | Semicolon-separated list of include directory paths. Each entry is passed to Clang as a `-I` path and serves as the base for the default header glob. | +| `ApiMarkApiHeaders` | _(unset)_ | Semicolon-separated, order-preserved list of glob and antipattern strings. Entries with `!` are exclusion antipatterns; 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 | -| `ApiMarkSearchPaths` | _(unset)_ | Semicolon-separated compiler-only `-I` paths for `#include` resolution; declarations are never documented | ## Configuration Examples @@ -91,11 +91,11 @@ the project file extension. - - + + - - + + ``` diff --git a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs index b7ffbf3..429b351 100644 --- a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs +++ b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs @@ -342,7 +342,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 +366,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) { diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 13629d2..a9780ce 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; @@ -171,31 +170,53 @@ 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 unconfigured runs. + /// + /// + /// 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. + /// + /// + /// A single-pattern is created per pattern check; patterns + /// are evaluated relative to each root directory using + /// from Microsoft.Extensions.FileSystemGlobbing. + /// /// /// /// 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++", }; + // Use the caller-supplied patterns or the default extension-based catch-all when none + // are configured — the default produces the same set as the old extension-filter-only path + var effectivePatterns = _options.ApiHeaderPatterns.Count > 0 + ? _options.ApiHeaderPatterns + : (IList)new List { "**/*.h", "**/*.hpp", "**/*.hxx", "**/*.h++" }; + var headers = new List(); foreach (var root in _options.PublicIncludeRoots) { @@ -206,53 +227,48 @@ 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) + // Apply gitignore-style evaluation for each header file + foreach (var absoluteFile in allFiles) { - // Build a Matcher whose patterns are relative to the root directory - var matcher = new Matcher(); + // Start with the file excluded — it must explicitly match an include pattern + var included = false; - if (_options.IncludePatterns.Count > 0) + foreach (var pattern in effectivePatterns) { - // Caller-supplied include patterns define the set of accepted headers - foreach (var pattern in _options.IncludePatterns) + if (pattern.StartsWith("!", StringComparison.Ordinal)) { - matcher.AddInclude(pattern); + // Exclusion antipattern: strip the '!' prefix and check whether the + // file matches — if it does, mark it excluded (included = false) + var exclusionGlob = pattern.Substring(1).Trim(); + if (exclusionGlob.Length > 0 && MatchesGlob(exclusionGlob, rootAbsolute, absoluteFile)) + { + included = false; + } + } + else + { + // Inclusion pattern: a match sets included=true, and a later exclusion + // antipattern can still override this to produce gitignore semantics + if (MatchesGlob(pattern, rootAbsolute, absoluteFile)) + { + included = true; + } } - } - 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) + if (included) { - matcher.AddExclude(pattern); + 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); } } @@ -266,6 +282,40 @@ private List CollectHeaderFiles() .ToList(); } + /// + /// Tests whether a single header file matches a glob pattern, evaluated relative to + /// the specified include root directory. + /// + /// + /// Creates a single-pattern per call so that each pattern + /// in the ordered list is + /// evaluated independently, which is required to correctly implement gitignore-style + /// last-match-wins semantics. + /// + /// + /// A glob pattern relative to , e.g. "**/*.h" + /// or "**/detail/**". Must not be null or empty. + /// + /// + /// Absolute path of the include root directory that serves as the pattern base. + /// + /// + /// Absolute path of the header file to test against the pattern. + /// + /// + /// when matches + /// relative to . + /// + private static bool MatchesGlob(string glob, string rootAbsolute, string absoluteFile) + { + // A single-pattern matcher is created per call so each pattern in the ordered + // ApiHeaderPatterns list is tested in isolation — this is what enables the + // last-match-wins (gitignore) semantics in the caller + var matcher = new Matcher(); + matcher.AddInclude(glob); + return matcher.Match(rootAbsolute, absoluteFile).HasMatches; + } + // ========================================================================= // Error checking // ========================================================================= diff --git a/src/ApiMark.Cpp/CppGeneratorOptions.cs b/src/ApiMark.Cpp/CppGeneratorOptions.cs index d574b6f..404af18 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 antipattern 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 exclude antipatterns (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.MSBuild/ApiMarkTask.cs b/src/ApiMark.MSBuild/ApiMarkTask.cs index 96a515d..08f8da8 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,15 +151,17 @@ public sealed class ApiMarkTask : Task /// public string? ApiMarkClangPath { get; set; } - /// Gets or sets the semicolon-separated list of compiler-only search paths for C++. + /// Gets or sets the semicolon-separated, order-preserved list of glob and antipattern strings for C++ header selection. /// - /// Used for the cpp language only. Maps to $(ApiMarkSearchPaths). - /// These paths are passed to Clang as -I flags for #include resolution only; - /// declarations from these paths are never documented. - /// Semicolons are converted to commas for the --search-paths tool argument. - /// Optional — omitted when empty or not set. + /// 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 antipatterns; 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? ApiMarkSearchPaths { get; set; } + public string? ApiMarkApiHeaders { get; set; } /// /// Gets or sets the full path to ApiMark.Tool.dll bundled inside the NuGet package. @@ -225,12 +229,10 @@ internal IReadOnlyList BuildArguments(string language) } else { - // Parse ApiMarkIncludePaths into three buckets: plain roots, glob patterns, exclusions. - // MSBuild uses semicolons as its list separator; each entry is trimmed before classification. - var roots = new List(); - var includePatterns = new List(); - var excludePatterns = new List(); + args.Add("cpp"); + // 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(';')) @@ -243,44 +245,27 @@ internal IReadOnlyList BuildArguments(string language) continue; } - if (entry.StartsWith("!")) - { - // Strip the '!' prefix, trim whitespace, and skip bare '!' entries - var pattern = entry.Substring(1).Trim(); - if (pattern.Length > 0) - { - excludePatterns.Add(pattern); - } - } - else if (entry.Contains("*") || entry.Contains("?")) - { - // Glob wildcards mark the entry as an include-filter pattern - includePatterns.Add(entry); - } - else - { - // Plain paths are public include root directories - roots.Add(entry); - } + args.Add("--includes"); + args.Add(entry); } } - args.Add("cpp"); - args.Add("--includes"); - args.Add(string.Join(",", roots)); - - // Forward include-filter patterns only when present — absent means all headers are included - if (includePatterns.Count > 0) + // Emit one --api-headers flag per pattern entry, order-preserved including ! antipatterns + if (!string.IsNullOrEmpty(ApiMarkApiHeaders)) { - args.Add("--include-patterns"); - args.Add(string.Join(",", includePatterns)); - } + 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; + } - // Forward exclusion patterns only when present - if (excludePatterns.Count > 0) - { - args.Add("--exclude-patterns"); - args.Add(string.Join(",", excludePatterns)); + args.Add("--api-headers"); + args.Add(entry); + } } // Library name (defaults to project name via .targets) @@ -318,14 +303,6 @@ internal IReadOnlyList BuildArguments(string language) args.Add("--clang-path"); args.Add(ApiMarkClangPath!); } - - // Compiler-only search paths — semicolons converted to commas - if (!string.IsNullOrEmpty(ApiMarkSearchPaths)) - { - var commaSearchPaths = ApiMarkSearchPaths!.Replace(';', ','); - args.Add("--search-paths"); - args.Add(commaSearchPaths); - } } // Optional: output directory diff --git a/src/ApiMark.Tool/Cli/Context.cs b/src/ApiMark.Tool/Cli/Context.cs index 4e83abf..4d6769e 100644 --- a/src/ApiMark.Tool/Cli/Context.cs +++ b/src/ApiMark.Tool/Cli/Context.cs @@ -64,30 +64,18 @@ internal sealed class Context : IContext, IDisposable /// /// Gets the include directory paths for the C++ language subcommand. - /// Contains only plain path entries from --includes (no wildcards, no !). + /// 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 compiler-only search path directories for the C++ language subcommand. - /// Passed to Clang as -I paths; declarations from these paths are never documented. + /// Gets the ordered list of glob and antipattern strings for the C++ language subcommand. + /// Collected from repeated --api-headers invocations; entries with a ! + /// prefix are exclusion antipatterns. Order is significant — gitignore semantics apply + /// (last matching pattern wins). /// - public string[] SearchPaths { get; private init; } = []; - - /// - /// Gets the glob patterns selecting which header files contribute to the documented API, - /// relative to each root. - /// Populated from --include-patterns or from wildcard entries inline in --includes. - /// When empty, all headers under the roots are included. - /// - public string[] IncludePatterns { get; private init; } = []; - - /// - /// Gets the glob patterns for header files to exclude from the documented API, - /// relative to each root. Evaluated after . - /// Populated from --exclude-patterns or from !-prefixed entries inline in --includes. - /// - public string[] ExcludePatterns { get; private init; } = []; + public string[] ApiHeaders { get; private init; } = []; /// /// Gets the output directory for generated Markdown files. @@ -175,10 +163,8 @@ public static Context Create(string[] args) Language = parser.Language, Assembly = parser.Assembly, XmlDoc = parser.XmlDoc, - Includes = parser.Includes, - SearchPaths = parser.SearchPaths, - IncludePatterns = parser.IncludePatterns, - ExcludePatterns = parser.ExcludePatterns, + Includes = [.. parser.Includes], + ApiHeaders = [.. parser.ApiHeaders], Output = parser.Output, Visibility = parser.Visibility, IncludeObsolete = parser.IncludeObsolete, @@ -333,30 +319,19 @@ private sealed class ArgumentParser public string? XmlDoc { get; private set; } /// - /// Gets the include directory paths for C++. - /// Contains only plain path entries (no wildcards, no !). - /// - public string[] Includes { get; private set; } = []; - - /// - /// Gets the compiler-only search paths parsed from the --search-paths comma-separated list. - /// Passed to Clang as -I paths for #include resolution; declarations from these - /// paths are never documented. + /// Gets the include directory paths for the C++ language subcommand. + /// Accumulated by repeated --includes invocations; each invocation appends + /// one plain directory path. /// - public string[] SearchPaths { get; private set; } = []; + public List Includes { get; } = new List(); /// - /// Gets the include glob patterns parsed from --include-patterns or from - /// glob entries inline in --includes. Passed to CppGeneratorOptions.IncludePatterns. + /// Gets the ordered list of glob and antipattern 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 string[] IncludePatterns { get; private set; } = []; - - /// - /// Gets the exclude glob patterns parsed from --exclude-patterns or from - /// !-prefixed entries inline in --includes. Passed to - /// CppGeneratorOptions.ExcludePatterns. - /// - public string[] ExcludePatterns { get; private set; } = []; + public List ApiHeaders { get; } = new List(); /// /// Gets the output directory. @@ -479,29 +454,21 @@ private int ParseArgument(string arg, string[] args, int index) case "--includes": { - // Classify each entry into: plain root paths, include-filter globs, or exclusion patterns - var raw = GetRequiredStringArgument(arg, args, index, "a comma-separated path list argument"); - ClassifyIncludeEntries(raw, out var roots, out var includePatterns, out var excludePatterns); - Includes = roots; - IncludePatterns = includePatterns; - ExcludePatterns = excludePatterns; + // 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 "--search-paths": - SearchPaths = GetRequiredStringArgument(arg, args, index, "a comma-separated path list argument") - .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - return index + 1; - - case "--include-patterns": - IncludePatterns = GetRequiredStringArgument(arg, args, index, "a comma-separated glob pattern argument") - .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - return index + 1; - - case "--exclude-patterns": - ExcludePatterns = GetRequiredStringArgument(arg, args, index, "a comma-separated glob pattern argument") - .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - 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"); @@ -548,51 +515,6 @@ private int ParseArgument(string arg, string[] args, int index) } } - /// - /// Classifies a comma-separated include list into three buckets. - /// - /// Raw comma-separated string from --includes. - /// Receives plain directory paths (no wildcards, no !). - /// Receives glob patterns (containing * or ?). - /// Receives !-stripped exclusion patterns. - private static void ClassifyIncludeEntries( - string value, - out string[] roots, - out string[] includePatterns, - out string[] excludePatterns) - { - var rootList = new List(); - var includeList = new List(); - var excludeList = new List(); - - foreach (var entry in value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - if (entry.StartsWith('!')) - { - // Strip the '!' prefix, trim whitespace, and skip bare '!' entries - var pattern = entry[1..].Trim(); - if (pattern.Length > 0) - { - excludeList.Add(pattern); - } - } - else if (entry.Contains('*') || entry.Contains('?')) - { - // Entries with glob wildcards select which headers to document - includeList.Add(entry); - } - else - { - // Plain paths are public include root directories - rootList.Add(entry); - } - } - - roots = [.. rootList]; - includePatterns = [.. includeList]; - excludePatterns = [.. excludeList]; - } - /// /// Gets a required string argument value from the argument array. /// diff --git a/src/ApiMark.Tool/Program.cs b/src/ApiMark.Tool/Program.cs index 088fe2d..1d8403a 100644 --- a/src/ApiMark.Tool/Program.cs +++ b/src/ApiMark.Tool/Program.cs @@ -246,14 +246,12 @@ private static IApiGenerator CreateGenerator(Context context) LibraryName = cppLibraryName, Description = context.LibraryDescription ?? string.Empty, PublicIncludeRoots = context.Includes, - IncludePatterns = context.IncludePatterns, - ExcludePatterns = context.ExcludePatterns, + ApiHeaderPatterns = context.ApiHeaders, Defines = context.Defines, CppStandard = context.CppStandard ?? "c++17", Visibility = (CppApiVisibility)(int)visibility, IncludeDeprecated = context.IncludeObsolete, ClangPath = context.ClangPath, - AdditionalIncludePaths = context.SearchPaths, }), // Any other token is an unrecognized subcommand @@ -302,16 +300,14 @@ 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"); context.WriteLine(" --defines Comma-separated preprocessor definitions (e.g. MYLIB_API=,NDEBUG)"); context.WriteLine(" --cpp-standard C++ language standard passed to Clang (default: c++17)"); context.WriteLine(" --clang-path Path to clang executable (default: auto-discovered via PATH / xcrun / vswhere)"); - context.WriteLine(" --search-paths (for #include resolution only; not included in generated output)"); - context.WriteLine(" --include-patterns

Comma-separated glob patterns selecting headers to document"); - context.WriteLine(" --exclude-patterns

Comma-separated glob patterns for headers to exclude"); context.WriteLine(" --visibility Visibility filter: Public, PublicAndProtected, All (default: Public)"); context.WriteLine(" --include-obsolete Include deprecated members in generated output"); } diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index a9d9c57..3b119d4 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -659,18 +659,18 @@ public void CppGenerator_Generate_FreeFunctionPage_ContainsIncludeDirective() } ///

- /// Validates that restricts header - /// enumeration to files matching at least one pattern. + /// Validates that with a specific + /// include pattern restricts header enumeration to files matching that pattern. /// [Fact] - public void CppGenerator_Generate_IncludePatterns_OnlyIncludesMatchingFiles() + 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); @@ -690,18 +690,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 antipattern in + /// excludes matching headers while + /// leaving all other headers documented. /// [Fact] - public void CppGenerator_Generate_ExcludePatterns_ExcludesMatchingFiles() + public void CppGenerator_Generate_ApiHeaderPatterns_ExcludeAntipattern_ExcludesMatchingFiles() { - // Arrange: exclude SampleClass.h to verify its type disappears from output + // Arrange: catch-all followed by an exclusion antipattern 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); @@ -709,10 +710,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 antipattern 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 antipattern"); // Assert: SampleStatus page must still exist because SampleEnum.h was not excluded Assert.True( @@ -720,6 +721,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 antipattern 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 antipattern 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 antipattern 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 antipattern 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 diff --git a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs index d6d840b..e5cfb24 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); } @@ -379,142 +386,36 @@ public void ApiMarkTask_BuildArguments_LibraryDescriptionWithDoubleQuote_PassedV } /// - /// Validates that appends the --search-paths - /// flag with semicolons converted to commas when - /// is set. - /// - [Fact] - public void ApiMarkTask_Cpp_SearchPaths_ForwardedToTool() - { - // Arrange: configure semicolon-separated search paths for a cpp invocation - var task = new ApiMarkTask - { - ProjectExtension = ".vcxproj", - ToolDllPath = "dummy.dll", - ApiMarkIncludePaths = "/include", - ApiMarkSearchPaths = "/sdk/include;/third-party/include", - }; - - // Act - var args = task.BuildArguments("cpp"); - - // Assert: the --search-paths flag must appear and semicolons must be converted to commas - Assert.Contains("--search-paths", args); - Assert.Contains("/sdk/include,/third-party/include", args); - Assert.DoesNotContain("/sdk/include;/third-party/include", args); - } - - /// - /// Validates that does not include the - /// --search-paths flag when is empty. + /// Validates that emits a separate + /// --api-headers flag for each pattern in , + /// preserving order and forwarding !-prefixed antipatterns verbatim. /// [Fact] - public void ApiMarkTask_Cpp_SearchPaths_Empty_NotIncludedInArgs() + public void ApiMarkTask_Cpp_ApiHeaders_ForwardedAsIndividualFlags() { - // Arrange: no search paths configured + // Arrange: two patterns — a catch-all followed by an exclusion antipattern var task = new ApiMarkTask { ProjectExtension = ".vcxproj", ToolDllPath = "dummy.dll", ApiMarkIncludePaths = "/include", - ApiMarkSearchPaths = string.Empty, - }; - - // Act - var args = task.BuildArguments("cpp"); - - // Assert: --search-paths must be absent when the property is empty - Assert.DoesNotContain("--search-paths", args); - } - - /// - /// Validates that glob entries in are forwarded - /// as --include-patterns and not included in --includes. - /// - [Fact] - public void ApiMarkTask_Cpp_IncludePaths_GlobEntries_ForwardedAsIncludePatterns() - { - // Arrange: a mix of a plain root and a glob pattern, semicolon-separated - var task = new ApiMarkTask - { - ProjectExtension = ".vcxproj", - ToolDllPath = "dummy.dll", - ApiMarkIncludePaths = "/include;*.h", + ApiMarkApiHeaders = "**/*.h;!**/detail/**", }; // Act var args = task.BuildArguments("cpp"); - // Assert: the glob is forwarded via --include-patterns, not embedded in --includes - Assert.Contains("--include-patterns", args); - Assert.Contains("*.h", args); - - // The --includes value must contain only the plain root - var includesIndex = args.ToList().IndexOf("--includes"); - Assert.True(includesIndex >= 0, "--includes must be present"); - Assert.Equal("/include", args[includesIndex + 1]); - } - - /// - /// Validates that !-prefixed entries in - /// are forwarded as --exclude-patterns (with the ! stripped) and not included - /// in --includes. - /// - [Fact] - public void ApiMarkTask_Cpp_IncludePaths_ExcludeEntries_ForwardedAsExcludePatterns() - { - // Arrange: a plain root and an exclusion pattern - var task = new ApiMarkTask - { - ProjectExtension = ".vcxproj", - ToolDllPath = "dummy.dll", - ApiMarkIncludePaths = "/include;!test/**", - }; - - // Act - var args = task.BuildArguments("cpp"); - - // Assert: the exclusion is forwarded via --exclude-patterns with '!' stripped - Assert.Contains("--exclude-patterns", args); - Assert.Contains("test/**", args); - Assert.DoesNotContain("!test/**", args); - } - - /// - /// Validates that a mixed value is correctly - /// split into --includes (plain roots), --include-patterns (globs), and - /// --exclude-patterns (exclusions). - /// - [Fact] - public void ApiMarkTask_Cpp_IncludePaths_Mixed_SplitIntoThreeBuckets() - { - // Arrange: plain root, glob pattern, and exclusion in a single semicolon-separated list - var task = new ApiMarkTask - { - ProjectExtension = ".vcxproj", - ToolDllPath = "dummy.dll", - ApiMarkIncludePaths = "/include;*.h;!test/**", - }; - - // Act - var args = task.BuildArguments("cpp"); - - // Assert: each entry must appear via its correct flag - Assert.Contains("--include-patterns", args); - Assert.Contains("--exclude-patterns", args); - + // Assert: each pattern must appear as its own --api-headers pair in order var argList = args.ToList(); - var includesIndex = argList.IndexOf("--includes"); - Assert.True(includesIndex >= 0, "--includes must be present"); - Assert.Equal("/include", argList[includesIndex + 1]); - - var includePatternIndex = argList.IndexOf("--include-patterns"); - Assert.True(includePatternIndex >= 0, "--include-patterns must be present"); - Assert.Equal("*.h", argList[includePatternIndex + 1]); - - var excludePatternIndex = argList.IndexOf("--exclude-patterns"); - Assert.True(excludePatternIndex >= 0, "--exclude-patterns must be present"); - Assert.Equal("test/**", argList[excludePatternIndex + 1]); + 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 antipattern 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 d62e5f4..25fb9f9 100644 --- a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs +++ b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs @@ -251,37 +251,37 @@ 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() { - // 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 + // (test name is retained for history; behavior changed from comma-splitting to repeatable flags) + 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,9 +313,7 @@ public void Context_Create_WithNoArguments_HasDefaultValues() () => Assert.False(context.IncludeObsolete), () => Assert.Equal(1, context.HeadingDepth), () => Assert.Empty(context.Includes), - () => Assert.Empty(context.SearchPaths), - () => Assert.Empty(context.IncludePatterns), - () => Assert.Empty(context.ExcludePatterns), + () => Assert.Empty(context.ApiHeaders), () => Assert.Equal(0, context.ExitCode)); } @@ -402,118 +400,87 @@ public void Context_Cli_ParsesAllGlobalFlags() } /// - /// Validates that --search-paths sets the SearchPaths property to the comma-split array. + /// Validates that repeated --includes flags accumulate into the + /// array in order. /// [Fact] - public void Context_Create_WithSearchPathsOption_SetsSearchPaths() + public void Context_Create_WithRepeatedIncludes_AccumulatesAllPaths() { - // Arrange: supply a comma-separated list of search paths - var args = new[] { "--search-paths", "/sdk/include,/third-party/include" }; + // 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: SearchPaths must contain the two split entries - string[] expected = ["/sdk/include", "/third-party/include"]; - Assert.Equal(expected, context.SearchPaths); + // 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 --include-patterns sets the IncludePatterns property. + /// Validates that repeated --api-headers flags accumulate into the + /// array in order, preserving ! antipatterns. /// [Fact] - public void Context_Create_WithIncludePatternsOption_SetsIncludePatterns() + public void Context_Create_WithRepeatedApiHeaders_AccumulatesAllPatternsInOrder() { - // Arrange: supply a comma-separated glob pattern list - var args = new[] { "--include-patterns", "*.h,**/*.hpp" }; - - // Act - using var context = Context.Create(args); - - // Assert: IncludePatterns must contain the two split patterns - string[] expected = ["*.h", "**/*.hpp"]; - Assert.Equal(expected, context.IncludePatterns); - } - - /// - /// Validates that --exclude-patterns sets the ExcludePatterns property. - /// - [Fact] - public void Context_Create_WithExcludePatternsOption_SetsExcludePatterns() - { - // Arrange: supply a comma-separated exclusion glob list - var args = new[] { "--exclude-patterns", "test/**,*_internal.h" }; - - // Act - using var context = Context.Create(args); - - // Assert: ExcludePatterns must contain the two split patterns - string[] expected = ["test/**", "*_internal.h"]; - Assert.Equal(expected, context.ExcludePatterns); - } - - /// - /// Validates that a wildcard entry inline in --includes is classified as an IncludePattern, - /// not added to Includes. - /// - [Fact] - public void Context_Create_WithIncludesContainingGlobEntry_ClassifiesAsIncludePattern() - { - // Arrange: --includes value with a plain root and a glob wildcard - var args = new[] { "--includes", "/usr/include,*.h" }; + // 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: plain path goes to Includes; the glob goes to IncludePatterns - Assert.Equal(["/usr/include"], context.Includes); - Assert.Equal(["*.h"], context.IncludePatterns); - Assert.Empty(context.ExcludePatterns); + // 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 entry inline in --includes is classified as an - /// ExcludePattern (with '!' stripped) and not added to Includes. + /// Validates that a !-prefixed antipattern is forwarded verbatim through + /// --api-headers without stripping the ! prefix. /// [Fact] - public void Context_Create_WithIncludesContainingExcludeEntry_ClassifiesAsExcludePattern() + public void Context_Create_WithApiHeadersAntipattern_ForwardsVerbatim() { - // Arrange: --includes value with a plain root and an exclusion pattern - var args = new[] { "--includes", "/usr/include,!test/**" }; + // Arrange: supply an exclusion antipattern via --api-headers + var args = new[] { "--api-headers", "!**/internal/**" }; // Act using var context = Context.Create(args); - // Assert: plain path goes to Includes; '!' entry with prefix stripped goes to ExcludePatterns - Assert.Equal(["/usr/include"], context.Includes); - Assert.Empty(context.IncludePatterns); - Assert.Equal(["test/**"], context.ExcludePatterns); + // Assert: the '!' prefix must be preserved so CppGenerator can apply gitignore semantics + Assert.Equal(["!**/internal/**"], context.ApiHeaders); } /// - /// Validates that a mixed --includes value is correctly classified into all three buckets. + /// Validates that --includes no longer accepts comma-separated lists and each + /// invocation appends exactly one plain directory path. /// [Fact] - public void Context_Create_WithIncludesMixed_ClassifiesIntoThreeBuckets() + public void Context_Create_WithSingleInclude_SetsSinglePath() { - // Arrange: --includes with one plain root, one glob, and one exclusion - var args = new[] { "--includes", "/usr/include,*.h,!test/**" }; + // Arrange: supply a single plain directory path via --includes + var args = new[] { "--includes", "/usr/include" }; // Act using var context = Context.Create(args); - // Assert: each entry must land in its correct bucket + // Assert: exactly one path must be in Includes with no ApiHeaders Assert.Equal(["/usr/include"], context.Includes); - Assert.Equal(["*.h"], context.IncludePatterns); - Assert.Equal(["test/**"], context.ExcludePatterns); + Assert.Empty(context.ApiHeaders); } /// - /// Validates that Context.Create with no arguments leaves SearchPaths, IncludePatterns, - /// and ExcludePatterns as empty arrays. + /// Validates that defaults + /// to an empty array when no --api-headers flags are supplied. /// [Fact] - public void Context_Create_WithNoArguments_HasEmptySearchPathsAndPatterns() + public void Context_Create_WithNoArguments_HasEmptyApiHeaders() { // Arrange: empty argument array var args = Array.Empty(); @@ -521,10 +488,7 @@ public void Context_Create_WithNoArguments_HasEmptySearchPathsAndPatterns() // Act using var context = Context.Create(args); - // Assert: new properties must default to empty arrays - Assert.Multiple( - () => Assert.Empty(context.SearchPaths), - () => Assert.Empty(context.IncludePatterns), - () => Assert.Empty(context.ExcludePatterns)); + // 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 c082ced..0cbaa3e 100644 --- a/test/ApiMark.Tool.Tests/ProgramTests.cs +++ b/test/ApiMark.Tool.Tests/ProgramTests.cs @@ -345,43 +345,31 @@ public void Program_Main_WithHelpAfterSubcommand_PrintsHelpAndExitsZero() } /// - /// Validates that the --search-paths flag is recognized and does not cause an - /// argument-parsing exception; when --includes is absent the tool still fails - /// with the expected includes-required diagnostic. + /// Validates that the removed --search-paths flag is no longer recognized and + /// causes an to be thrown at parse time. /// [Fact] - public void Program_Main_CppWithSearchPathsFlag_FlagIsAccepted() + public void Program_Main_CppWithSearchPathsFlag_ThrowsArgumentException() { - // Arrange: provide --search-paths but omit --includes so parsing completes but validation fails + // 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]); + // Act / Assert: --search-paths must no longer be recognized and must cause a non-zero exit + var exitCode = Program.Main(["cpp", "--search-paths", "/sdk", "--output", outputDir]); - // Assert: fails due to missing --includes, confirming --search-paths was accepted by the parser - Assert.NotEqual(0, exitCode); - Assert.Contains("--includes", errorWriter.ToString(), StringComparison.Ordinal); - } - finally - { - Console.SetError(originalError); - } + // The unknown flag causes an ArgumentException that is caught and results in exit code 1 + Assert.NotEqual(0, exitCode); } /// - /// Validates that a glob-only --includes value is classified as an include pattern - /// rather than a root directory, so validation still fails with the includes-required diagnostic. + /// 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_CppWithGlobOnlyIncludes_FailsMissingRootValidation() + public void Program_Main_CppWithApiHeadersFlag_FlagIsAccepted() { - // Arrange: --includes contains only a glob wildcard entry (no plain root directory) + // 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(); @@ -391,10 +379,10 @@ public void Program_Main_CppWithGlobOnlyIncludes_FailsMissingRootValidation() Console.SetError(errorWriter); // Act - var exitCode = Program.Main(["cpp", "--includes", "*.h", "--output", outputDir]); + var exitCode = Program.Main(["cpp", "--api-headers", "**/*.h", "--output", outputDir]); - // Assert: because *.h is classified as an include pattern (not a root), validation - // fails with the same --includes-required diagnostic as if --includes were absent + // 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); } @@ -405,38 +393,22 @@ public void Program_Main_CppWithGlobOnlyIncludes_FailsMissingRootValidation() } /// - /// Validates that --include-patterns and --exclude-patterns flags are - /// recognized by the parser and do not cause an argument exception. + /// Validates that the removed --include-patterns flag is no longer recognized + /// and causes a non-zero exit code. /// [Fact] - public void Program_Main_CppWithIncludeAndExcludePatternFlags_FlagsAreAccepted() + public void Program_Main_CppWithIncludePatternsFlag_ThrowsArgumentException() { - // Arrange: provide pattern flags but omit --includes so the tool fails on validation, - // confirming the new flags were accepted without throwing an ArgumentException + // 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", - "--exclude-patterns", "test/**", - "--output", outputDir, - ]); + // Act: the unknown flag causes an ArgumentException resulting in exit code 1 + var exitCode = Program.Main([ + "cpp", + "--include-patterns", "*.h", + "--output", outputDir, + ]); - // Assert: fails due to missing --includes (not due to unknown flag), - // confirming both pattern flags were accepted by the parser - Assert.NotEqual(0, exitCode); - Assert.Contains("--includes", errorWriter.ToString(), StringComparison.Ordinal); - } - finally - { - Console.SetError(originalError); - } + Assert.NotEqual(0, exitCode); } } From 231fbe60eb70fd36bc0578e67814c611267febdb Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 21:29:13 -0400 Subject: [PATCH 14/31] fix: update requirements traceability and design docs after header selection redesign - Remove stale requirements referencing deleted --search-paths and --include-patterns/--exclude-patterns tests - Add ApiMarkMsbuild-ApiMarkTask-ForwardApiHeaders requirement - Add ApiMarkCpp-CppGenerator-ApplyGitignoreStylePatternSelectionToHeaders requirement - Fix malformed ApiMarkApiHeaders/ApiMarkLibraryName entry in design doc - Update context.yaml requirements to reference new --api-headers tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-msbuild/api-mark-task.md | 5 +-- .../reqstream/api-mark-cpp/cpp-generator.yaml | 16 +++++++ .../api-mark-msbuild/api-mark-task.yaml | 27 ++++-------- docs/reqstream/api-mark-tool/cli/context.yaml | 42 +++++++++---------- 4 files changed, 45 insertions(+), 45 deletions(-) diff --git a/docs/design/api-mark-msbuild/api-mark-task.md b/docs/design/api-mark-msbuild/api-mark-task.md index bbb2707..fab78cb 100644 --- a/docs/design/api-mark-msbuild/api-mark-task.md +++ b/docs/design/api-mark-msbuild/api-mark-task.md @@ -66,6 +66,8 @@ order-preserved list of glob and antipattern strings forwarded as individual 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 top-level heading in `api.md`. The `.targets` file defaults this to `$(MSBuildProjectName)` when not explicitly set. @@ -89,9 +91,6 @@ passed to Clang (e.g. `c++17`, `c++20`). The `.targets` file defaults this to executable. Optional — when empty, clang is located automatically via PATH, xcrun (macOS), or vswhere (Windows). -**ApiMarkTask.ApiMarkSearchPaths**: `string` — removed in this version. Use -`ApiMarkIncludePaths` to pass all include directories to Clang. - **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. diff --git a/docs/reqstream/api-mark-cpp/cpp-generator.yaml b/docs/reqstream/api-mark-cpp/cpp-generator.yaml index 99ac203..2bd7a7f 100644 --- a/docs/reqstream/api-mark-cpp/cpp-generator.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-generator.yaml @@ -158,6 +158,22 @@ sections: 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 antipatterns, matching the + familiar gitignore convention. + tests: + - CppGenerator_Generate_ApiHeaderPatterns_IncludePattern_OnlyMatchingFilesDocumented + - CppGenerator_Generate_ApiHeaderPatterns_ExcludeAntipattern_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: | diff --git a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml index a280027..087d229 100644 --- a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml +++ b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml @@ -95,25 +95,14 @@ sections: build, parallel to the dotnet behavior when ApiMarkXmlDocPath is absent. tests: - ApiMarkTask_Cpp_EmptyIncludePaths_SkipsExecution - - id: ApiMarkMsbuild-ApiMarkTask-ForwardCppSearchPaths + - id: ApiMarkMsbuild-ApiMarkTask-ForwardApiHeaders title: >- - ApiMarkTask shall forward the ApiMarkSearchPaths property to the tool as the --search-paths - argument for C++ builds, converting semicolons to commas. - justification: | - C++ projects need compiler-only include paths for SDK or third-party headers that are - referenced by public headers but should not appear in the generated documentation. - tests: - - ApiMarkTask_Cpp_SearchPaths_ForwardedToTool - - ApiMarkTask_Cpp_SearchPaths_Empty_NotIncludedInArgs - - id: ApiMarkMsbuild-ApiMarkTask-ClassifyIncludePathsIntoThreeBuckets - title: >- - ApiMarkTask shall classify ApiMarkIncludePaths entries into --includes (plain paths), - --include-patterns (glob wildcards), and --exclude-patterns ('!'-prefixed entries). + ApiMarkTask shall forward each entry of ApiMarkApiHeaders to the tool as a separate + --api-headers flag for C++ builds, preserving order and forwarding '!'-prefixed + antipatterns verbatim. justification: | - MSBuild property lists use semicolons as separators and may mix plain directory roots - with glob patterns and exclusion entries. The task must classify and route each entry - to the correct tool argument so that the generator receives the right configuration. + 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_IncludePaths_GlobEntries_ForwardedAsIncludePatterns - - ApiMarkTask_Cpp_IncludePaths_ExcludeEntries_ForwardedAsExcludePatterns - - ApiMarkTask_Cpp_IncludePaths_Mixed_SplitIntoThreeBuckets + - 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 1f5a53d..04bcce5 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, --includes, --search-paths, --include-patterns, and --exclude-patterns. + 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,32 +40,28 @@ sections: - Context_Create_WithVisibilityOption_SetsVisibility - Context_Create_WithIncludeObsoleteFlag_SetsIncludeObsoleteTrue - Context_Create_WithIncludesOption_SetsIncludes - - Context_Create_WithSearchPathsOption_SetsSearchPaths - - Context_Create_WithIncludePatternsOption_SetsIncludePatterns - - Context_Create_WithExcludePatternsOption_SetsExcludePatterns - - Context_Create_WithIncludesContainingGlobEntry_ClassifiesAsIncludePattern - - Context_Create_WithIncludesContainingExcludeEntry_ClassifiesAsExcludePattern - - Context_Create_WithIncludesMixed_ClassifiesIntoThreeBuckets - - id: ApiMarkTool-Cli-Context-ParseCppSearchPaths - title: Context shall parse --search-paths and set SearchPaths to the comma-split array. + - 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 need to specify compiler-only -I paths for #include resolution - without those paths contributing to the documented API surface. + 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_WithSearchPathsOption_SetsSearchPaths - - id: ApiMarkTool-Cli-Context-ParseIncludeExcludePatterns - title: Context shall parse --include-patterns and --exclude-patterns and classify inline wildcards and '!'-prefixed entries in --includes into the correct buckets. + - 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 antipatterns verbatim. justification: | - Projects need fine-grained control over which headers contribute to the documented - API surface. Inline classification in --includes provides a concise syntax while - explicit --include-patterns and --exclude-patterns flags allow MSBuild to pass - pre-classified values directly. + C++ projects express gitignore-style header selection as an ordered sequence of + include patterns and '!'-prefixed antipatterns. The context must accumulate all + supplied patterns in order and forward antipatterns verbatim so CppGenerator can + apply last-match-wins semantics correctly. tests: - - Context_Create_WithIncludePatternsOption_SetsIncludePatterns - - Context_Create_WithExcludePatternsOption_SetsExcludePatterns - - Context_Create_WithIncludesContainingGlobEntry_ClassifiesAsIncludePattern - - Context_Create_WithIncludesContainingExcludeEntry_ClassifiesAsExcludePattern - - Context_Create_WithIncludesMixed_ClassifiesIntoThreeBuckets + - Context_Create_WithRepeatedApiHeaders_AccumulatesAllPatternsInOrder + - Context_Create_WithApiHeadersAntipattern_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: | From bd1b69505ea65b2e8bc91290df5fafb33394fb06 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 21:51:25 -0400 Subject: [PATCH 15/31] fix: replace antipattern/unconfigured with cspell-clean terms and update stale requirements - Replace 'antipattern'/'antipatterns' with 'exclusion pattern'/'exclusion patterns' throughout source, tests, and docs to satisfy cspell dictionary requirements - Replace 'unconfigured' with 'runs with no configured patterns' in CppGenerator - Rename test methods to use cspell-clean identifiers: ExcludeAntipattern -> ExcludePattern, WithApiHeadersAntipattern -> WithApiHeadersExclusionPattern - Update reqstream test references to match renamed methods - Update docs/reqstream/api-mark-tool/program.yaml to reflect --api-headers flag (replaces stale --search-paths/--include-patterns/--exclude-patterns references) - Update docs/design/api-mark-tool/cli.md to remove stale flag enumeration - Add CppGenerator_Generate_NoApiHeaderPatterns_DocumentsAllHeaders test for explicit default-behavior coverage (no patterns -> all headers documented) - Add new test to reqstream traceability for ApiMarkCpp-CppGenerator-ApplyGitignoreStylePatternSelectionToHeaders Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp/cpp-generator.md | 4 +- docs/design/api-mark-msbuild/api-mark-task.md | 6 +-- docs/design/api-mark-tool.md | 2 +- docs/design/api-mark-tool/cli.md | 14 +++-- .../reqstream/api-mark-cpp/cpp-generator.yaml | 5 +- .../api-mark-msbuild/api-mark-task.yaml | 2 +- docs/reqstream/api-mark-tool/cli/context.yaml | 8 +-- docs/reqstream/api-mark-tool/program.yaml | 12 ++--- docs/user_guide/cli-reference.md | 2 +- docs/user_guide/msbuild-integration.md | 2 +- src/ApiMark.Cpp/CppGenerator.cs | 6 +-- src/ApiMark.Cpp/CppGeneratorOptions.cs | 4 +- src/ApiMark.MSBuild/ApiMarkTask.cs | 6 +-- src/ApiMark.Tool/Cli/Context.cs | 6 +-- test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 51 +++++++++++++++---- .../ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs | 6 +-- test/ApiMark.Tool.Tests/Cli/ContextTests.cs | 8 +-- 17 files changed, 87 insertions(+), 57 deletions(-) diff --git a/docs/design/api-mark-cpp/cpp-generator.md b/docs/design/api-mark-cpp/cpp-generator.md index 85116d5..9873bed 100644 --- a/docs/design/api-mark-cpp/cpp-generator.md +++ b/docs/design/api-mark-cpp/cpp-generator.md @@ -45,10 +45,10 @@ When multiple roots are configured and a file matches more than one, the longest matching root path (most specific) wins. **CppGeneratorOptions.ApiHeaderPatterns**: `IList` — ordered list of -glob and antipattern strings that determine which header files contribute to the +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 antipatterns (the `!` +include patterns; entries with a `!` prefix are exclusion patterns (the `!` is stripped before glob matching). When empty, the default patterns `["**/*.h", "**/*.hpp", "**/*.hxx", "**/*.h++"]` are applied, which selects all headers with recognized C++ extensions under all roots. diff --git a/docs/design/api-mark-msbuild/api-mark-task.md b/docs/design/api-mark-msbuild/api-mark-task.md index fab78cb..29a3945 100644 --- a/docs/design/api-mark-msbuild/api-mark-task.md +++ b/docs/design/api-mark-msbuild/api-mark-task.md @@ -61,8 +61,8 @@ default header glob when `ApiMarkApiHeaders` is not set. **ApiMarkTask.ApiMarkApiHeaders**: `string` — MSBuild property `$(ApiMarkApiHeaders)`; for the `cpp` language, a semicolon-separated, -order-preserved list of glob and antipattern strings forwarded as individual -`--api-headers` flags. Entries with a `!` prefix are exclusion antipatterns; +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. @@ -120,7 +120,7 @@ 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`, split `ApiMarkIncludePaths` on `;` and emit one `--includes` flag per entry; split `ApiMarkApiHeaders` on `;` and emit one `--api-headers` -flag per entry, order-preserved including `!` antipatterns; if `ApiMarkLibraryName` +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`; diff --git a/docs/design/api-mark-tool.md b/docs/design/api-mark-tool.md index d07ed0e..e3058f3 100644 --- a/docs/design/api-mark-tool.md +++ b/docs/design/api-mark-tool.md @@ -40,7 +40,7 @@ users or CI pipelines. Options for `dotnet`: `--assembly `, `--xml-doc `, `--output `, `--visibility `, `--include-obsolete`. Options for `cpp`: `--includes ` (repeatable), `--api-headers ` - (repeatable, ordered, supports `!` exclusion antipatterns), + (repeatable, ordered, supports `!` exclusion patterns), `--library-name `, `--library-description `, `--defines `, `--cpp-standard `, `--output `, `--visibility `, `--include-obsolete`. diff --git a/docs/design/api-mark-tool/cli.md b/docs/design/api-mark-tool/cli.md index 2493df1..9335a41 100644 --- a/docs/design/api-mark-tool/cli.md +++ b/docs/design/api-mark-tool/cli.md @@ -25,10 +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`, `SearchPaths`, `IncludePatterns`, - `ExcludePatterns`, `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**: @@ -44,10 +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`, `--search-paths`, `--include-patterns`, -`--exclude-patterns`, `--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/reqstream/api-mark-cpp/cpp-generator.yaml b/docs/reqstream/api-mark-cpp/cpp-generator.yaml index 2bd7a7f..20e81a1 100644 --- a/docs/reqstream/api-mark-cpp/cpp-generator.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-generator.yaml @@ -167,11 +167,12 @@ sections: 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 antipatterns, matching the + 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_ExcludeAntipattern_ExcludesMatchingFiles + - CppGenerator_Generate_ApiHeaderPatterns_ExcludePattern_ExcludesMatchingFiles - CppGenerator_Generate_ApiHeaderPatterns_ReInclude_GitignoreSemantics_IncludesReIncludedHeader - CppGenerator_Generate_ApiHeaderPatterns_ExcludeWithoutReInclude_ExcludesHeader - id: ApiMarkCpp-CppGenerator-EmitExternalTypesSection diff --git a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml index 087d229..848399d 100644 --- a/docs/reqstream/api-mark-msbuild/api-mark-task.yaml +++ b/docs/reqstream/api-mark-msbuild/api-mark-task.yaml @@ -99,7 +99,7 @@ sections: 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 - antipatterns verbatim. + 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 diff --git a/docs/reqstream/api-mark-tool/cli/context.yaml b/docs/reqstream/api-mark-tool/cli/context.yaml index 04bcce5..7fcfacf 100644 --- a/docs/reqstream/api-mark-tool/cli/context.yaml +++ b/docs/reqstream/api-mark-tool/cli/context.yaml @@ -52,15 +52,15 @@ sections: - 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 antipatterns verbatim. + 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 antipatterns. The context must accumulate all - supplied patterns in order and forward antipatterns verbatim so CppGenerator can + 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_WithApiHeadersAntipattern_ForwardsVerbatim + - 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. diff --git a/docs/reqstream/api-mark-tool/program.yaml b/docs/reqstream/api-mark-tool/program.yaml index fb5fdb1..63d053d 100644 --- a/docs/reqstream/api-mark-tool/program.yaml +++ b/docs/reqstream/api-mark-tool/program.yaml @@ -30,20 +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, --cpp-standard, --search-paths, --include-patterns, and --exclude-patterns 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. The --search-paths option allows compiler-only -I paths to be - specified without contributing to the documented API surface. The --include-patterns - and --exclude-patterns options provide fine-grained glob filtering of headers. + 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_CppWithSearchPathsFlag_FlagIsAccepted - - Program_Main_CppWithGlobOnlyIncludes_FailsMissingRootValidation - - Program_Main_CppWithIncludeAndExcludePatternFlags_FlagsAreAccepted + - 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/user_guide/cli-reference.md b/docs/user_guide/cli-reference.md index 1f6608b..2e74e7d 100644 --- a/docs/user_guide/cli-reference.md +++ b/docs/user_guide/cli-reference.md @@ -60,7 +60,7 @@ are passed to Clang as `-I` paths and serve as the base for the default `--api-h `--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 antipatterns. +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. diff --git a/docs/user_guide/msbuild-integration.md b/docs/user_guide/msbuild-integration.md index bd67c66..5664908 100644 --- a/docs/user_guide/msbuild-integration.md +++ b/docs/user_guide/msbuild-integration.md @@ -39,7 +39,7 @@ the project file extension. | Property | Default | Description | | --- | --- | --- | | `ApiMarkIncludePaths` | _(required)_ | Semicolon-separated list of include directory paths. Each entry is passed to Clang as a `-I` path and serves as the base for the default header glob. | -| `ApiMarkApiHeaders` | _(unset)_ | Semicolon-separated, order-preserved list of glob and antipattern strings. Entries with `!` are exclusion antipatterns; gitignore-style last-match-wins semantics apply. When unset, all headers with recognized C++ extensions under `ApiMarkIncludePaths` 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)_ | Semicolon-separated preprocessor definitions (e.g. `MYLIB_API=;NDEBUG`) | diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index a9780ce..468cff2 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -182,7 +182,7 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) /// /// When is empty the default /// patterns ["**/*.h", "**/*.hpp", "**/*.hxx", "**/*.h++"] are used, - /// preserving the behavior of unconfigured runs. + /// preserving the behavior of runs with no configured patterns. /// /// /// Gitignore-style evaluation: for each candidate file, start with @@ -246,7 +246,7 @@ private List CollectHeaderFiles() { if (pattern.StartsWith("!", StringComparison.Ordinal)) { - // Exclusion antipattern: strip the '!' prefix and check whether the + // Exclusion pattern: strip the '!' prefix and check whether the // file matches — if it does, mark it excluded (included = false) var exclusionGlob = pattern.Substring(1).Trim(); if (exclusionGlob.Length > 0 && MatchesGlob(exclusionGlob, rootAbsolute, absoluteFile)) @@ -257,7 +257,7 @@ private List CollectHeaderFiles() else { // Inclusion pattern: a match sets included=true, and a later exclusion - // antipattern can still override this to produce gitignore semantics + // pattern can still override this to produce gitignore semantics if (MatchesGlob(pattern, rootAbsolute, absoluteFile)) { included = true; diff --git a/src/ApiMark.Cpp/CppGeneratorOptions.cs b/src/ApiMark.Cpp/CppGeneratorOptions.cs index 404af18..5f71460 100644 --- a/src/ApiMark.Cpp/CppGeneratorOptions.cs +++ b/src/ApiMark.Cpp/CppGeneratorOptions.cs @@ -35,14 +35,14 @@ public sealed class CppGeneratorOptions public IReadOnlyList PublicIncludeRoots { get; set; } = []; /// - /// Gets or sets the ordered list of glob and antipattern strings that define which + /// Gets or sets the ordered list of glob and exclusion pattern strings that define which /// headers appear in the generated documentation. /// /// /// /// 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 exclude antipatterns (the ! is stripped + /// entries with a ! prefix are exclusion patterns (the ! is stripped /// before glob matching). /// /// diff --git a/src/ApiMark.MSBuild/ApiMarkTask.cs b/src/ApiMark.MSBuild/ApiMarkTask.cs index 08f8da8..1f950de 100644 --- a/src/ApiMark.MSBuild/ApiMarkTask.cs +++ b/src/ApiMark.MSBuild/ApiMarkTask.cs @@ -151,11 +151,11 @@ public sealed class ApiMarkTask : Task /// public string? ApiMarkClangPath { get; set; } - /// Gets or sets the semicolon-separated, order-preserved list of glob and antipattern strings for C++ header selection. + /// 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 antipatterns; entries without are include + /// 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 @@ -250,7 +250,7 @@ internal IReadOnlyList BuildArguments(string language) } } - // Emit one --api-headers flag per pattern entry, order-preserved including ! antipatterns + // Emit one --api-headers flag per pattern entry, order-preserved including ! exclusion patterns if (!string.IsNullOrEmpty(ApiMarkApiHeaders)) { foreach (var rawEntry in ApiMarkApiHeaders!.Split(';')) diff --git a/src/ApiMark.Tool/Cli/Context.cs b/src/ApiMark.Tool/Cli/Context.cs index 4d6769e..38f8bed 100644 --- a/src/ApiMark.Tool/Cli/Context.cs +++ b/src/ApiMark.Tool/Cli/Context.cs @@ -70,9 +70,9 @@ internal sealed class Context : IContext, IDisposable public string[] Includes { get; private init; } = []; /// - /// Gets the ordered list of glob and antipattern strings for the C++ language subcommand. + /// 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 antipatterns. Order is significant — gitignore semantics apply + /// prefix are exclusion patterns. Order is significant — gitignore semantics apply /// (last matching pattern wins). /// public string[] ApiHeaders { get; private init; } = []; @@ -326,7 +326,7 @@ private sealed class ArgumentParser public List Includes { get; } = new List(); /// - /// Gets the ordered list of glob and antipattern strings for the C++ language subcommand. + /// 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. diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 3b119d4..3d390e8 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -658,6 +658,39 @@ public void CppGenerator_Generate_FreeFunctionPage_ContainsIncludeDirective() s => s.Contains("#include", StringComparison.Ordinal)); } + /// + /// Validates that when no are set, + /// all headers under the include roots with recognized C++ extensions are documented. + /// + [Fact] + 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. @@ -690,14 +723,14 @@ public void CppGenerator_Generate_ApiHeaderPatterns_IncludePattern_OnlyMatchingF } /// - /// Validates that a !-prefixed antipattern in + /// Validates that a !-prefixed exclusion pattern in /// excludes matching headers while /// leaving all other headers documented. /// [Fact] - public void CppGenerator_Generate_ApiHeaderPatterns_ExcludeAntipattern_ExcludesMatchingFiles() + public void CppGenerator_Generate_ApiHeaderPatterns_ExcludePattern_ExcludesMatchingFiles() { - // Arrange: catch-all followed by an exclusion antipattern for SampleClass.h + // Arrange: catch-all followed by an exclusion pattern for SampleClass.h var options = new CppGeneratorOptions { LibraryName = "Fixtures", @@ -710,10 +743,10 @@ public void CppGenerator_Generate_ApiHeaderPatterns_ExcludeAntipattern_ExcludesM // Act generator.Generate(factory, new InMemoryContext()); - // Assert: SampleClass page must not exist because its header was excluded by the antipattern + // 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 by antipattern"); + "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( @@ -723,7 +756,7 @@ public void CppGenerator_Generate_ApiHeaderPatterns_ExcludeAntipattern_ExcludesM /// /// Validates gitignore-style re-include semantics: a header that is first excluded by - /// an antipattern and then re-included by a later positive pattern is documented. + /// an exclusion pattern and then re-included by a later positive pattern is documented. /// [Fact] public void CppGenerator_Generate_ApiHeaderPatterns_ReInclude_GitignoreSemantics_IncludesReIncludedHeader() @@ -750,13 +783,13 @@ public void CppGenerator_Generate_ApiHeaderPatterns_ReInclude_GitignoreSemantics } /// - /// Validates that an exclusion antipattern without a subsequent re-include permanently + /// 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 antipattern for DeprecatedClass.h (no re-include); + // 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 { @@ -771,7 +804,7 @@ public void CppGenerator_Generate_ApiHeaderPatterns_ExcludeWithoutReInclude_Excl // Act generator.Generate(factory, new InMemoryContext()); - // Assert: DeprecatedClass must not be documented because the exclusion antipattern is final + // 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"); diff --git a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs index e5cfb24..1f542bc 100644 --- a/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs +++ b/test/ApiMark.MSBuild.Tests/ApiMarkTaskTests.cs @@ -388,12 +388,12 @@ public void ApiMarkTask_BuildArguments_LibraryDescriptionWithDoubleQuote_PassedV /// /// Validates that emits a separate /// --api-headers flag for each pattern in , - /// preserving order and forwarding !-prefixed antipatterns verbatim. + /// 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 antipattern + // Arrange: two patterns — a catch-all followed by an exclusion pattern var task = new ApiMarkTask { ProjectExtension = ".vcxproj", @@ -413,7 +413,7 @@ public void ApiMarkTask_Cpp_ApiHeaders_ForwardedAsIndividualFlags() Assert.NotEqual(firstIdx, lastIdx); Assert.Equal("**/*.h", argList[firstIdx + 1]); - // The antipattern must be forwarded verbatim with the ! prefix intact + // 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 25fb9f9..3be40a1 100644 --- a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs +++ b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs @@ -419,7 +419,7 @@ public void Context_Create_WithRepeatedIncludes_AccumulatesAllPaths() /// /// Validates that repeated --api-headers flags accumulate into the - /// array in order, preserving ! antipatterns. + /// array in order, preserving ! exclusion patterns. /// [Fact] public void Context_Create_WithRepeatedApiHeaders_AccumulatesAllPatternsInOrder() @@ -441,13 +441,13 @@ public void Context_Create_WithRepeatedApiHeaders_AccumulatesAllPatternsInOrder( } /// - /// Validates that a !-prefixed antipattern is forwarded verbatim through + /// Validates that a !-prefixed exclusion pattern is forwarded verbatim through /// --api-headers without stripping the ! prefix. /// [Fact] - public void Context_Create_WithApiHeadersAntipattern_ForwardsVerbatim() + public void Context_Create_WithApiHeadersExclusionPattern_ForwardsVerbatim() { - // Arrange: supply an exclusion antipattern via --api-headers + // Arrange: supply an exclusion pattern via --api-headers var args = new[] { "--api-headers", "!**/internal/**" }; // Act From 58f31c2b2c0041943eb90e8301b152ee07ad7d31 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 22:20:28 -0400 Subject: [PATCH 16/31] refactor: replace synthesized default globs and fix header pattern matching - CollectHeaderFiles: default (no ApiHeaderPatterns) now directly includes all headers under every root - no synthesized glob patterns - eliminating the path traversal issue when roots are outside CWD - MatchesGlob: prefer CWD-relative matching when the file is under CWD; fall back to root-relative matching for absolute include roots or test environments where CWD != project root - pattern wildcards work in both - All five include emitters in CppGenerator.cs now use double-quotes per C++ convention for user/library headers - Update README CLI Usage with --api-headers example (include/exclude/re-include) - Update cli-reference.md: accurate pattern-evaluation explanation - Update msbuild-integration.md: remove incorrect 'default header glob' wording - Update design docs: correct default-empty behavior and include quote style Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 10 +- docs/design/api-mark-cpp.md | 10 +- docs/design/api-mark-cpp/cpp-generator.md | 19 ++- docs/design/api-mark-msbuild/api-mark-task.md | 4 +- docs/user_guide/cli-reference.md | 24 ++- docs/user_guide/msbuild-integration.md | 2 +- src/ApiMark.Cpp/CppGenerator.cs | 157 +++++++++++------- 7 files changed, 146 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index c1bca07..01d5c4c 100644 --- a/README.md +++ b/README.md @@ -106,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 151b8eb..5e55dfb 100644 --- a/docs/design/api-mark-cpp.md +++ b/docs/design/api-mark-cpp.md @@ -83,10 +83,12 @@ N/A — not a safety-classified software item. Defines, CppStandard, AdditionalCompilerArguments, Visibility, IncludeDeprecated, and LibraryName, then passes an IMarkdownWriterFactory to Generate. -2. CppGenerator enumerates all header files under each PublicIncludeRoot, - applying ApiHeaderPatterns with gitignore-style last-match-wins semantics, - 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` diff --git a/docs/design/api-mark-cpp/cpp-generator.md b/docs/design/api-mark-cpp/cpp-generator.md index 9873bed..1ddf95f 100644 --- a/docs/design/api-mark-cpp/cpp-generator.md +++ b/docs/design/api-mark-cpp/cpp-generator.md @@ -38,7 +38,7 @@ two purposes: 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 @@ -49,12 +49,12 @@ glob and exclusion pattern strings that determine which header files contribute 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, the default patterns -`["**/*.h", "**/*.hpp", "**/*.hxx", "**/*.h++"]` are applied, which selects all -headers with recognized C++ extensions under all roots. +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. Example — all headers except `detail/`, with one re-included: -`["**/*", "!**/detail/**", "**/detail/public_api.h"]` +`["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 @@ -196,9 +196,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 ApiHeaderPatterns using gitignore-style - last-match-wins evaluation. Return owned=true only when the final - evaluated state is included. + 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 diff --git a/docs/design/api-mark-msbuild/api-mark-task.md b/docs/design/api-mark-msbuild/api-mark-task.md index 29a3945..847724c 100644 --- a/docs/design/api-mark-msbuild/api-mark-task.md +++ b/docs/design/api-mark-msbuild/api-mark-task.md @@ -56,8 +56,8 @@ 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 directory paths. Each entry is forwarded as an individual `--includes` -flag; all paths are passed to Clang as `-I` flags and serve as the base for the -default header glob when `ApiMarkApiHeaders` is not set. +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, diff --git a/docs/user_guide/cli-reference.md b/docs/user_guide/cli-reference.md index 2e74e7d..668efee 100644 --- a/docs/user_guide/cli-reference.md +++ b/docs/user_guide/cli-reference.md @@ -56,7 +56,8 @@ apimark cpp [options] #### `--includes` and `--api-headers` `--includes ` is repeatable — provide it once per include directory. All directories -are passed to Clang as `-I` paths and serve as the base for the default `--api-headers` glob. +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 @@ -65,22 +66,29 @@ 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 /usr/local/include \ ---api-headers "**/*" \ ---api-headers "!**/detail/**" \ ---api-headers "**/detail/public_api.h" +--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 all headers | -| `!**/detail/**` | Exclude all headers under `detail/` | -| `**/detail/public_api.h` | Re-include `detail/public_api.h` specifically | +| `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 | ## Platform Support diff --git a/docs/user_guide/msbuild-integration.md b/docs/user_guide/msbuild-integration.md index 5664908..7606361 100644 --- a/docs/user_guide/msbuild-integration.md +++ b/docs/user_guide/msbuild-integration.md @@ -38,7 +38,7 @@ the project file extension. | Property | Default | Description | | --- | --- | --- | -| `ApiMarkIncludePaths` | _(required)_ | Semicolon-separated list of include directory paths. Each entry is passed to Clang as a `-I` path and serves as the base for the default header glob. | +| `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 | diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 468cff2..5a6cfe9 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -194,9 +194,10 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) /// sequences that are not possible with separate include and exclude lists. /// /// - /// A single-pattern is created per pattern check; patterns - /// are evaluated relative to each root directory using - /// from Microsoft.Extensions.FileSystemGlobbing. + /// 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. /// /// /// @@ -211,11 +212,10 @@ private List CollectHeaderFiles() ".h", ".hpp", ".hxx", ".h++", }; - // Use the caller-supplied patterns or the default extension-based catch-all when none - // are configured — the default produces the same set as the old extension-filter-only path - var effectivePatterns = _options.ApiHeaderPatterns.Count > 0 - ? _options.ApiHeaderPatterns - : (IList)new List { "**/*.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. See MatchesGlob for details. + var cwdAbsolute = Path.GetFullPath(Directory.GetCurrentDirectory()); var headers = new List(); foreach (var root in _options.PublicIncludeRoots) @@ -236,38 +236,52 @@ private List CollectHeaderFiles() .Select(Path.GetFullPath) .ToList(); - // Apply gitignore-style evaluation for each header file - foreach (var absoluteFile in allFiles) + if (_options.ApiHeaderPatterns.Count == 0) { - // Start with the file excluded — it must explicitly match an include pattern - var included = false; - - foreach (var pattern in effectivePatterns) + // 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. + // Patterns are CWD-relative so "src/include/**" targets exactly one root tree + // regardless of how many --includes roots are configured; pure filename + // patterns like "**/foo.h" work correctly against both CWD and root-relative + // paths (see MatchesGlob for the fallback strategy). + foreach (var absoluteFile in allFiles) { - if (pattern.StartsWith("!", StringComparison.Ordinal)) + // Start with the file excluded — it must explicitly match an include pattern + var included = false; + + foreach (var pattern in _options.ApiHeaderPatterns) { - // Exclusion pattern: strip the '!' prefix and check whether the - // file matches — if it does, mark it excluded (included = false) - var exclusionGlob = pattern.Substring(1).Trim(); - if (exclusionGlob.Length > 0 && MatchesGlob(exclusionGlob, rootAbsolute, absoluteFile)) + if (pattern.StartsWith("!", StringComparison.Ordinal)) { - included = false; + // Exclusion pattern: strip the '!' prefix and check whether the + // file matches — if it does, mark it excluded (included = false) + var exclusionGlob = pattern.Substring(1).Trim(); + if (exclusionGlob.Length > 0 && MatchesGlob(exclusionGlob, cwdAbsolute, rootAbsolute, absoluteFile)) + { + included = false; + } } - } - else - { - // Inclusion pattern: a match sets included=true, and a later exclusion - // pattern can still override this to produce gitignore semantics - if (MatchesGlob(pattern, rootAbsolute, absoluteFile)) + else { - included = true; + // Inclusion pattern: a match sets included=true, and a later exclusion + // pattern can still override this to produce gitignore semantics + if (MatchesGlob(pattern, cwdAbsolute, rootAbsolute, absoluteFile)) + { + included = true; + } } } - } - if (included) - { - headers.Add(absoluteFile); + if (included) + { + headers.Add(absoluteFile); + } } } } @@ -283,37 +297,68 @@ private List CollectHeaderFiles() } /// - /// Tests whether a single header file matches a glob pattern, evaluated relative to - /// the specified include root directory. + /// Tests whether a single header file matches a glob pattern. /// /// - /// Creates a single-pattern per call so that each pattern - /// in the ordered list is - /// evaluated independently, which is required to correctly implement gitignore-style - /// last-match-wins semantics. + /// + /// Patterns are evaluated as relative paths in order to support gitignore-style + /// semantics. A single-pattern is created per call so that + /// each pattern in the ordered + /// list is evaluated independently. + /// + /// + /// The matching strategy is: + /// + /// + /// + /// If is under , + /// match the pattern against the CWD-relative path. This is the normal + /// production path and lets users write patterns like src/include/** + /// to target a specific root tree. + /// + /// + /// + /// + /// Otherwise fall back to matching against the path relative to + /// . This covers absolute include roots + /// (e.g. /usr/local/include) and test environments where the + /// include root is not under the CWD. Pure filename patterns such as + /// **/foo.h work correctly in both strategies. + /// + /// + /// + /// /// /// - /// A glob pattern relative to , e.g. "**/*.h" - /// or "**/detail/**". Must not be null or empty. - /// - /// - /// Absolute path of the include root directory that serves as the pattern base. - /// - /// - /// Absolute path of the header file to test against the pattern. + /// A glob pattern relative to (or + /// as fallback), e.g. + /// "src/include/**/*.h" or "**/foo.hpp". + /// Must not be null or empty. /// + /// Absolute path of the current working directory. + /// Absolute path of the include root for this file. + /// Absolute path of the header file to test. /// /// when matches - /// relative to . + /// . /// - private static bool MatchesGlob(string glob, string rootAbsolute, string absoluteFile) + private static bool MatchesGlob(string glob, string cwdAbsolute, string rootAbsolute, string absoluteFile) { - // A single-pattern matcher is created per call so each pattern in the ordered - // ApiHeaderPatterns list is tested in isolation — this is what enables the - // last-match-wins (gitignore) semantics in the caller var matcher = new Matcher(); matcher.AddInclude(glob); - return matcher.Match(rootAbsolute, absoluteFile).HasMatches; + + // Prefer CWD-relative matching so that explicit path patterns like "src/include/**" + // unambiguously target one root tree when multiple roots are configured + var relFromCwd = Path.GetRelativePath(cwdAbsolute, absoluteFile).Replace('\\', '/'); + if (!relFromCwd.StartsWith("..", StringComparison.Ordinal)) + { + return matcher.Match(relFromCwd).HasMatches; + } + + // File is outside CWD (absolute root path or test environment): fall back to the + // path relative to the include root so that "**/foo.h" still matches correctly + var relFromRoot = Path.GetRelativePath(rootAbsolute, absoluteFile).Replace('\\', '/'); + return matcher.Match(relFromRoot).HasMatches; } // ========================================================================= @@ -751,7 +796,7 @@ private void WriteTypePage( sigParts.Add(templateDecl); } - sigParts.Add($"#include <{includePath}>"); + sigParts.Add($"#include \"{includePath}\""); // 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 @@ -1015,7 +1060,7 @@ 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}."); @@ -1076,7 +1121,7 @@ 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; @@ -1174,7 +1219,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 { @@ -1457,7 +1502,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 { From e5ae30f545cf6dd623db99bb401e652303e0497b Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 22:40:09 -0400 Subject: [PATCH 17/31] fix: address PR review r4454762697 (6 comments) 1. docs/design/api-mark-tool/program.md: update CreateGenerator description to reflect new --includes/--api-headers wiring; remove stale SearchPaths/ IncludePatterns/ExcludePatterns/AdditionalIncludePaths references 2. test/ApiMark.Tool.Tests/ProgramTests.cs: capture stderr in removed-flag tests and assert the specific 'Unsupported argument' message for --search-paths and --include-patterns so failures are unambiguous 3. test/ApiMark.Tool.Tests/Cli/ContextTests.cs: rename Context_Create_WithIncludesOption_TrimsWhitespaceAndRemovesEmptyEntries to Context_Create_WithRepeatedIncludesFlags_AccumulatesAllPathsInOrder; drop stale 'retained for history' note 4. src/ApiMark.Cpp/CppGenerator.cs: fix IsOutsideCwd check - use exact '..' segment and '../' prefix rather than StartsWith('..') to avoid misclassifying filenames like '..hidden.h'; add Path.IsPathRooted guard for different-drive paths on Windows 5. src/ApiMark.Cpp/CppGenerator.cs: precompile one Matcher per ApiHeaderPatterns entry outside both the root and file loops; replace MatchesGlob helper with new IsOutsideCwd helper and inline the path-selection logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-tool/program.md | 8 +- src/ApiMark.Cpp/CppGenerator.cs | 143 ++++++++------------ test/ApiMark.Tool.Tests/Cli/ContextTests.cs | 3 +- test/ApiMark.Tool.Tests/ProgramTests.cs | 53 ++++++-- 4 files changed, 105 insertions(+), 102 deletions(-) diff --git a/docs/design/api-mark-tool/program.md b/docs/design/api-mark-tool/program.md index 792cd5f..465586e 100644 --- a/docs/design/api-mark-tool/program.md +++ b/docs/design/api-mark-tool/program.md @@ -72,10 +72,10 @@ the generator, and calls `Generate`. `NotSupportedException` for unrecognized or not-yet-implemented language identifiers. - For the `cpp` language, `CppGeneratorOptions` is populated with - `PublicIncludeRoots` (from `context.Includes`), `AdditionalIncludePaths` - (from `context.SearchPaths`), `IncludePatterns` (from - `context.IncludePatterns`), and `ExcludePatterns` (from - `context.ExcludePatterns`) in addition to the other cpp-specific options. + `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/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 5a6cfe9..0e275de 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -214,9 +214,34 @@ private List CollectHeaderFiles() // 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. See MatchesGlob for details. + // 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) { @@ -236,7 +261,7 @@ private List CollectHeaderFiles() .Select(Path.GetFullPath) .ToList(); - if (_options.ApiHeaderPatterns.Count == 0) + if (compiledPatterns.Count == 0) { // No patterns configured: include all recognized header files under this root. // This is the default behavior that documents every header reachable from @@ -245,36 +270,32 @@ private List CollectHeaderFiles() } else { - // Apply gitignore-style evaluation for each header file. - // Patterns are CWD-relative so "src/include/**" targets exactly one root tree - // regardless of how many --includes roots are configured; pure filename - // patterns like "**/foo.h" work correctly against both CWD and root-relative - // paths (see MatchesGlob for the fallback strategy). + // 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) { + 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 pattern in _options.ApiHeaderPatterns) + foreach (var (isExclusion, matcher) in compiledPatterns) { - if (pattern.StartsWith("!", StringComparison.Ordinal)) + if (matcher.Match(relPath).HasMatches) { - // Exclusion pattern: strip the '!' prefix and check whether the - // file matches — if it does, mark it excluded (included = false) - var exclusionGlob = pattern.Substring(1).Trim(); - if (exclusionGlob.Length > 0 && MatchesGlob(exclusionGlob, cwdAbsolute, rootAbsolute, absoluteFile)) - { - included = false; - } - } - else - { - // Inclusion pattern: a match sets included=true, and a later exclusion - // pattern can still override this to produce gitignore semantics - if (MatchesGlob(pattern, cwdAbsolute, rootAbsolute, absoluteFile)) - { - included = true; - } + // Inclusion pattern sets included=true; exclusion sets it false. + // Last match wins (gitignore semantics). + included = !isExclusion; } } @@ -297,69 +318,25 @@ private List CollectHeaderFiles() } /// - /// Tests whether a single header file matches a glob pattern. + /// Returns when the path returned by + /// indicates the file lies outside the base directory. /// /// - /// - /// Patterns are evaluated as relative paths in order to support gitignore-style - /// semantics. A single-pattern is created per call so that - /// each pattern in the ordered - /// list is evaluated independently. - /// - /// - /// The matching strategy is: - /// - /// - /// - /// If is under , - /// match the pattern against the CWD-relative path. This is the normal - /// production path and lets users write patterns like src/include/** - /// to target a specific root tree. - /// - /// - /// - /// - /// Otherwise fall back to matching against the path relative to - /// . This covers absolute include roots - /// (e.g. /usr/local/include) and test environments where the - /// include root is not under the CWD. Pure filename patterns such as - /// **/foo.h work correctly in both strategies. - /// - /// - /// - /// + /// 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). /// - /// - /// A glob pattern relative to (or - /// as fallback), e.g. - /// "src/include/**/*.h" or "**/foo.hpp". - /// Must not be null or empty. - /// - /// Absolute path of the current working directory. - /// Absolute path of the include root for this file. - /// Absolute path of the header file to test. + /// The forward-slash-normalized relative path to test. /// - /// when matches - /// . + /// when indicates the file is + /// outside the base directory. /// - private static bool MatchesGlob(string glob, string cwdAbsolute, string rootAbsolute, string absoluteFile) - { - var matcher = new Matcher(); - matcher.AddInclude(glob); - - // Prefer CWD-relative matching so that explicit path patterns like "src/include/**" - // unambiguously target one root tree when multiple roots are configured - var relFromCwd = Path.GetRelativePath(cwdAbsolute, absoluteFile).Replace('\\', '/'); - if (!relFromCwd.StartsWith("..", StringComparison.Ordinal)) - { - return matcher.Match(relFromCwd).HasMatches; - } - - // File is outside CWD (absolute root path or test environment): fall back to the - // path relative to the include root so that "**/foo.h" still matches correctly - var relFromRoot = Path.GetRelativePath(rootAbsolute, absoluteFile).Replace('\\', '/'); - return matcher.Match(relFromRoot).HasMatches; - } + private static bool IsOutsideCwd(string relativeFromBase) => + relativeFromBase == ".." + || relativeFromBase.StartsWith("../", StringComparison.Ordinal) + || Path.IsPathRooted(relativeFromBase); // ========================================================================= // Error checking diff --git a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs index 3be40a1..660a71f 100644 --- a/test/ApiMark.Tool.Tests/Cli/ContextTests.cs +++ b/test/ApiMark.Tool.Tests/Cli/ContextTests.cs @@ -272,10 +272,9 @@ public void Context_Create_WithIncludesOption_SetsIncludes() /// Validates that repeated --includes flags accumulate all paths in order. /// [Fact] - public void Context_Create_WithIncludesOption_TrimsWhitespaceAndRemovesEmptyEntries() + public void Context_Create_WithRepeatedIncludesFlags_AccumulatesAllPathsInOrder() { // Arrange: three separate --includes flags, each with a plain path - // (test name is retained for history; behavior changed from comma-splitting to repeatable flags) var args = new[] { "--includes", "path/a", "--includes", "path/b", "--includes", "path/c" }; // Act diff --git a/test/ApiMark.Tool.Tests/ProgramTests.cs b/test/ApiMark.Tool.Tests/ProgramTests.cs index 0cbaa3e..785546f 100644 --- a/test/ApiMark.Tool.Tests/ProgramTests.cs +++ b/test/ApiMark.Tool.Tests/ProgramTests.cs @@ -346,19 +346,32 @@ public void Program_Main_WithHelpAfterSubcommand_PrintsHelpAndExitsZero() /// /// Validates that the removed --search-paths flag is no longer recognized and - /// causes an to be thrown at parse time. + /// produces an "Unsupported argument" diagnostic on stderr. /// [Fact] public void Program_Main_CppWithSearchPathsFlag_ThrowsArgumentException() { // 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(); - // Act / Assert: --search-paths must no longer be recognized and must cause a non-zero exit - var exitCode = Program.Main(["cpp", "--search-paths", "/sdk", "--output", outputDir]); + try + { + Console.SetError(errorWriter); - // The unknown flag causes an ArgumentException that is caught and results in exit code 1 - Assert.NotEqual(0, exitCode); + // 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); + } } /// @@ -394,21 +407,35 @@ public void Program_Main_CppWithApiHeadersFlag_FlagIsAccepted() /// /// Validates that the removed --include-patterns flag is no longer recognized - /// and causes a non-zero exit code. + /// and produces an "Unsupported argument" diagnostic on stderr. /// [Fact] public void Program_Main_CppWithIncludePatternsFlag_ThrowsArgumentException() { // 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(); - // Act: the unknown flag causes an ArgumentException resulting in exit code 1 - var exitCode = Program.Main([ - "cpp", - "--include-patterns", "*.h", - "--output", outputDir, - ]); + try + { + Console.SetError(errorWriter); - Assert.NotEqual(0, exitCode); + // 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); + } } } From 325e284eddac42ca3ed4da4abf9c4312600f2332 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 22:49:34 -0400 Subject: [PATCH 18/31] feat: render delegate types with source-level delegate syntax The C# compiler compiles every delegate into a sealed class inheriting System.MulticastDelegate, causing ApiMark to emit "public sealed class ServiceEvent" and list compiler-injected Invoke/BeginInvoke/EndInvoke/ constructor noise as API members. Changes: - DotNetGenerator.IsDelegate: new helper - true when BaseType is System.MulticastDelegate - DotNetGenerator.BuildDelegateSignature: reconstructs the source-level 'public delegate ReturnType Name(params)' from the Invoke method - DotNetGenerator.BuildTypeSignature: dispatches to BuildDelegateSignature before the class/struct/enum/interface switch - DotNetGenerator.WriteTypePage: returns immediately after emitting the signature and summary for delegate types, suppressing the member table (Invoke/BeginInvoke/EndInvoke/constructor are compiler noise, not API) - SampleDelegate.cs: two new fixture types - ServiceEvent (void, 4 params) and SampleTransform (generic, 1 param) - DotNetGeneratorTests: three new tests covering delegate signature, absence of member tables, and generic type parameter rendering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.DotNet/DotNetGenerator.cs | 75 ++++++++++++- .../ApiMark.DotNet.Fixtures/SampleDelegate.cs | 7 ++ .../DotNetGeneratorTests.cs | 104 ++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 test/ApiMark.DotNet.Fixtures/SampleDelegate.cs diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index 061a8a9..44b70d4 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -291,6 +291,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) @@ -1063,6 +1072,56 @@ 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 bare declaration without parameters + return $"public delegate {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 @@ -1071,15 +1130,29 @@ private static string BuildMethodId(MethodDefinition method) /// 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. /// Used to simplify base type and interface names in the signature. /// /// A string of the form public class Name, public interface Name<T>, - /// or public class Name : BaseClass, IInterface when direct inheritance is present. + /// 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", 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.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index ce9f102..edf870f 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1265,4 +1265,108 @@ public void DotNetGenerator_Generate_ExternalNonSystemParameterType_EmitsExterna 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 (IL name for a 2-param generic is SampleTransform`2) + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/SampleTransform`2"), + "Expected type page for SampleTransform generic delegate"); + + var writer = factory.Writers["ApiMark.DotNet.Fixtures/SampleTransform`2"]; + 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); + } } From c2f535ca9972ca0d34f0c0c9479831fa05a3f997 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 22:57:58 -0400 Subject: [PATCH 19/31] Strip arity suffix from generic type names in page files, headings, and links Generic types stored in IL with backtick-arity suffix (e.g. SampleTransform`2) were leaking into generated Markdown file names, headings, and member link paths. Replace type.Name with StripArity(type.Name) in all file/heading/link construction across WriteNamespacePage, WriteTypePage, WriteMemberPage, WriteMethodOverloadPage, and WriteCombinedMemberPage. Update generic delegate test to use the clean SampleTransform key (no backtick). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.DotNet/DotNetGenerator.cs | 21 ++++++++++--------- .../DotNetGeneratorTests.cs | 6 +++--- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index 44b70d4..6f19fba 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -240,8 +240,9 @@ 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}/{typeDisplayName}.md"; + return new[] { $"[{typeDisplayName}]({link})", summary }; }); nsWriter.WriteTable(typeHeaders, typeRows); @@ -272,8 +273,8 @@ private void WriteTypePage( XmlDocReader xmlDocs, TypeLinkResolver resolver) { - using var typeWriter = factory.CreateMarkdown(namespaceFolderPath, type.Name); - typeWriter.WriteHeading(1, type.Name); + using var typeWriter = factory.CreateMarkdown(namespaceFolderPath, StripArity(type.Name)); + typeWriter.WriteHeading(1, StripArity(type.Name)); // Emit the C# declaration signature so readers can see the type kind, modifiers, and direct inheritance var typeSignature = BuildTypeSignature(type, namespaceName); @@ -360,7 +361,7 @@ private void WriteTypePage( : string.Empty; var memberDisplayName = GetMemberDisplayName(member); var sanitizedName = GetSanitizedMemberFileName(member, type); - var memberPageLink = $"{type.Name}/{sanitizedName}.md"; + var memberPageLink = $"{StripArity(type.Name)}/{sanitizedName}.md"; if (member is MethodDefinition singleMethod) { @@ -420,7 +421,7 @@ private void WriteTypePage( : string.Empty; var overloadDisplayName = GetMethodGroupDisplayName(representative, orderedOverloads.Count); var overloadFileName = GetSanitizedMemberFileName(representative, type); - var memberLink = $"{type.Name}/{overloadFileName}.md"; + var memberLink = $"{StripArity(type.Name)}/{overloadFileName}.md"; var isConstructorGroup = representative.Name == ConstructorMethodName; WriteMethodOverloadPage(factory, namespaceName, namespaceFolderPath, type, orderedOverloads, xmlDocs, resolver); @@ -438,7 +439,7 @@ 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 = $"{StripArity(type.Name)}/{lowerKey}.md"; if (writtenLowerKeys.Add(lowerKey)) { @@ -548,7 +549,7 @@ private static void WriteMemberPage( TypeLinkResolver resolver) { var sanitizedName = GetSanitizedMemberFileName(member, type); - var memberCurrentFolder = $"{namespaceFolderPath}/{type.Name}"; + var memberCurrentFolder = $"{namespaceFolderPath}/{StripArity(type.Name)}"; using var memberWriter = factory.CreateMarkdown(memberCurrentFolder, sanitizedName); var displayName = GetMemberDisplayName(member); @@ -608,7 +609,7 @@ private static void WriteMethodOverloadPage( TypeLinkResolver resolver) { var sanitizedName = BuildMethodFileName(overloads[0], type); - var overloadCurrentFolder = $"{namespaceFolderPath}/{type.Name}"; + var overloadCurrentFolder = $"{namespaceFolderPath}/{StripArity(type.Name)}"; using var memberWriter = factory.CreateMarkdown(overloadCurrentFolder, sanitizedName); memberWriter.WriteHeading(1, GetMethodGroupName(overloads[0])); @@ -664,7 +665,7 @@ private static void WriteCombinedMemberPage( XmlDocReader xmlDocs, TypeLinkResolver resolver) { - var combinedCurrentFolder = $"{namespaceFolderPath}/{type.Name}"; + var combinedCurrentFolder = $"{namespaceFolderPath}/{StripArity(type.Name)}"; using var writer = factory.CreateMarkdown(combinedCurrentFolder, lowerKey); // The shared lowercase key serves as the page heading so every member in the group diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index edf870f..e280e55 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1355,12 +1355,12 @@ public void DotNetGenerator_Generate_GenericDelegateType_IncludesTypeParameters( // Act generator.Generate(factory, new InMemoryContext()); - // Assert: type page must exist (IL name for a 2-param generic is SampleTransform`2) + // Assert: type page must exist (arity suffix stripped from IL name SampleTransform`2) Assert.True( - factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/SampleTransform`2"), + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/SampleTransform"), "Expected type page for SampleTransform generic delegate"); - var writer = factory.Writers["ApiMark.DotNet.Fixtures/SampleTransform`2"]; + var writer = factory.Writers["ApiMark.DotNet.Fixtures/SampleTransform"]; var signature = writer.Operations.OfType().FirstOrDefault(); Assert.NotNull(signature); From 63cb05e34f9b4fb383b6c366be615ccc422710fe Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 23:37:14 -0400 Subject: [PATCH 20/31] Fix PR review r4454901364: namespace-prefix checks, FlattenArity, deleted functions Three review fixes: 1. TypeLinkResolver.TrackExternalType: tighten System namespace check from StartsWith('System') to == 'System' || StartsWith('System.') so that third-party namespaces like SystemX.* are no longer incorrectly suppressed. 2. CppTypeLinkResolver external tracking: tighten std namespace check from !StartsWith('std') to != 'std' && !StartsWith('std::') so that namespaces like stdext:: are no longer incorrectly suppressed. 3. Generic type file-name collision: add FlattenArity() (removes backtick, keeps arity count e.g. Foo`2->Foo2) and use it for all file names, folder paths, and link hrefs in DotNetGenerator and TypeLinkResolver, keeping StripArity() only for display headings and link labels. Fixes potential collision between Foo and Foo which would both strip to Foo.md. Also add cspell word 'linkified' and fix 'arities' comment wording. New feature: C++ deleted function support - Add IsDeleted to CppFunction record (parsed from clang JSON 'explicitlyDeleted') - BuildMethodSignature appends ' = delete' when IsDeleted is true - Add DeletedMembersClass fixture with deleted copy constructor and operator= - Add two tests verifying '= delete' appears in generated signatures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 1 + src/ApiMark.Cpp/CppAst/ClangAstParser.cs | 4 ++ src/ApiMark.Cpp/CppAst/CppAstModel.cs | 4 ++ src/ApiMark.Cpp/CppGenerator.cs | 5 ++ src/ApiMark.Cpp/CppTypeLinkResolver.cs | 2 +- src/ApiMark.DotNet/DotNetGenerator.cs | 24 ++++++--- src/ApiMark.DotNet/TypeLinkResolver.cs | 5 +- src/ApiMark.DotNet/TypeNameSimplifier.cs | 17 ++++++ .../include/fixtures/DeletedMembersClass.h | 29 ++++++++++ test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 54 +++++++++++++++++++ .../DotNetGeneratorTests.cs | 6 +-- 11 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 test/ApiMark.Cpp.Fixtures/include/fixtures/DeletedMembersClass.h diff --git a/.cspell.yaml b/.cspell.yaml index ac5d1fe..abe2e23 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -31,6 +31,7 @@ words: - libclang - libclangsharp - libext + - linkified - Linkification - Linkify - linkify diff --git a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs index 429b351..d329671 100644 --- a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs +++ b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs @@ -879,6 +879,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; @@ -913,6 +914,7 @@ private void ParseFreeFunction(JsonElement node, string nsQualName) false, isVariadic, isDeprecated, + isDeleted, location, doc); @@ -1039,6 +1041,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); @@ -1075,6 +1078,7 @@ private void ParseEnum(JsonElement node, string nsQualName) isConstructor, isVariadic, isDeprecated, + isDeleted, location, doc); } diff --git a/src/ApiMark.Cpp/CppAst/CppAstModel.cs b/src/ApiMark.Cpp/CppAst/CppAstModel.cs index 8d7fe70..ffdf923 100644 --- a/src/ApiMark.Cpp/CppAst/CppAstModel.cs +++ b/src/ApiMark.Cpp/CppAst/CppAstModel.cs @@ -120,6 +120,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 +135,7 @@ public record CppFunction( bool IsConstructor, bool IsVariadic, bool IsDeprecated, + bool IsDeleted, CppSourceLocation? Location, CppDocComment? Doc); diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 0e275de..5293260 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -1677,6 +1677,11 @@ private static string BuildMethodSignature(CppFunction fn) sb.Append(string.Join(", ", paramParts)); sb.Append(')'); + if (fn.IsDeleted) + { + sb.Append(" = delete"); + } + return sb.ToString(); } diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs index b309d6b..ae5aae8 100644 --- a/src/ApiMark.Cpp/CppTypeLinkResolver.cs +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -146,7 +146,7 @@ public string Linkify( // External type with a namespace: track for the External Types section var ns = ExtractNamespace(stripped); - if (!string.IsNullOrEmpty(ns) && !ns.StartsWith("std", StringComparison.Ordinal)) + 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; diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index 6f19fba..2973c3b 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -241,7 +241,7 @@ private void WriteNamespacePage( var typeMemberId = BuildTypeId(t); var summary = xmlDocs.GetSummary(typeMemberId) ?? NoDescriptionPlaceholder; var typeDisplayName = StripArity(t.Name); - var link = $"{shortName}/{typeDisplayName}.md"; + var link = $"{shortName}/{FlattenArity(t.Name)}.md"; return new[] { $"[{typeDisplayName}]({link})", summary }; }); nsWriter.WriteTable(typeHeaders, typeRows); @@ -273,7 +273,7 @@ private void WriteTypePage( XmlDocReader xmlDocs, TypeLinkResolver resolver) { - using var typeWriter = factory.CreateMarkdown(namespaceFolderPath, StripArity(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, modifiers, and direct inheritance @@ -361,7 +361,7 @@ private void WriteTypePage( : string.Empty; var memberDisplayName = GetMemberDisplayName(member); var sanitizedName = GetSanitizedMemberFileName(member, type); - var memberPageLink = $"{StripArity(type.Name)}/{sanitizedName}.md"; + var memberPageLink = $"{FlattenArity(type.Name)}/{sanitizedName}.md"; if (member is MethodDefinition singleMethod) { @@ -421,7 +421,7 @@ private void WriteTypePage( : string.Empty; var overloadDisplayName = GetMethodGroupDisplayName(representative, orderedOverloads.Count); var overloadFileName = GetSanitizedMemberFileName(representative, type); - var memberLink = $"{StripArity(type.Name)}/{overloadFileName}.md"; + var memberLink = $"{FlattenArity(type.Name)}/{overloadFileName}.md"; var isConstructorGroup = representative.Name == ConstructorMethodName; WriteMethodOverloadPage(factory, namespaceName, namespaceFolderPath, type, orderedOverloads, xmlDocs, resolver); @@ -439,7 +439,7 @@ 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 = $"{StripArity(type.Name)}/{lowerKey}.md"; + var memberLink = $"{FlattenArity(type.Name)}/{lowerKey}.md"; if (writtenLowerKeys.Add(lowerKey)) { @@ -549,7 +549,7 @@ private static void WriteMemberPage( TypeLinkResolver resolver) { var sanitizedName = GetSanitizedMemberFileName(member, type); - var memberCurrentFolder = $"{namespaceFolderPath}/{StripArity(type.Name)}"; + var memberCurrentFolder = $"{namespaceFolderPath}/{FlattenArity(type.Name)}"; using var memberWriter = factory.CreateMarkdown(memberCurrentFolder, sanitizedName); var displayName = GetMemberDisplayName(member); @@ -609,7 +609,7 @@ private static void WriteMethodOverloadPage( TypeLinkResolver resolver) { var sanitizedName = BuildMethodFileName(overloads[0], type); - var overloadCurrentFolder = $"{namespaceFolderPath}/{StripArity(type.Name)}"; + var overloadCurrentFolder = $"{namespaceFolderPath}/{FlattenArity(type.Name)}"; using var memberWriter = factory.CreateMarkdown(overloadCurrentFolder, sanitizedName); memberWriter.WriteHeading(1, GetMethodGroupName(overloads[0])); @@ -665,7 +665,7 @@ private static void WriteCombinedMemberPage( XmlDocReader xmlDocs, TypeLinkResolver resolver) { - var combinedCurrentFolder = $"{namespaceFolderPath}/{StripArity(type.Name)}"; + 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 @@ -1485,4 +1485,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/TypeLinkResolver.cs b/src/ApiMark.DotNet/TypeLinkResolver.cs index 9674114..a9b7846 100644 --- a/src/ApiMark.DotNet/TypeLinkResolver.cs +++ b/src/ApiMark.DotNet/TypeLinkResolver.cs @@ -238,7 +238,7 @@ private static bool IsIntraAssembly(TypeReference typeRef) => private string GetTypePageKey(TypeReference typeRef) { var folder = DotNetGenerator.GetNamespaceFolderPath(typeRef.Namespace, _rootNamespaces); - var name = TypeNameSimplifier.StripArity(typeRef.Name); + var name = TypeNameSimplifier.FlattenArity(typeRef.Name); return folder.Length > 0 ? $"{folder}/{name}" : name; } @@ -277,7 +277,8 @@ private static string ComputeRelativePath(string currentFolder, string targetKey /// The set to add to when the type qualifies. private static void TrackExternalType(TypeReference typeRef, ISet externalTypes) { - if (typeRef.Namespace.StartsWith("System", StringComparison.Ordinal)) + if (typeRef.Namespace == "System" || + typeRef.Namespace.StartsWith("System.", StringComparison.Ordinal)) { return; } diff --git a/src/ApiMark.DotNet/TypeNameSimplifier.cs b/src/ApiMark.DotNet/TypeNameSimplifier.cs index 6da94f7..298ae1f 100644 --- a/src/ApiMark.DotNet/TypeNameSimplifier.cs +++ b/src/ApiMark.DotNet/TypeNameSimplifier.cs @@ -130,6 +130,23 @@ internal static string StripArity(string name) 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/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.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 3d390e8..47f51eb 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; @@ -1015,4 +1016,57 @@ public void CppTypeLinkResolver_Linkify_UnknownNamespacedType_TracksExternalType 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)); + } } diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index e280e55..be00f39 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1355,12 +1355,12 @@ public void DotNetGenerator_Generate_GenericDelegateType_IncludesTypeParameters( // Act generator.Generate(factory, new InMemoryContext()); - // Assert: type page must exist (arity suffix stripped from IL name SampleTransform`2) + // Assert: type page must exist (arity flattened: SampleTransform`2 → SampleTransform2) Assert.True( - factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/SampleTransform"), + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/SampleTransform2"), "Expected type page for SampleTransform generic delegate"); - var writer = factory.Writers["ApiMark.DotNet.Fixtures/SampleTransform"]; + var writer = factory.Writers["ApiMark.DotNet.Fixtures/SampleTransform2"]; var signature = writer.Operations.OfType().FirstOrDefault(); Assert.NotNull(signature); From 88a4d9978f2fd637ef16961c8dafaa363fcb39be Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 8 Jun 2026 23:57:03 -0400 Subject: [PATCH 21/31] feat: add C++ using type alias support - Parse TypeAliasDecl nodes from clang JSON into CppTypeAlias records - Add TypeAliases collection to CppNamespaceDecl and NamespaceBuilder - Collect type aliases in CollectResultNamespace (with deprecated filter) - Include type aliases in the known-types map for link resolution - Add 'Type Aliases' section to namespace summary page (Alias/Underlying Type/Description) - Write individual type alias page with using declaration, #include, and doc summary - Add TypeAliasFixtures.h with item_id_t and label_t aliases for testing - Add 3 new tests: page creation, page content, and namespace page listing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppAst/ClangAstParser.cs | 62 ++++++++++++- src/ApiMark.Cpp/CppAst/CppAstModel.cs | 20 +++++ src/ApiMark.Cpp/CppGenerator.cs | 86 ++++++++++++++++++- .../include/fixtures/TypeAliasFixtures.h | 14 +++ test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 65 ++++++++++++++ 5 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 test/ApiMark.Cpp.Fixtures/include/fixtures/TypeAliasFixtures.h diff --git a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs index d329671..67709a6 100644 --- a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs +++ b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs @@ -580,6 +580,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); @@ -986,6 +990,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 . @@ -1579,6 +1636,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. @@ -1588,7 +1648,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 ffdf923..c999c8e 100644 --- a/src/ApiMark.Cpp/CppAst/CppAstModel.cs +++ b/src/ApiMark.Cpp/CppAst/CppAstModel.cs @@ -184,6 +184,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). /// @@ -199,12 +217,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/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 5293260..ad64909 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -120,7 +120,8 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) var nsDisplay = kv.Key.Replace(".", "::", StringComparison.Ordinal); var nsPath = kv.Key.Replace(".", "/", StringComparison.Ordinal); return kv.Value.Classes.Select(cls => (Key: $"{nsDisplay}::{cls.Name}", Value: $"{nsPath}/{cls.Name}")) - .Concat(kv.Value.Enums.Select(enm => (Key: $"{nsDisplay}::{enm.Name}", Value: $"{nsPath}/{enm.Name}"))); + .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); @@ -161,6 +162,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); + } } } @@ -447,6 +454,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); + } } /// @@ -678,6 +697,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 = SimplifyTypeName(alias.UnderlyingTypeName); + 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 @@ -1511,6 +1546,52 @@ 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. + private void WriteTypeAliasPage( + IMarkdownWriterFactory factory, + string nsKey, + string nsDisplayName, + CppTypeAlias alias) + { + using var writer = factory.CreateMarkdown(nsKey, alias.Name); + writer.WriteHeading(1, alias.Name); + + // 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 declaration = $"using {alias.Name} = {alias.UnderlyingTypeName}"; + 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); + } + } + // ========================================================================= // Visibility filtering // ========================================================================= @@ -1967,6 +2048,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/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.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 47f51eb..a8f6002 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -1069,4 +1069,69 @@ public void CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffi 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)); + } } From 0e08fe50734dff4426323b531eda0be568faff29 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 9 Jun 2026 00:06:53 -0400 Subject: [PATCH 22/31] feat: add APIMARK_CLANG_PATH env var; doc deleted functions and type aliases Code: - Add APIMARK_CLANG_PATH environment variable support to FindClangExecutable with priority: ClangPath option > APIMARK_CLANG_PATH > PATH > xcrun > vswhere - Expose ClangPathEnvVar constant for testability - Update error message to mention APIMARK_CLANG_PATH alongside ClangPath Requirements: - Update Clang-Executable-Discoverable to include APIMARK_CLANG_PATH - Add ApiMarkCpp-CppGenerator-ShowDeletedFunctionsWithDeletedNotation - Add ApiMarkCpp-CppGenerator-DocumentTypeAliases Design: - Update clang.md discovery section with full priority order and env var - Update api-mark-cpp.md clang contract section - Update cpp-generator.md: ClangPath option, naming table, WriteTypeAliasPage, CppFunction.IsDeleted, Dependencies Verification: - Add test scenarios for deleted constructors, deleted operators, type alias page creation, type alias page content, and namespace page listing User guide: - Update --clang-path description to mention APIMARK_CLANG_PATH - Add 'Clang executable discovery' section explaining full priority order - Add type alias row to output structure table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-cpp.md | 6 ++-- docs/design/api-mark-cpp/cpp-generator.md | 34 +++++++++++++++--- docs/design/ots/clang.md | 18 ++++++---- .../reqstream/api-mark-cpp/cpp-generator.yaml | 27 ++++++++++++++ docs/reqstream/ots/clang.yaml | 12 ++++--- docs/user_guide/cli-reference.md | 16 +++++++-- docs/verification/api-mark-cpp.md | 35 +++++++++++++++++-- src/ApiMark.Cpp/CppAst/ClangAstParser.cs | 25 +++++++++++-- 8 files changed, 147 insertions(+), 26 deletions(-) diff --git a/docs/design/api-mark-cpp.md b/docs/design/api-mark-cpp.md index 5e55dfb..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. diff --git a/docs/design/api-mark-cpp/cpp-generator.md b/docs/design/api-mark-cpp/cpp-generator.md index 1ddf95f..35eb356 100644 --- a/docs/design/api-mark-cpp/cpp-generator.md +++ b/docs/design/api-mark-cpp/cpp-generator.md @@ -73,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 @@ -128,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` | @@ -177,7 +179,8 @@ 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. @@ -295,6 +298,26 @@ type was referenced in table cells. - *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 @@ -310,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/ots/clang.md b/docs/design/ots/clang.md index 176d835..bf76103 100644 --- a/docs/design/ots/clang.md +++ b/docs/design/ots/clang.md @@ -35,18 +35,24 @@ 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). 3. The parser constructs a clang command line: `-Xclang -ast-dump=json -fparse-all-comments diff --git a/docs/reqstream/api-mark-cpp/cpp-generator.yaml b/docs/reqstream/api-mark-cpp/cpp-generator.yaml index 20e81a1..2d3e9aa 100644 --- a/docs/reqstream/api-mark-cpp/cpp-generator.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-generator.yaml @@ -184,3 +184,30 @@ sections: 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_DeletedConstructor_SignatureContainsDeleteSuffix + - CppGenerator_Generate_DeletedOperator_SignatureContainsDeleteSuffix + - 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/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 668efee..4b0dfff 100644 --- a/docs/user_guide/cli-reference.md +++ b/docs/user_guide/cli-reference.md @@ -49,7 +49,7 @@ apimark cpp [options] | `--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 | @@ -90,6 +90,17 @@ This is equivalent to the gitignore rule sequence: | `!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` | @@ -105,8 +116,9 @@ 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 | An AI agent can read the root index first, drill into the relevant namespace diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index b7b510c..b4bb0a9 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 constructor signature contains = delete suffix**: Verifies that a 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_DeletedConstructor_SignatureContainsDeleteSuffix`. + +**Deleted operator signature contains = delete suffix**: Verifies that an 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_DeletedOperator_SignatureContainsDeleteSuffix`. + +**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 67709a6..d653613 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."); } From b2e23f421cea05490477b0ecd8dca08aecd8ac00 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 9 Jun 2026 00:20:02 -0400 Subject: [PATCH 23/31] =?UTF-8?q?fix:=20PR=20review=20r4455192693=20?= =?UTF-8?q?=E2=80=94=20delegate=20fallback,=20nsPath=20dots,=20test=20name?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DotNetGenerator: add missing 'void' return type to malformed-delegate fallback signature (was 'public delegate Foo()'; now 'public delegate void Foo()') - CppGenerator: use kv.Key directly as nsPath instead of replacing '.' with '/'; the dot-separated namespace key is the actual page key used by CreateMarkdown, so Path.GetRelativePath in Linkify was computing wrong hrefs for nested namespaces - ProgramTests: rename two test methods from ThrowsArgumentException to ReturnsNonZeroExitCode to match what they actually assert Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppGenerator.cs | 4 ++-- src/ApiMark.DotNet/DotNetGenerator.cs | 4 ++-- test/ApiMark.Tool.Tests/ProgramTests.cs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index ad64909..a07d9ce 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -118,7 +118,7 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) var knownTypes = namespaceDecls.SelectMany(kv => { var nsDisplay = kv.Key.Replace(".", "::", StringComparison.Ordinal); - var nsPath = kv.Key.Replace(".", "/", StringComparison.Ordinal); + var nsPath = kv.Key; // preserve dot-separated key to match CreateMarkdown page keys return kv.Value.Classes.Select(cls => (Key: $"{nsDisplay}::{cls.Name}", Value: $"{nsPath}/{cls.Name}")) .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}"))); @@ -624,7 +624,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 }; }); diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index 2973c3b..8c7c965 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -1105,8 +1105,8 @@ private static string BuildDelegateSignature(TypeDefinition type, string context var invoke = type.Methods.FirstOrDefault(m => m.Name == "Invoke"); if (invoke == null) { - // Malformed delegate — fall back to a bare declaration without parameters - return $"public delegate {StripArity(type.Name)}()"; + // Malformed delegate — fall back to a void declaration without parameters + return $"public delegate void {StripArity(type.Name)}()"; } var returnType = TypeNameSimplifier.Simplify(invoke.ReturnType, contextNamespace); diff --git a/test/ApiMark.Tool.Tests/ProgramTests.cs b/test/ApiMark.Tool.Tests/ProgramTests.cs index 785546f..ead6b81 100644 --- a/test/ApiMark.Tool.Tests/ProgramTests.cs +++ b/test/ApiMark.Tool.Tests/ProgramTests.cs @@ -349,7 +349,7 @@ public void Program_Main_WithHelpAfterSubcommand_PrintsHelpAndExitsZero() /// produces an "Unsupported argument" diagnostic on stderr. /// [Fact] - public void Program_Main_CppWithSearchPathsFlag_ThrowsArgumentException() + public void Program_Main_CppWithSearchPathsFlag_ReturnsNonZeroExitCode() { // Arrange: provide --search-paths which was removed in the redesign var outputDir = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); @@ -410,7 +410,7 @@ public void Program_Main_CppWithApiHeadersFlag_FlagIsAccepted() /// and produces an "Unsupported argument" diagnostic on stderr. /// [Fact] - public void Program_Main_CppWithIncludePatternsFlag_ThrowsArgumentException() + public void Program_Main_CppWithIncludePatternsFlag_ReturnsNonZeroExitCode() { // Arrange: provide --include-patterns which was removed in the redesign var outputDir = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); From d33cf791a1bb72a05b0fe1ad7adb6b2536b22e1d Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 9 Jun 2026 00:32:25 -0400 Subject: [PATCH 24/31] fix: linkify return types in WriteFreeFunctionContent and WriteFunctionContent Both 'Returns' sections were using the raw simplified type name without passing it through CppTypeLinkResolver. This meant intra-library return types were not linkified and external return types were not tracked, leaving the 'External Types' section incomplete for functions that return an external type but have no parameters. Fix: always call cppResolver.Linkify on the return type name so external types are tracked regardless of whether a doc-comment return description is present. The linked type name is used as the fallback paragraph text when no description is available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppGenerator.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index a07d9ce..5701c90 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -1265,9 +1265,11 @@ private void WriteFreeFunctionContent( var returnTypeName = SimplifyTypeName(fn.ReturnTypeName); if (!string.Equals(returnTypeName, "void", StringComparison.Ordinal)) { + // 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); } } @@ -1415,9 +1417,11 @@ private static void WriteFunctionContent( var returnTypeName = SimplifyTypeName(method.ReturnTypeName); if (!string.Equals(returnTypeName, "void", StringComparison.Ordinal)) { + // 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); } } } From ab3813329e729b45f93c05cbc8b7191610169bdc Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 9 Jun 2026 00:44:24 -0400 Subject: [PATCH 25/31] feat: render @note as blockquote; show default parameter values in signatures @note tags: - Add Note field to CppDocComment (optional, null when absent) - Parse @note BlockCommandComment in ClangAstParser.ParseDocComment - Add GetNote helper in CppGenerator - Emit '> **Note:** {text}' blockquote paragraph after @details in all content writers: WriteFreeFunctionContent, WriteFunctionContent, WriteFieldContent, WriteTypePage, WriteEnumPage, WriteTypeAliasPage Default parameter values: - Add DefaultValue field to CppParameter (optional, null when absent) - Parse default from ParmVarDecl inner nodes in ParseParameter - Add ExtractDefaultValue helper handling integer/float/bool/string/nullptr literals, named constants (DeclRefExpr), unary operators, and implicit cast wrappers; returns null for complex expressions - Include '= {defaultValue}' in BuildMethodSignature parameter list when set Tests: - Add DefaultParamFixtures.h fixture with crc32 (seed=0U default) and count_capped - Add CppGenerator_Generate_DefaultParameter_SignatureContainsDefault - Add CppGenerator_Generate_NoteTag_RenderedAsBlockquote Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppAst/ClangAstParser.cs | 75 ++++++++++++++++++- src/ApiMark.Cpp/CppAst/CppAstModel.cs | 10 ++- src/ApiMark.Cpp/CppGenerator.cs | 54 ++++++++++++- .../include/fixtures/DefaultParamFixtures.h | 21 ++++++ test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 39 ++++++++++ 5 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 test/ApiMark.Cpp.Fixtures/include/fixtures/DefaultParamFixtures.h diff --git a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs index d653613..637996d 100644 --- a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs +++ b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs @@ -1221,7 +1221,7 @@ private void ParseTypeAlias(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; @@ -1230,7 +1230,68 @@ 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 and boolean literals carry their value directly + "IntegerLiteral" or "FloatingLiteral" or "CXXBoolLiteralExpr" => + node.TryGetProperty("value", out var v) ? v.GetString() : 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, + }; } // ========================================================================= @@ -1285,6 +1346,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()) @@ -1334,6 +1396,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; @@ -1351,12 +1418,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); } /// diff --git a/src/ApiMark.Cpp/CppAst/CppAstModel.cs b/src/ApiMark.Cpp/CppAst/CppAstModel.cs index c999c8e..0c60772 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. diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 5701c90..f96493d 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -832,6 +832,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) { @@ -1249,6 +1256,13 @@ 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) { @@ -1398,6 +1412,13 @@ 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) { @@ -1488,6 +1509,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}"); + } } /// @@ -1536,6 +1564,13 @@ 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) { @@ -1594,6 +1629,13 @@ private void WriteTypeAliasPage( { writer.WriteParagraph(details); } + + // Emit @note as a blockquote when present + var aliasNote = GetNote(alias.Doc); + if (!string.IsNullOrEmpty(aliasNote)) + { + writer.WriteParagraph($"> **Note:** {aliasNote}"); + } } // ========================================================================= @@ -1676,6 +1718,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 . /// @@ -1752,7 +1802,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) { 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..2b5f012 --- /dev/null +++ b/test/ApiMark.Cpp.Fixtures/include/fixtures/DefaultParamFixtures.h @@ -0,0 +1,21 @@ +#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); + +} // namespace fixtures diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index a8f6002..2719a2c 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -1134,4 +1134,43 @@ public void CppGenerator_Generate_NamespacePage_ListsTypeAliases() Assert.Contains(allCells, c => c.Contains("item_id_t", StringComparison.Ordinal)); Assert.Contains(allCells, c => c.Contains("label_t", 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_NoteTag_RenderedAsBlockquote() + { + // Arrange + var factory = _fixture.PublicFactory; + + // Assert: crc32 page must contain a blockquote paragraph with the @note text + Assert.True(factory.Writers.ContainsKey("fixtures/crc32")); + var writer = factory.Writers["fixtures/crc32"]; + var paragraphs = writer.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains( + paragraphs, + p => p.StartsWith("> **Note:**", StringComparison.Ordinal) && + p.Contains("seed", StringComparison.Ordinal)); + } } From 8bc9a1bf008452eac451c731e4ba952d46543ac0 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 9 Jun 2026 00:55:35 -0400 Subject: [PATCH 26/31] Add bool/int/float default parameter tests and fix bool literal parsing - Split CXXBoolLiteralExpr handling in ExtractDefaultValue to correctly read JSON boolean values (true/false) rather than calling GetString() which silently returns null - Add float scale() fixture function to DefaultParamFixtures.h - Add three tests covering all default value types: - BoolDefaultParameter: bool initial = false - NegativeIntDefaultParameter: int max = -1 - FloatDefaultParameter: float factor = 1.5f - Total: 62 C++ tests passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppAst/ClangAstParser.cs | 15 +++++- .../include/fixtures/DefaultParamFixtures.h | 11 ++++ test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 53 ++++++++++++++++--- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs index 637996d..6b1bacd 100644 --- a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs +++ b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs @@ -1258,10 +1258,21 @@ private static CppParameter ParseParameter(JsonElement node) return kind switch { - // Numeric and boolean literals carry their value directly - "IntegerLiteral" or "FloatingLiteral" or "CXXBoolLiteralExpr" => + // 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, diff --git a/test/ApiMark.Cpp.Fixtures/include/fixtures/DefaultParamFixtures.h b/test/ApiMark.Cpp.Fixtures/include/fixtures/DefaultParamFixtures.h index 2b5f012..fa437cc 100644 --- a/test/ApiMark.Cpp.Fixtures/include/fixtures/DefaultParamFixtures.h +++ b/test/ApiMark.Cpp.Fixtures/include/fixtures/DefaultParamFixtures.h @@ -18,4 +18,15 @@ uint32_t crc32(const uint8_t* data, uint32_t length, uint32_t seed = 0U); /// @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.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 2719a2c..d5f34f4 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -1159,18 +1159,55 @@ public void CppGenerator_Generate_DefaultParameter_SignatureContainsDefault() /// (prefixed with > **Note:**) on the function's detail page. /// [Fact] - public void CppGenerator_Generate_NoteTag_RenderedAsBlockquote() + public void CppGenerator_Generate_BoolDefaultParameter_SignatureContainsFalse() { // Arrange var factory = _fixture.PublicFactory; - // Assert: crc32 page must contain a blockquote paragraph with the @note text - Assert.True(factory.Writers.ContainsKey("fixtures/crc32")); - var writer = factory.Writers["fixtures/crc32"]; - var paragraphs = writer.Operations.OfType().Select(p => p.Text).ToList(); + // 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( - paragraphs, - p => p.StartsWith("> **Note:**", StringComparison.Ordinal) && - p.Contains("seed", StringComparison.Ordinal)); + 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)); } } From 71f6ff550d17c37746f52ef00b0b0496cb1503b0 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 9 Jun 2026 01:08:39 -0400 Subject: [PATCH 27/31] Fix type alias linkification and simplification - Namespace page 'Type Aliases' table now calls cppResolver.Linkify on the underlying type, enabling intra-library links and tracking external types for the page's External Types section - WriteTypeAliasPage now accepts CppTypeLinkResolver, uses SimplifyTypeName on the underlying type in the using declaration (consistent with all other signatures in the generator), and emits an External Types section - Add test CppGenerator_Generate_TypeAliasPage_SimplifiesUnderlyingType verifying that verbose clang basic_string forms are simplified to std::string on both the alias page and the namespace summary table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppGenerator.cs | 18 ++++++++--- test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 34 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index f96493d..bad2db3 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -166,7 +166,7 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) // 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); + WriteTypeAliasPage(factory, nsKey, nsDecls.DisplayName, alias, cppResolver); } } } @@ -707,7 +707,7 @@ private static void WriteNamespacePage( .Select(alias => { var summary = GetSummary(alias.Doc) ?? NoDescriptionPlaceholder; - var underlying = SimplifyTypeName(alias.UnderlyingTypeName); + var underlying = cppResolver.Linkify(SimplifyTypeName(alias.UnderlyingTypeName), nsKey, externalTypes); return new[] { $"[{alias.Name}]({nsKey}/{alias.Name}.md)", underlying, summary }; }); writer.WriteTable(aliasHeaders, aliasRows); @@ -1594,21 +1594,27 @@ private void WriteEnumPage( /// 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) + 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 declaration = $"using {alias.Name} = {alias.UnderlyingTypeName}"; + var simplifiedUnderlying = SimplifyTypeName(alias.UnderlyingTypeName); + var declaration = $"using {alias.Name} = {simplifiedUnderlying}"; if (alias.Location != null) { var includePath = GetIncludePath(alias.Location.File); @@ -1636,6 +1642,10 @@ private void WriteTypeAliasPage( { writer.WriteParagraph($"> **Note:** {aliasNote}"); } + + // Resolve the underlying type to track any non-std external type references + cppResolver.Linkify(simplifiedUnderlying, nsKey, externalTypes); + WriteExternalTypesSection(writer, externalTypes); } // ========================================================================= diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index d5f34f4..1cb7088 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -1135,6 +1135,40 @@ public void CppGenerator_Generate_NamespacePage_ListsTypeAliases() 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). From 1781e536a0370080ba5bdf09acede280500b54bb Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 9 Jun 2026 01:32:13 -0400 Subject: [PATCH 28/31] Add nested class and class-scoped type alias support Parser: - Extracted BuildClass (returns CppClass?) from ParseClass (now a thin wrapper), allowing recursive nested class parsing without leaking into the namespace builder - Added CXXRecordDecl, ClassTemplateDecl, and TypeAliasDecl cases in BuildClass inner loop; only public members collected Model: - Added NestedClasses and TypeAliases to CppClass record Generator: - Added FlattenClass LINQ helper to build knownTypes entries for nested classes and their aliases using fully-qualified keys, preventing same-short-name collisions (e.g. Outer::size_type vs Other::size_type) - Added WriteNestedTypePages recursive helper - Extended WriteTypePage with Nested Classes and Type Aliases H2 sections CppTypeLinkResolver: - FindPageKey short-name fallback now returns null when multiple known types share the same unqualified name, preventing non-deterministic links Tests: - 5 new integration tests for nested class and class-scoped alias features - 4 new CppTypeLinkResolver unit tests (exact match, unambiguous short-name, ambiguous short-name emits plain text, qualified reference to ambiguous type) - New NestedClassFixtures.h fixture with Outer/Inner nesting and same-name size_type aliases on Outer and Other Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppAst/ClangAstParser.cs | 152 +++++++++++++++++- src/ApiMark.Cpp/CppAst/CppAstModel.cs | 13 ++ src/ApiMark.Cpp/CppGenerator.cs | 105 +++++++++++- src/ApiMark.Cpp/CppTypeLinkResolver.cs | 16 +- .../include/fixtures/NestedClassFixtures.h | 28 ++++ test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 106 ++++++++++++ .../CppTypeLinkResolverTests.cs | 102 ++++++++++++ 7 files changed, 508 insertions(+), 14 deletions(-) create mode 100644 test/ApiMark.Cpp.Fixtures/include/fixtures/NestedClassFixtures.h create mode 100644 test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs diff --git a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs index 6b1bacd..73d94f6 100644 --- a/src/ApiMark.Cpp/CppAst/ClangAstParser.cs +++ b/src/ApiMark.Cpp/CppAst/ClangAstParser.cs @@ -689,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. /// @@ -699,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 @@ -727,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 @@ -799,6 +840,105 @@ 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": // Fallback for clang versions older than 18 that emit base specifiers // as CXXBaseSpecifier child nodes in "inner" rather than in a top-level @@ -832,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); } /// diff --git a/src/ApiMark.Cpp/CppAst/CppAstModel.cs b/src/ApiMark.Cpp/CppAst/CppAstModel.cs index 0c60772..cb2da3e 100644 --- a/src/ApiMark.Cpp/CppAst/CppAstModel.cs +++ b/src/ApiMark.Cpp/CppAst/CppAstModel.cs @@ -150,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. /// @@ -170,6 +181,8 @@ public record CppClass( IReadOnlyList TemplateParams, IReadOnlyList Members, IReadOnlyList Fields, + IReadOnlyList NestedClasses, + IReadOnlyList TypeAliases, bool IsDeprecated, bool IsFinal, CppSourceLocation? Location, diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index bad2db3..a16d7b7 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -115,11 +115,24 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) // 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.Select(cls => (Key: $"{nsDisplay}::{cls.Name}", Value: $"{nsPath}/{cls.Name}")) + 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); @@ -137,6 +150,7 @@ public void Generate(IMarkdownWriterFactory factory, IContext context) foreach (var cls in nsDecls.Classes) { WriteTypePage(factory, nsKey, nsDecls.DisplayName, cls, cppResolver); + WriteNestedTypePages(factory, nsKey, nsDecls.DisplayName, cls, cppResolver); } // Partition free functions into regular functions and operator overloads; @@ -857,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; } @@ -905,9 +927,6 @@ private void WriteTypePage( var methodRows = new List(); var fieldRows = new List(); - // Accumulate external type references found in type-column cells on this page - var externalTypes = new SortedSet(); - // Process constructors — emitted first because instantiation is the first thing // a consumer needs to understand about a type foreach (var ctor in visibleCtors) @@ -1033,9 +1052,85 @@ private void WriteTypePage( 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); + } + } + /// /// Writes the combined operator overloads page for a class, placing all operator /// methods onto a single operators.md page to prevent file-name collisions. diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs index ae5aae8..29ffd01 100644 --- a/src/ApiMark.Cpp/CppTypeLinkResolver.cs +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -170,18 +170,28 @@ public string Linkify( return key; } - // Short-name fallback: match when the stripped value is the unqualified part of a known name + // 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) { - return knownKey; + if (matchKey != null) + { + ambiguous = true; + break; + } + + matchKey = knownKey; } } - return null; + return ambiguous ? null : matchKey; } /// 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.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index 1cb7088..270874b 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -1244,4 +1244,110 @@ public void CppGenerator_Generate_FloatDefaultParameter_SignatureContainsValue() 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); + } +} From 096c108c0bd1edbb2bb75ebf39055395066f3ae3 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 9 Jun 2026 01:32:26 -0400 Subject: [PATCH 29/31] Fix nullable array type rendering and reqstream test name mismatch DotNetGenerator: - Add HasNullableAnnotation helper reading NullableAttribute(byte) and NullableAttribute(byte[]) from Mono.Cecil custom attribute providers - Add IsMemberTypeNullableAnnotated dispatching to the correct attribute provider for each member kind (method return, property, field, event) - Pass isNullableAnnotated to all three type-table Linkify call sites and to BuildMethodSignature, BuildPropertySignature, BuildFieldSignature, and BuildEventSignature so that string[]?, string?, etc. are correctly rendered throughout generated output TypeLinkResolver: - Propagate isNullableAnnotated through the ArrayType branch so that string[]? renders as string[]? rather than string[] ArrayAndNullableClass fixture: - Add GetNullableNames() returning string[]? to exercise the nullable array annotation path end-to-end Tests: - Add DotNetGenerator_Generate_NullableArrayReturnType_RendersWithQuestionMark verifying string[]? in both the type page Methods table and detail page signature reqstream: - Correct ShowDeletedFunctionsWithDeletedNotation test references to match actual test method names (EmitsDeleteSuffix not SignatureContainsDeleteSuffix) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../reqstream/api-mark-cpp/cpp-generator.yaml | 4 +- src/ApiMark.DotNet/DotNetGenerator.cs | 90 +++++++++++++++++-- src/ApiMark.DotNet/TypeLinkResolver.cs | 4 +- .../ArrayAndNullableClass.cs | 4 + .../DotNetGeneratorTests.cs | 41 +++++++++ 5 files changed, 131 insertions(+), 12 deletions(-) diff --git a/docs/reqstream/api-mark-cpp/cpp-generator.yaml b/docs/reqstream/api-mark-cpp/cpp-generator.yaml index 2d3e9aa..a4074ef 100644 --- a/docs/reqstream/api-mark-cpp/cpp-generator.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-generator.yaml @@ -195,8 +195,8 @@ sections: would imply the operation is available; showing them with = delete makes the prohibition visible without requiring readers to examine the header. tests: - - CppGenerator_Generate_DeletedConstructor_SignatureContainsDeleteSuffix - - CppGenerator_Generate_DeletedOperator_SignatureContainsDeleteSuffix + - CppGenerator_Generate_DeletedCopyConstructor_EmitsDeleteSuffix + - CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffix - id: ApiMarkCpp-CppGenerator-DocumentTypeAliases title: >- CppGenerator shall document using type aliases declared in documented namespaces diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index 8c7c965..7fc4ffe 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -357,7 +357,7 @@ private void WriteTypePage( var memberSummary = xmlDocs.GetSummary(memberId) ?? NoDescriptionPlaceholder; var memberTypeRef = GetMemberTypeRef(member); var memberTypeName = memberTypeRef != null - ? resolver.Linkify(memberTypeRef, namespaceFolderPath, namespaceName, externalTypes) + ? resolver.Linkify(memberTypeRef, namespaceFolderPath, namespaceName, externalTypes, IsMemberTypeNullableAnnotated(member)) : string.Empty; var memberDisplayName = GetMemberDisplayName(member); var sanitizedName = GetSanitizedMemberFileName(member, type); @@ -417,7 +417,7 @@ private void WriteTypePage( var representativeSummary = xmlDocs.GetSummary(representativeMemberId) ?? NoDescriptionPlaceholder; var representativeTypeRef = GetMemberTypeRef(representative); var representativeTypeName = representativeTypeRef != null - ? resolver.Linkify(representativeTypeRef, namespaceFolderPath, namespaceName, externalTypes) + ? resolver.Linkify(representativeTypeRef, namespaceFolderPath, namespaceName, externalTypes, IsMemberTypeNullableAnnotated(representative)) : string.Empty; var overloadDisplayName = GetMethodGroupDisplayName(representative, orderedOverloads.Count); var overloadFileName = GetSanitizedMemberFileName(representative, type); @@ -452,7 +452,7 @@ private void WriteTypePage( var memberSummary = xmlDocs.GetSummary(memberId) ?? NoDescriptionPlaceholder; var memberTypeRef = GetMemberTypeRef(member); var memberTypeName = memberTypeRef != null - ? resolver.Linkify(memberTypeRef, namespaceFolderPath, namespaceName, externalTypes) + ? resolver.Linkify(memberTypeRef, namespaceFolderPath, namespaceName, externalTypes, IsMemberTypeNullableAnnotated(member)) : string.Empty; var memberDisplayName = GetMemberDisplayName(member); @@ -1232,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 @@ -1243,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); @@ -1259,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} }}"; @@ -1294,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" @@ -1317,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}"; } @@ -1371,6 +1387,64 @@ private static string GetMemberDisplayName(IMemberDefinition member) _ => 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) + { + 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. diff --git a/src/ApiMark.DotNet/TypeLinkResolver.cs b/src/ApiMark.DotNet/TypeLinkResolver.cs index a9b7846..195de95 100644 --- a/src/ApiMark.DotNet/TypeLinkResolver.cs +++ b/src/ApiMark.DotNet/TypeLinkResolver.cs @@ -132,11 +132,11 @@ public string Linkify( return Linkify(inner, currentFolder, contextNamespace, externalTypes, true); } - // Handle array types by resolving the element type and appending "[]" + // 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 elementText + "[]"; + return isNullableAnnotated ? elementText + "[]?" : elementText + "[]"; } // Handle generic instance types: linkify the container when intra-assembly, else track external 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.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index be00f39..6882a09 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1369,4 +1369,45 @@ public void DotNetGenerator_Generate_GenericDelegateType_IncludesTypeParameters( 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); + } } From cd5bd248c46f0d77bccd66d7b871a620c16d35ed Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 9 Jun 2026 01:43:15 -0400 Subject: [PATCH 30/31] Update output structure table with nested type and class-scoped alias rows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/user_guide/cli-reference.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/user_guide/cli-reference.md b/docs/user_guide/cli-reference.md index 4b0dfff..bc32ef0 100644 --- a/docs/user_guide/cli-reference.md +++ b/docs/user_guide/cli-reference.md @@ -120,6 +120,8 @@ ApiMark uses a four-tier gradual disclosure layout: | `{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 From 198b0e4b3e0d2662476aac8364be8fd9ffdf6442 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Tue, 9 Jun 2026 02:04:27 -0400 Subject: [PATCH 31/31] Fix documentation issues flagged in PR #15 review - Correct test names in api-mark-cpp.md verification doc: use CppGenerator_Generate_DeletedCopyConstructor_EmitsDeleteSuffix and CppGenerator_Generate_DeletedCopyAssignmentOperator_EmitsDeleteSuffix, and update scenario descriptions to say 'copy constructor' and 'copy-assignment operator' - Replace stale IncludePatterns/ExcludePatterns reference in clang.md with ApiHeaderPatterns - Add APIMARK_CLANG_PATH env var to clang discovery order in api-mark-task.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-msbuild/api-mark-task.md | 5 +++-- docs/design/ots/clang.md | 2 +- docs/verification/api-mark-cpp.md | 18 +++++++++--------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/design/api-mark-msbuild/api-mark-task.md b/docs/design/api-mark-msbuild/api-mark-task.md index 847724c..ef5a62a 100644 --- a/docs/design/api-mark-msbuild/api-mark-task.md +++ b/docs/design/api-mark-msbuild/api-mark-task.md @@ -88,8 +88,9 @@ passed to Clang (e.g. `c++17`, `c++20`). The `.targets` file defaults this to **ApiMarkTask.ApiMarkClangPath**: `string` — MSBuild property `$(ApiMarkClangPath)`; for the `cpp` language, the path to the clang -executable. Optional — when empty, clang is located automatically via PATH, -xcrun (macOS), or vswhere (Windows). +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. diff --git a/docs/design/ots/clang.md b/docs/design/ots/clang.md index bf76103..402cbe0 100644 --- a/docs/design/ots/clang.md +++ b/docs/design/ots/clang.md @@ -54,7 +54,7 @@ integration follows these steps: 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/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index b4bb0a9..12afb48 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -161,15 +161,15 @@ than an unrelated null-reference failure during I/O. This scenario is tested by providing a clear diagnostic rather than silently producing empty output. This scenario is tested by `CppGenerator_Generate_NonexistentIncludeRoot_ThrowsDirectoryNotFoundException`. -**Deleted constructor signature contains = delete suffix**: Verifies that a 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_DeletedConstructor_SignatureContainsDeleteSuffix`. - -**Deleted operator signature contains = delete suffix**: Verifies that an 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_DeletedOperator_SignatureContainsDeleteSuffix`. +**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