From d8d5ce1a7019b127d92a4c1fa366c1e265389dee Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 7 Oct 2026 19:26:24 -0400 Subject: [PATCH 01/13] Extract GetCandidateMembers from DotNetEmitter.GetVisibleMembers Behavior-preserving refactor: splits the structural member-eligibility filtering (special-name accessor exclusion, compiler-generated exclusion, value__ field exclusion) out of GetVisibleMembers into a new static GetCandidateMembers helper, with GetVisibleMembers now simply applying ShouldIncludeMember on top of it. This is preparation for the upcoming BuildMemberPageIndex function (full cref cross-reference linking work), which needs the same structural member-eligibility rules under a caller-supplied visibility predicate instead of the instance-bound ShouldIncludeMember, without duplicating the filtering rules in a second, drift-prone copy. No behavior change: full existing ApiMark.DotNet.Tests suite (404 tests) passes unchanged. --- src/ApiMark.DotNet/DotNetEmitter.cs | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/ApiMark.DotNet/DotNetEmitter.cs b/src/ApiMark.DotNet/DotNetEmitter.cs index 248f30f..b865bb5 100644 --- a/src/ApiMark.DotNet/DotNetEmitter.cs +++ b/src/ApiMark.DotNet/DotNetEmitter.cs @@ -324,12 +324,31 @@ internal static bool IsPropertyPublicOrProtected(PropertyDefinition p) /// /// The type whose members to inspect. /// An enumerable of visible member definitions. - internal IEnumerable GetVisibleMembers(TypeDefinition type) + internal IEnumerable GetVisibleMembers(TypeDefinition type) => + GetCandidateMembers(type).Where(ShouldIncludeMember); + + /// + /// Enumerates all structurally-eligible members of — i.e. those + /// that pass the same compiler-generated/special-name structural filters as + /// — without applying any visibility or obsolete + /// filtering. + /// + /// + /// Extracted from so that + /// DotNetEmitterGradualDisclosure.BuildMemberPageIndex can apply its own + /// caller-supplied isMemberEmitted predicate (needed because it is invoked before + /// the owning instance's visibility rules have a public, + /// type-agnostic callable surface in some call paths) without duplicating the structural + /// filtering rules in a second, drift-prone copy. + /// + /// The type whose candidate members to inspect. + /// An enumerable of structurally-eligible member definitions, unfiltered by visibility. + internal static IEnumerable GetCandidateMembers(TypeDefinition type) { // Methods: exclude special-name accessors (property getters/setters, event add/remove) // but always include constructors foreach (var method in type.Methods - .Where(m => !IsSpecialNameNonConstructor(m) && !IsCompilerGenerated(m) && ShouldIncludeMember(m))) + .Where(m => !IsSpecialNameNonConstructor(m) && !IsCompilerGenerated(m))) { yield return method; } @@ -339,7 +358,7 @@ internal IEnumerable GetVisibleMembers(TypeDefinition type) // inheritance hierarchy), which has no source line to attach a to and is // filtered out the same way the record's other generated members (PrintMembers, // $, Deconstruct, Equals(T), etc.) already are via the method filter above. - foreach (var prop in type.Properties.Where(p => !IsCompilerGenerated(p) && ShouldIncludeMember(p))) + foreach (var prop in type.Properties.Where(p => !IsCompilerGenerated(p))) { yield return prop; } @@ -348,12 +367,12 @@ internal IEnumerable GetVisibleMembers(TypeDefinition type) // the compiler-generated enum backing field named "value__" that does not appear // in source and has no meaningful documentation foreach (var field in type.Fields - .Where(f => f.Name != "value__" && !IsCompilerGeneratedField(f) && ShouldIncludeMember(f))) + .Where(f => f.Name != "value__" && !IsCompilerGeneratedField(f))) { yield return field; } - foreach (var evt in type.Events.Where(ShouldIncludeMember)) + foreach (var evt in type.Events) { yield return evt; } From 62547dd5871ac6a06712d5b59587140cee573671 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 7 Oct 2026 19:39:12 -0400 Subject: [PATCH 02/13] Add CrefTargetResolver, link helpers, and member page index wiring - Add CrefTargetResolver: visibility-agnostic index resolving a cref XML-doc-ID string to the TypeDefinition/IMemberDefinition that defines it within a single assembly, built once from AssemblyDefinition.MainModule.GetTypes(). Used later to determine whether a / target exists in-assembly at all (external types/members simply are not found). - Add TypeLinkResolver.LinkifyResolvedType/LinkifyResolvedMember, reusing the existing GetTypePageKey/ComputeRelativePath helpers so resolved cref targets can be rendered as real relative Markdown links consistent with existing intra-assembly type links. - Add DotNetEmitterGradualDisclosure.BuildMemberPageIndex: a pure function that reproduces the existing member grouping/page-naming decisions (overload groups, operator page, case-collision groups, single-member pages) for every emitted member of a type, returning {member XML-doc-ID -> relative page path}. This is an independently written duplicate of the grouping logic in ProcessTypeMembers rather than a true single-source refactor (scope reduction, see planning-full-cref-linking-a1f3e9.md); mitigated by a dedicated cross-check test to be added against real written page paths. - Wire CrefTargetResolver and the merged MemberPageIndex/ EmittedTypeIds into DotNetAstModel/DotNetGenerator.Parse: CrefTargetResolver is built eagerly in the DotNetAstModel constructor (needs only the assembly); MemberPageIndex and EmittedTypeIds are computed after DotNetEmitter construction (they need its visibility predicates) via new BuildCrefLinkIndices/ CollectTypeCrefLinkIndex helpers and populated through a new DotNetAstModel.SetMemberPageIndex setter, preserving the existing one-pass Parse contract. No behavior change: these additions are not yet consulted by any rendering code path. --- src/ApiMark.DotNet/CrefTargetResolver.cs | 133 ++++++++++++++++ src/ApiMark.DotNet/DotNetAstModel.cs | 49 ++++++ .../DotNetEmitterGradualDisclosure.cs | 144 ++++++++++++++++++ src/ApiMark.DotNet/DotNetGenerator.cs | 92 ++++++++++- src/ApiMark.DotNet/TypeLinkResolver.cs | 90 +++++++++++ 5 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 src/ApiMark.DotNet/CrefTargetResolver.cs diff --git a/src/ApiMark.DotNet/CrefTargetResolver.cs b/src/ApiMark.DotNet/CrefTargetResolver.cs new file mode 100644 index 0000000..eda446d --- /dev/null +++ b/src/ApiMark.DotNet/CrefTargetResolver.cs @@ -0,0 +1,133 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +using Mono.Cecil; + +namespace ApiMark.DotNet; + +/// +/// Resolves a raw XML-doc cref attribute string to the Mono.Cecil type or member it +/// names, when that symbol is declared in a single, specific assembly. +/// +/// +/// Built once per assembly by walking every type and member and computing its XML-doc +/// identifier via / +/// — the exact same identifier format the C# compiler emits for a <member name="..."> +/// entry in the XML documentation file, and therefore byte-identical to what appears verbatim +/// in a cref="..." attribute for an intra-assembly target. No new identifier-formatting +/// or parsing logic is introduced; this class is purely a reverse index over the builders that +/// already exist. +/// +/// This resolver intentionally applies no visibility, obsolete, or exclude-pattern filtering — +/// it answers only "does a symbol with this XML-doc-ID string exist in this assembly", so that +/// a caller can distinguish "no such symbol" (external assembly, or a malformed/misspelled +/// cref) from "the symbol exists but is filtered out of the generated documentation", which +/// requires a separate, caller-supplied visibility check (see CrefLinkContext). +/// +/// +/// On duplicate XML-doc-ID collisions (which should not occur for well-formed assemblies, but +/// are tolerated defensively the same way tolerates duplicate +/// member IDs in the XML documentation file), the first occurrence encountered while walking +/// wins and later duplicates are silently discarded. +/// +/// +internal sealed class CrefTargetResolver +{ + /// Index of type definitions keyed by their XML doc type identifier (e.g. T:Namespace.Type). + private readonly Dictionary _types = new(StringComparer.Ordinal); + + /// + /// Index of member definitions keyed by their XML doc member identifier + /// (e.g. M:Namespace.Type.Method, P:Namespace.Type.Property). + /// + private readonly Dictionary _members = new(StringComparer.Ordinal); + + /// + /// Initializes a new by walking every type (including + /// nested types) and member declared in . + /// + /// The assembly whose types and members to index. + internal CrefTargetResolver(AssemblyDefinition assembly) + { + // ModuleDefinition.GetTypes() is Mono.Cecil's flattening enumerator — it already + // includes nested types at every depth, so no separate recursive walk is needed. + foreach (var type in assembly.MainModule.GetTypes()) + { + var typeId = DotNetEmitter.BuildTypeId(type); + _types.TryAdd(typeId, type); + + foreach (var member in GetMembersForIndexing(type)) + { + var memberId = DotNetEmitter.BuildMemberId(member); + + // BuildMemberId returns string.Empty for member kinds it does not recognize — + // skip those rather than indexing a collision-prone empty key. + if (memberId.Length > 0) + { + _members.TryAdd(memberId, member); + } + } + } + } + + /// + /// Attempts to resolve to a type declared in the indexed + /// assembly. + /// + /// + /// The raw cref attribute value, including its T: kind prefix (e.g. + /// T:MyNamespace.MyClass). + /// + /// + /// When this method returns , the resolved type; otherwise + /// . + /// + /// when a type with this exact identifier was indexed. + internal bool TryResolveType(string crefId, out TypeDefinition type) => + _types.TryGetValue(crefId, out type!); + + /// + /// Attempts to resolve to a member declared in the indexed + /// assembly. + /// + /// + /// The raw cref attribute value, including its M:/P:/F:/E: + /// kind prefix (e.g. M:MyNamespace.MyClass.DoWork(System.Int32)). + /// + /// + /// When this method returns , the resolved member; otherwise + /// . + /// + /// when a member with this exact identifier was indexed. + internal bool TryResolveMember(string crefId, out IMemberDefinition member) => + _members.TryGetValue(crefId, out member!); + + /// + /// Enumerates every method, property, field, and event declared directly on + /// , for indexing purposes only (no visibility filtering). + /// + /// The type whose members to enumerate. + /// All members declared directly on . + private static IEnumerable GetMembersForIndexing(TypeDefinition type) + { + foreach (var method in type.Methods) + { + yield return method; + } + + foreach (var property in type.Properties) + { + yield return property; + } + + foreach (var field in type.Fields) + { + yield return field; + } + + foreach (var evt in type.Events) + { + yield return evt; + } + } +} diff --git a/src/ApiMark.DotNet/DotNetAstModel.cs b/src/ApiMark.DotNet/DotNetAstModel.cs index e596d4b..ed9cfeb 100644 --- a/src/ApiMark.DotNet/DotNetAstModel.cs +++ b/src/ApiMark.DotNet/DotNetAstModel.cs @@ -129,6 +129,10 @@ internal DotNetAstModel(DotNetAstModelArgs args) NamespaceDescriptions = args.NamespaceDescriptions; Resolver = args.Resolver; Options = args.Options; + + // Visibility-agnostic — needs only the assembly, so it can be built eagerly here rather + // than deferred to a post-construction step (unlike MemberPageIndex below). + CrefTargets = new CrefTargetResolver(args.Assembly); } /// Gets the assembly definition held open for the duration of emit. @@ -160,4 +164,49 @@ internal DotNetAstModel(DotNetAstModelArgs args) /// Gets the generator configuration options. internal DotNetGeneratorOptions Options { get; } + + /// + /// Gets the cref-target resolution index for this assembly, used to resolve a raw + /// <see cref>/<seealso cref> value to the type or member it + /// names when that symbol is declared in this assembly. + /// + internal CrefTargetResolver CrefTargets { get; } + + /// + /// Gets the global member-to-page index used by + /// to resolve a member cref target to its gradual-disclosure page path. Empty until + /// is called. + /// + /// + /// Unlike , this cannot be computed eagerly in the constructor: + /// it depends on visibility rules that are instance methods on , + /// which is constructed from this model (and therefore does not yet exist while this + /// constructor runs). DotNetGenerator.Parse instead calls + /// once, immediately after constructing the that owns this model. + /// + internal IReadOnlyDictionary MemberPageIndex { get; private set; } = + new Dictionary(StringComparer.Ordinal); + + /// + /// Gets the set of XML-doc type identifiers (see ) + /// for every type that will actually be emitted as a page in gradual-disclosure mode + /// (top-level visible types plus all of their visible nested types, transitively). Empty + /// until is called. + /// + internal IReadOnlySet EmittedTypeIds { get; private set; } = new HashSet(StringComparer.Ordinal); + + /// + /// Sets and . Called exactly + /// once, by DotNetGenerator.Parse, after the owning has + /// been constructed. + /// + /// The computed member-to-page index. + /// The computed set of emitted type identifiers. + internal void SetMemberPageIndex( + IReadOnlyDictionary memberPageIndex, + IReadOnlySet emittedTypeIds) + { + MemberPageIndex = memberPageIndex; + EmittedTypeIds = emittedTypeIds; + } } diff --git a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs index 2dd0230..decc93f 100644 --- a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs +++ b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs @@ -760,6 +760,150 @@ private static void WriteCombinedMemberPage( WriteExternalTypesSection(writer, externalTypes); } + // ========================================================================= + // Member page index (cref cross-reference linking support) + // ========================================================================= + + /// + /// Computes, without writing any Markdown, the page key (relative path without the + /// .md extension) that each emitted member of will be + /// written to, reproducing the same overload-group/operator/case-collision/single-member + /// page-grouping decisions that makes when it actually + /// writes those pages. + /// + /// + /// Used by DotNetGenerator.Parse to build the global member-to-page index consulted + /// by when rendering a <see + /// cref>/<seealso cref> reference to a member. The grouping rules + /// reproduced here (ordering, case-insensitive collision detection, pure-overload-group + /// detection, operator segregation) are intentionally kept parallel to — rather than + /// sourced from a single shared implementation with — , + /// , and : those + /// methods interleave table-row accumulation and immediate page writing in an order that + /// does not separate cleanly into a reusable "decide groups" step without a much larger, + /// higher-risk rewrite of an already-shipped, fixture-tested code path. Instead, this + /// function's correctness is independently verified by an end-to-end integration test + /// (see DotNetEmitterGradualDisclosureTests) asserting that every page key this + /// function returns matches an actually-written Markdown file for the test fixtures. + /// + /// The type whose member pages to index. + /// + /// The file-system folder path for the namespace containing (or, + /// for a nested type, the containing type's own folder — i.e. the same value that would be + /// passed as TypePageWriteContext.NamespaceFolderPath when writing + /// 's own page). + /// + /// + /// Predicate deciding whether a given member will actually be emitted — typically bound to + /// for the active visibility/obsolete + /// settings. Members for which this returns are omitted entirely + /// from the result. + /// + /// + /// A map from each emitted member's XML-doc identifier (see + /// ) to its page key (a forward-slash-separated + /// relative path, without the .md extension). Empty when is + /// a delegate (delegates never emit member pages — see ) or has + /// no emitted members. + /// + internal static IReadOnlyDictionary BuildMemberPageIndex( + TypeDefinition type, + string namespaceFolderPath, + Func isMemberEmitted) + { + var index = new Dictionary(StringComparer.Ordinal); + + // Delegates never reach ProcessTypeMembers/CollectTypePageMembers at all — WriteTypePage + // returns immediately after the header sections for a delegate type (see IsDelegate check). + if (IsDelegate(type)) + { + return index; + } + + var typeFolder = $"{namespaceFolderPath}/{FlattenArity(type.Name)}"; + + // Mirror CollectTypePageMembers: constructors first, then alphabetically; operators + // segregated onto their own shared "operators" page. + var allMembers = DotNetEmitter.GetCandidateMembers(type) + .Where(isMemberEmitted) + .OrderBy(m => m.Name == DotNetEmitter.ConstructorMethodName ? 0 : 1) + .ThenBy(m => m.Name) + .ToList(); + + var operatorMethods = allMembers + .OfType() + .Where(IsOperator) + .ToList(); + foreach (var op in operatorMethods) + { + index[BuildMemberId(op)] = $"{typeFolder}/operators"; + } + + var members = allMembers + .Where(m => !(m is MethodDefinition md && IsOperator(md))) + .ToList(); + + // Mirror ProcessTypeMembers: group by case-insensitive sanitized file name, then decide + // single/overload-group/collision handling for each distinct group exactly once. + var caseInsensitiveGroups = new Dictionary>(StringComparer.Ordinal); + foreach (var member in members) + { + var lowerKey = GetSanitizedMemberFileName(member, type).ToLowerInvariant(); + if (!caseInsensitiveGroups.TryGetValue(lowerKey, out var list)) + { + list = []; + caseInsensitiveGroups[lowerKey] = list; + } + + list.Add(member); + } + + var processedKeys = new HashSet(StringComparer.Ordinal); + foreach (var member in members) + { + var lowerKey = GetSanitizedMemberFileName(member, type).ToLowerInvariant(); + if (!processedKeys.Add(lowerKey)) + { + continue; + } + + var group = caseInsensitiveGroups[lowerKey]; + if (group.Count == 1) + { + var sanitizedName = GetSanitizedMemberFileName(group[0], type); + index[BuildMemberId(group[0])] = $"{typeFolder}/{sanitizedName}"; + } + else if (IsPureMethodOverloadGroup(group, type)) + { + // Mirror ProcessOverloadGroup's deterministic representative selection — the + // representative's sanitized file name is the shared overload-group page name + // (matching WriteMethodOverloadPage's use of orderedOverloads[0]). + var orderedOverloads = group + .Cast() + .OrderBy(m => m.GenericParameters.Count) + .ThenBy(m => m.Parameters.Count) + .ThenBy(m => string.Join(",", m.Parameters.Select(p => p.ParameterType.FullName)), StringComparer.Ordinal) + .ToList(); + var overloadFileName = GetSanitizedMemberFileName(orderedOverloads[0], type); + var pageKey = $"{typeFolder}/{overloadFileName}"; + foreach (var overload in orderedOverloads) + { + index[BuildMemberId(overload)] = pageKey; + } + } + else + { + var pageKey = $"{typeFolder}/{lowerKey}"; + foreach (var collisionMember in group) + { + index[BuildMemberId(collisionMember)] = pageKey; + } + } + } + + return index; + } + // ========================================================================= // Member processing helpers // ========================================================================= diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index efc04ba..0e5c733 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -247,7 +247,7 @@ public IApiEmitter Parse(IContext context) // to the returned model: the resolver may still be consulted for lazy metadata // resolution for as long as the assembly itself is alive, so it must share the // assembly's lifetime rather than being disposed here or left undisposed entirely. - return new DotNetEmitter(new DotNetAstModel(new DotNetAstModelArgs( + var model = new DotNetAstModel(new DotNetAstModelArgs( assembly, assemblyResolver, xmlDocs, @@ -256,7 +256,18 @@ public IApiEmitter Parse(IContext context) rootNamespaces, namespaceDescriptions, resolver, - _options))); + _options)); + var emitter = new DotNetEmitter(model); + + // The member-to-page index and emitted-type-id set used for / link resolution require the emitter's own visibility rules + // (ShouldIncludeMember, GetVisibleNestedTypes), so — unlike CrefTargets, which + // DotNetAstModel builds eagerly in its own constructor — they can only be computed + // now, after the emitter exists. + var (memberPageIndex, emittedTypeIds) = BuildCrefLinkIndices(emitter, byNamespace, rootNamespaces); + model.SetMemberPageIndex(memberPageIndex, emittedTypeIds); + + return emitter; } catch { @@ -1138,6 +1149,83 @@ internal static bool IsExcluded(TypeDefinition type, IReadOnlyList exclud var ns = type.Namespace; return excludePatterns.Any(p => p.IsMatch(fullName) || p.IsMatch(ns)); } + + /// + /// Builds the global member-to-page index and emitted-type-id set consulted when + /// resolving <see cref>/<seealso cref> references to real + /// Markdown links, by walking every visible top-level type plus its visible nested types, + /// transitively. + /// + /// + /// Must be called after has been constructed: it relies on the + /// emitter's own visibility rules (, + /// ) so that visibility logic is never + /// duplicated. Only meaningful for gradual-disclosure output — single-file mode never + /// consults either result — but is computed unconditionally in since + /// the output format is not known until is called. + /// + /// The emitter whose visibility rules govern inclusion. + /// Visible top-level types grouped by namespace name. + /// Root namespaces identified during parse. + /// + /// The computed member-to-page index (see ) + /// and emitted-type-id set (see ). + /// + private static (IReadOnlyDictionary MemberPageIndex, IReadOnlySet EmittedTypeIds) BuildCrefLinkIndices( + DotNetEmitter emitter, + IReadOnlyDictionary> byNamespace, + IReadOnlyList rootNamespaces) + { + var memberPageIndex = new Dictionary(StringComparer.Ordinal); + var emittedTypeIds = new HashSet(StringComparer.Ordinal); + + foreach (var (namespaceName, types) in byNamespace) + { + var namespaceFolderPath = DotNetEmitter.GetNamespaceFolderPath(namespaceName, rootNamespaces); + foreach (var type in types) + { + CollectTypeCrefLinkIndex(emitter, type, namespaceFolderPath, memberPageIndex, emittedTypeIds); + } + } + + return (memberPageIndex, emittedTypeIds); + } + + /// + /// Recursively indexes and all of its visible nested types for + /// , mirroring the folder-path recursion + /// DotNetEmitterGradualDisclosure.WriteNestedTypesSection uses when actually writing + /// nested-type pages. + /// + /// The emitter whose visibility rules govern inclusion. + /// The type to index (top-level or nested). + /// + /// The folder path for 's own page (its containing namespace's + /// folder for a top-level type, or its containing type's folder for a nested type). + /// + /// Accumulator for member XML-doc-ID to page-key entries. + /// Accumulator for emitted type XML-doc-ID values. + private static void CollectTypeCrefLinkIndex( + DotNetEmitter emitter, + TypeDefinition type, + string namespaceFolderPath, + Dictionary memberPageIndex, + HashSet emittedTypeIds) + { + emittedTypeIds.Add(DotNetEmitter.BuildTypeId(type)); + + foreach (var (memberId, pageKey) in DotNetEmitterGradualDisclosure.BuildMemberPageIndex( + type, namespaceFolderPath, emitter.ShouldIncludeMember)) + { + memberPageIndex[memberId] = pageKey; + } + + var nestedFolderPath = $"{namespaceFolderPath}/{DotNetEmitter.FlattenArity(type.Name)}"; + foreach (var nested in emitter.GetVisibleNestedTypes(type)) + { + CollectTypeCrefLinkIndex(emitter, nested, nestedFolderPath, memberPageIndex, emittedTypeIds); + } + } } /// Specifies which members are included in the generated API documentation. diff --git a/src/ApiMark.DotNet/TypeLinkResolver.cs b/src/ApiMark.DotNet/TypeLinkResolver.cs index 26c94a2..1d98a3c 100644 --- a/src/ApiMark.DotNet/TypeLinkResolver.cs +++ b/src/ApiMark.DotNet/TypeLinkResolver.cs @@ -184,6 +184,96 @@ public string Linkify( return LinkifyExternalType(typeRef, externalTypes, isNullableAnnotated); } + /// + /// Resolves a cref target already known to be a type declared in the assembly + /// being documented to a Markdown link wrapping . + /// + /// + /// Reuses the same / helpers + /// that already uses for intra-assembly + /// values — is itself a , so no new + /// page-key derivation logic is required. Callers are expected to have already confirmed + /// (via and the emitter's visibility rules) that + /// will actually be emitted as a page before calling this method; + /// this method itself performs no such check and always computes a link when link + /// generation is enabled. + /// + /// The resolved intra-assembly type the cref refers to. + /// + /// The already-formatted display text (e.g. from XmlDocReader.FormatCref) to wrap + /// in the Markdown link. + /// + /// Folder path of the containing Markdown file. + /// + /// A Markdown link of the form [displayText](relative/path.md), or + /// unchanged when link generation is disabled (single-file + /// mode). + /// + internal string LinkifyResolvedType(TypeDefinition type, string displayText, string currentFolder) + { + if (!_generateLinks) + { + return displayText; + } + + var pageKey = GetTypePageKey(type); + var relativePath = ComputeRelativePath(currentFolder, pageKey); + return $"[{displayText}]({relativePath})"; + } + + /// + /// Resolves a cref target already known to be a member declared in the assembly + /// being documented to a Markdown link wrapping , using the + /// gradual-disclosure member-to-page index. + /// + /// + /// Unlike , there is no closed-form page-key computation + /// for a member — in gradual-disclosure mode, members may share a page with their overloads, + /// with same-named-but-different-kind members (case-collision grouping), or with every + /// other operator overload of the same type, decisions made per-type while generating that + /// type's own pages (see DotNetEmitterGradualDisclosure.BuildMemberPageIndex). + /// is therefore required to resolve the member's actual + /// page path. + /// + /// The resolved intra-assembly member the cref refers to. + /// + /// The already-formatted display text (e.g. from XmlDocReader.FormatMemberReference) + /// to wrap in the Markdown link. + /// + /// Folder path of the containing Markdown file. + /// + /// Map from a member's XML-doc identifier (see ) + /// to its page key (a forward-slash-separated relative path, without the .md + /// extension). + /// + /// + /// A Markdown link of the form [displayText](relative/path.md), or + /// unchanged when link generation is disabled (single-file + /// mode) or when has no entry in + /// (e.g. the member is filtered out of the generated + /// documentation). + /// + internal string LinkifyResolvedMember( + IMemberDefinition member, + string displayText, + string currentFolder, + IReadOnlyDictionary memberPageIndex) + { + if (!_generateLinks) + { + return displayText; + } + + var memberId = DotNetEmitter.BuildMemberId(member); + if (!memberPageIndex.TryGetValue(memberId, out var pageKey)) + { + return displayText; + } + + var relativePath = ComputeRelativePath(currentFolder, pageKey); + return $"[{displayText}]({relativePath})"; + } + /// Renders a generic type parameter (e.g. T, TKey) as plain text. /// The generic parameter reference. /// Whether the parameter itself is annotated nullable. From 9ddb878b9c4857737ad7af361447e7f0e49cf09b Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 7 Oct 2026 19:39:28 -0400 Subject: [PATCH 03/13] Thread optional CrefLinkContext through XmlDocReader rendering chain Add a new internal CrefLinkContext record bundling everything needed to resolve and linkify a / target: the CrefTargetResolver and TypeLinkResolver for the current assembly, the merged member page index, delegates to test whether a resolved type/ member is actually emitted, and the current page's folder (needed to compute a relative path to the link target). Thread a new optional, trailing, nullable linkContext parameter through the entire XmlDocReader rendering chain: GetDocumentationText/GetSingleLineDocumentationText -> AppendNodeText (both overloads) -> AppendElementText -> list rendering helpers (AppendListText/AppendInlineMarkerList/AppendMarkerList/ AppendTableList/RenderTermDescription/RenderInlineElement) -> GetInlineReferenceText. Because XmlDocReader is a public type, its public GetSummary/ GetSummaryMarkdown/GetRemarks/GetParams/GetReturns/GetExampleParts methods cannot directly expose an internal CrefLinkContext parameter (CS0051). Each is split into its existing public single-argument overload (behavior unchanged, delegates to the new overload with linkContext: null) plus a new internal two-argument overload that accepts the context. GetExample is deliberately left without an internal overload: it returns the raw element value without going through AppendNodeText/AppendElementText, so threading a link context through it would be a no-op; this is documented in its XML remarks. GetExceptions/GetExceptionDetails are explicitly out of scope per the approved plan and are untouched. In GetInlineReferenceText, a new TryLinkifyCref helper implements the fallback semantics: when linkContext is null, or the cref cannot be resolved via CrefTargetResolver.TryResolveType/TryResolveMember, or the resolved symbol's IsTypeEmitted/IsMemberEmitted delegate returns false, the existing plain-text/code-span behavior is preserved unchanged. Otherwise the resolved target is linkified via TypeLinkResolver.LinkifyResolvedType/LinkifyResolvedMember using the existing display text, and the resulting Markdown link is wrapped in the same code span the plain text would have been wrapped in. All ~100 existing call sites across both emitters and XmlDocReaderTests.cs continue to call the public overloads unchanged (passing no link context), so existing behavior for both single-file and gradual-disclosure modes is unaffected by this commit; CrefLinkContext is not yet constructed or passed by either emitter. Also disambiguates now-ambiguous references in XmlDocReader.cs's own doc comments and in DocumentationCoverageChecker.cs after the overload split. Full existing ApiMark.DotNet.Tests suite (404 tests) passes unchanged. --- src/ApiMark.DotNet/CrefLinkContext.cs | 60 ++++ .../DocumentationCoverageChecker.cs | 2 +- src/ApiMark.DotNet/XmlDocReader.cs | 286 +++++++++++++----- 3 files changed, 277 insertions(+), 71 deletions(-) create mode 100644 src/ApiMark.DotNet/CrefLinkContext.cs diff --git a/src/ApiMark.DotNet/CrefLinkContext.cs b/src/ApiMark.DotNet/CrefLinkContext.cs new file mode 100644 index 0000000..69ead04 --- /dev/null +++ b/src/ApiMark.DotNet/CrefLinkContext.cs @@ -0,0 +1,60 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +using Mono.Cecil; + +namespace ApiMark.DotNet; + +/// +/// Bundles the context needs to resolve a <see cref>/ +/// <seealso cref> reference to a real relative Markdown link instead of its +/// default code-span-only fallback rendering. +/// +/// +/// Every public method accepts this as an optional, trailing, +/// nullable parameter ( by default). When , or +/// when a given cref cannot be resolved to an intra-assembly symbol that will actually +/// be emitted, rendering falls back unchanged to today's code-span-or-plain-text behavior — +/// this is how the single-file emitter (DotNetEmitterSingleFile) opts out of linking +/// entirely, simply by never constructing or passing one. +/// +/// Deliberately carries only primitives, delegates, and types that are already +/// ApiMark.DotNet-internal peers of (, +/// ) — not a DotNetEmitter/DotNetAstModel +/// reference — so that does not take on a hard dependency on the +/// emitter/model layer merely to support cref linking. +/// +/// +/// +/// Resolves a raw cref identifier string to the type or member it names, when that +/// symbol is declared in the assembly being documented. +/// +/// +/// Builds the actual Markdown link once a cref target has been resolved and confirmed +/// to be emitted. +/// +/// +/// Map from a member's XML-doc identifier to its gradual-disclosure page key, consulted by +/// . +/// +/// +/// Returns when the given type will actually be emitted as a page +/// under the active visibility/obsolete/exclude-pattern settings. Typically bound to the +/// active DotNetEmitter's own visibility rules so that this logic is never duplicated. +/// +/// +/// Returns when the given member will actually be emitted. Typically +/// bound to DotNetEmitter.ShouldIncludeMember. +/// +/// +/// Folder path of the Markdown file currently being rendered, relative to the documentation +/// output root — forwarded to / +/// to compute the relative link path. +/// +internal sealed record CrefLinkContext( + CrefTargetResolver Targets, + TypeLinkResolver Resolver, + IReadOnlyDictionary MemberPageIndex, + Func IsTypeEmitted, + Func IsMemberEmitted, + string CurrentFolder); diff --git a/src/ApiMark.DotNet/DocumentationCoverageChecker.cs b/src/ApiMark.DotNet/DocumentationCoverageChecker.cs index 14e6920..6c93eeb 100644 --- a/src/ApiMark.DotNet/DocumentationCoverageChecker.cs +++ b/src/ApiMark.DotNet/DocumentationCoverageChecker.cs @@ -19,7 +19,7 @@ namespace ApiMark.DotNet; /// which are bound to the model's single emission tier. /// /// The bar for "documented" is deliberately shallow: a type or member is considered -/// documented when returns a non-null, non-whitespace +/// documented when returns a non-null, non-whitespace /// string for its XML doc member identifier, or — for a member carrying /// (detected via /// ) — when it has a non-empty diff --git a/src/ApiMark.DotNet/XmlDocReader.cs b/src/ApiMark.DotNet/XmlDocReader.cs index e40d293..88953b5 100644 --- a/src/ApiMark.DotNet/XmlDocReader.cs +++ b/src/ApiMark.DotNet/XmlDocReader.cs @@ -147,13 +147,32 @@ public XmlDocReader( /// contexts (table cells and quick-index bullet lines) where real Markdown block structure /// cannot render. A <list> inside the summary therefore renders as inline /// numbered markers (e.g. (1) item one (2) item two) rather than a real list — see - /// for the full multi-line rendering used in member-detail + /// for the full multi-line rendering used in member-detail /// page bodies. When the member carries an <inheritdoc /> element, the summary /// is resolved from the referenced or inherited base member recursively. /// /// The XML doc member identifier (e.g. T:MyNamespace.MyClass). /// Single-line trimmed summary text, or null. - public string? GetSummary(string memberId) + public string? GetSummary(string memberId) => GetSummary(memberId, null); + + /// + /// Overload of accepting an optional + /// , used internally by the gradual-disclosure emitter to + /// enable <see cref>/<seealso cref> cross-reference linking. + /// Kept as a separate internal overload (rather than adding the parameter to the public + /// method) so the public API surface of is completely + /// unaffected by this feature. + /// + /// The XML doc member identifier (e.g. T:MyNamespace.MyClass). + /// + /// Optional context enabling <see cref>/<seealso cref> references + /// within the summary to render as real relative Markdown links instead of their default + /// code-span-only fallback. See . + /// preserves the exact current fallback rendering unconditionally — this is how + /// single-file emitter output remains completely unaffected by this feature. + /// + /// Single-line trimmed summary text, or null. + internal string? GetSummary(string memberId, CrefLinkContext? linkContext) { var member = ResolveMemberElement(memberId, new HashSet(StringComparer.Ordinal)); if (member == null) @@ -162,7 +181,7 @@ public XmlDocReader( } // Use single-line normalization — summaries must fit on one line by convention - return GetSingleLineDocumentationText(member.Element("summary")); + return GetSingleLineDocumentationText(member.Element("summary"), linkContext); } /// @@ -170,18 +189,27 @@ public XmlDocReader( /// , or null if absent. /// /// - /// Unlike (always collapsed to a single line for compact + /// Unlike (always collapsed to a single line for compact /// contexts), this preserves real multi-line Markdown structure — a <list> /// renders as a blank-line-separated block with each item on its own line, and any prose - /// following the list renders as a separate paragraph — matching how + /// following the list renders as a separate paragraph — matching how /// already renders lists. Intended for member-detail-page bodies, where the summary is the /// primary prose rather than a compact table cell. When the member carries an /// <inheritdoc /> element, the summary is resolved from the referenced or - /// inherited base member recursively, matching . + /// inherited base member recursively, matching . /// /// The XML doc member identifier (e.g. T:MyNamespace.MyClass). /// Multi-line Markdown summary text, or null. - public string? GetSummaryMarkdown(string memberId) + public string? GetSummaryMarkdown(string memberId) => GetSummaryMarkdown(memberId, null); + + /// + /// Overload of accepting an optional + /// . See . + /// + /// The XML doc member identifier (e.g. T:MyNamespace.MyClass). + /// See . + /// Multi-line Markdown summary text, or null. + internal string? GetSummaryMarkdown(string memberId, CrefLinkContext? linkContext) { var member = ResolveMemberElement(memberId, new HashSet(StringComparer.Ordinal)); if (member == null) @@ -189,7 +217,7 @@ public XmlDocReader( return null; } - return GetDocumentationText(member.Element("summary")); + return GetDocumentationText(member.Element("summary"), linkContext); } /// Returns the trimmed remarks text for , or null if absent. @@ -199,7 +227,16 @@ public XmlDocReader( /// /// The XML doc member identifier. /// Trimmed remarks text, or null. - public string? GetRemarks(string memberId) + public string? GetRemarks(string memberId) => GetRemarks(memberId, null); + + /// + /// Overload of accepting an optional + /// . See . + /// + /// The XML doc member identifier. + /// See . + /// Trimmed remarks text, or null. + internal string? GetRemarks(string memberId, CrefLinkContext? linkContext) { var member = ResolveMemberElement(memberId, new HashSet(StringComparer.Ordinal)); if (member == null) @@ -207,7 +244,7 @@ public XmlDocReader( return null; } - return GetDocumentationText(member.Element("remarks")); + return GetDocumentationText(member.Element("remarks"), linkContext); } /// Returns all cref attribute values from <exception> elements for . @@ -236,8 +273,8 @@ public IReadOnlyList GetExceptions(string memberId) /// When the member carries an <inheritdoc /> element, exception details are /// resolved from the referenced or inherited base member recursively. The description uses /// the single-line rendering (), not the - /// multi-line used by and - /// : every caller of this method writes the description into a + /// multi-line used by and + /// : every caller of this method writes the description into a /// raw pipe-delimited Markdown table row (see FileMarkdownWriter.WriteTable), which /// does not escape embedded newlines — a fenced code block or <br/>-driven /// paragraph break in the description would otherwise inject extra rows and corrupt the @@ -276,7 +313,16 @@ public IReadOnlyList GetExceptions(string memberId) /// /// The XML doc member identifier. /// A read-only list of (Name, Description) tuples. - public IReadOnlyList<(string Name, string? Description)> GetParams(string memberId) + public IReadOnlyList<(string Name, string? Description)> GetParams(string memberId) => GetParams(memberId, null); + + /// + /// Overload of accepting an optional + /// . See . + /// + /// The XML doc member identifier. + /// See . + /// A read-only list of (Name, Description) tuples. + internal IReadOnlyList<(string Name, string? Description)> GetParams(string memberId, CrefLinkContext? linkContext) { var member = ResolveMemberElement(memberId, new HashSet(StringComparer.Ordinal)); if (member == null) @@ -287,7 +333,7 @@ public IReadOnlyList GetExceptions(string memberId) return member.Elements("param") .Select(p => ( p.Attribute("name")?.Value ?? string.Empty, - GetSingleLineDocumentationText(p))) + GetSingleLineDocumentationText(p, linkContext))) .Where(p => p.Name.Length > 0) .ToList(); } @@ -299,7 +345,16 @@ public IReadOnlyList GetExceptions(string memberId) /// /// The XML doc member identifier. /// Trimmed returns text, or null. - public string? GetReturns(string memberId) + public string? GetReturns(string memberId) => GetReturns(memberId, null); + + /// + /// Overload of accepting an optional + /// . See . + /// + /// The XML doc member identifier. + /// See . + /// Trimmed returns text, or null. + internal string? GetReturns(string memberId, CrefLinkContext? linkContext) { var member = ResolveMemberElement(memberId, new HashSet(StringComparer.Ordinal)); if (member == null) @@ -307,16 +362,24 @@ public IReadOnlyList GetExceptions(string memberId) return null; } - return GetDocumentationText(member.Element("returns")); + return GetDocumentationText(member.Element("returns"), linkContext); } /// Returns the trimmed example text for , or null if absent. /// /// Returns null when the <example> element is absent or contains only - /// whitespace, matching the null-for-missing contract of , - /// , and . + /// whitespace, matching the null-for-missing contract of , + /// , and . /// When the member carries an <inheritdoc /> element, the example is /// resolved from the referenced or inherited base member recursively. + /// + /// This method has no overload: it returns the raw, + /// untransformed element value rather than routing through + /// /, so a + /// <see cref> inside an <example> element is never linkified + /// here. Use for structured example rendering that + /// does honor a . + /// /// /// The XML doc member identifier. /// Trimmed example text, or null when the element is absent or whitespace-only. @@ -359,7 +422,19 @@ public IReadOnlyList GetExceptions(string memberId) /// A list of (IsCode, Content) pairs, or an empty list when the member is absent or has /// no <example> element. /// - public IReadOnlyList<(bool IsCode, string Content)> GetExampleParts(string memberId) + public IReadOnlyList<(bool IsCode, string Content)> GetExampleParts(string memberId) => GetExampleParts(memberId, null); + + /// + /// Overload of accepting an optional + /// . See . + /// + /// The XML doc member identifier. + /// See . + /// + /// A list of (IsCode, Content) pairs, or an empty list when the member is absent or has + /// no <example> element. + /// + internal IReadOnlyList<(bool IsCode, string Content)> GetExampleParts(string memberId, CrefLinkContext? linkContext) { var member = ResolveMemberElement(memberId, new HashSet(StringComparer.Ordinal)); var el = member?.Element("example"); @@ -370,7 +445,7 @@ public IReadOnlyList GetExceptions(string memberId) // When no children exist, treat the entire value as a single code block return el.Elements("code").Any() - ? BuildMixedExampleParts(el) + ? BuildMixedExampleParts(el, linkContext) : BuildSingleCodeExamplePart(el); } @@ -399,8 +474,9 @@ public IReadOnlyList GetExceptions(string memberId) /// within a prose run rather than emitting them as isolated, broken fragments. /// /// The <example> element. + /// See . /// Ordered list of (IsCode, Content) parts. - private static List<(bool IsCode, string Content)> BuildMixedExampleParts(XElement el) + private static List<(bool IsCode, string Content)> BuildMixedExampleParts(XElement el, CrefLinkContext? linkContext) { var parts = new List<(bool IsCode, string Content)>(); var proseBuilder = new StringBuilder(); @@ -433,13 +509,13 @@ void FlushProse() { // Render the paragraph into the accumulator, then flush so that each // becomes a distinct prose part rather than merging with adjacent content - AppendNodeText(proseBuilder, paraElement.Nodes()); + AppendNodeText(proseBuilder, paraElement.Nodes(), linkContext: linkContext); FlushProse(); } else { // Text nodes and inline elements — accumulate for combined prose rendering - AppendNodeText(proseBuilder, node); + AppendNodeText(proseBuilder, node, linkContext: linkContext); } } @@ -642,14 +718,15 @@ void FlushProse() /// immediately before returning. This method and /// are the only two call sites that supply a /// non- map; every other caller of - /// + /// /// is eventually squashed through directly (not via /// 's protected path), where a fenced block would /// never be valid, so those callers deliberately keep passing . /// /// The XML element whose text content to extract, or null. + /// See . /// Normalized text, or null when the element is absent or empty. - private static string? GetDocumentationText(XElement? element) + private static string? GetDocumentationText(XElement? element, CrefLinkContext? linkContext = null) { if (element == null) { @@ -658,7 +735,7 @@ void FlushProse() var codeBlocks = new Dictionary(StringComparer.Ordinal); var builder = new StringBuilder(); - AppendNodeText(builder, element.Nodes(), singleLine: false, codeBlocks); + AppendNodeText(builder, element.Nodes(), singleLine: false, codeBlocks, linkContext); var text = RestoreCodeBlockPlaceholders(NormalizeDocumentationText(builder.ToString()), codeBlocks); return text.Length == 0 ? null : text; } @@ -674,8 +751,9 @@ void FlushProse() /// internal spacing inside a code span survives this single-line rendering too. /// /// The XML element whose text content to extract, or null. + /// See . /// Single-line trimmed text, or null when the element is absent or empty. - private static string? GetSingleLineDocumentationText(XElement? element) + private static string? GetSingleLineDocumentationText(XElement? element, CrefLinkContext? linkContext = null) { if (element == null) { @@ -684,7 +762,7 @@ void FlushProse() var codeBlocks = new Dictionary(StringComparer.Ordinal); var builder = new StringBuilder(); - AppendNodeText(builder, element.Nodes(), singleLine: true, codeBlocks); + AppendNodeText(builder, element.Nodes(), singleLine: true, codeBlocks, linkContext); var text = RestoreCodeBlockPlaceholders(NormalizeSingleLine(builder.ToString()), codeBlocks); return text.Length == 0 ? null : text; } @@ -711,11 +789,13 @@ void FlushProse() /// elements to always render as an unprotected inline span instead of a fenced block (see /// ). /// + /// See . private static void AppendNodeText( StringBuilder builder, IEnumerable nodes, bool singleLine = false, - Dictionary? codeBlocks = null) + Dictionary? codeBlocks = null, + CrefLinkContext? linkContext = null) { foreach (var node in nodes) { @@ -725,7 +805,7 @@ private static void AppendNodeText( builder.Append(text.Value); break; case XElement element: - AppendElementText(builder, element, singleLine, codeBlocks); + AppendElementText(builder, element, singleLine, codeBlocks, linkContext); break; } } @@ -738,13 +818,15 @@ private static void AppendNodeText( /// /// The string builder that accumulates the output text. /// The single XML node to process. - /// See . - /// See . + /// See . + /// See . + /// See . private static void AppendNodeText( StringBuilder builder, XNode node, bool singleLine = false, - Dictionary? codeBlocks = null) + Dictionary? codeBlocks = null, + CrefLinkContext? linkContext = null) { switch (node) { @@ -752,7 +834,7 @@ private static void AppendNodeText( builder.Append(text.Value); break; case XElement element: - AppendElementText(builder, element, singleLine, codeBlocks); + AppendElementText(builder, element, singleLine, codeBlocks, linkContext); break; } } @@ -764,26 +846,28 @@ private static void AppendNodeText( /// /// The string builder that accumulates the output text. /// The XML element to render. - /// See . - /// See . + /// See . + /// See . + /// See . private static void AppendElementText( StringBuilder builder, XElement element, bool singleLine = false, - Dictionary? codeBlocks = null) + Dictionary? codeBlocks = null, + CrefLinkContext? linkContext = null) { switch (element.Name.LocalName) { case "see": case "seealso": - builder.Append(GetInlineReferenceText(element)); + builder.Append(GetInlineReferenceText(element, linkContext)); break; case "paramref": case "typeparamref": builder.Append(element.Attribute("name")?.Value ?? string.Empty); break; case "para": - AppendNodeText(builder, element.Nodes(), singleLine, codeBlocks); + AppendNodeText(builder, element.Nodes(), singleLine, codeBlocks, linkContext); builder.AppendLine(); break; case "c": @@ -810,10 +894,10 @@ private static void AppendElementText( AppendCodeElementText(builder, element, singleLine, codeBlocks); break; case "list": - AppendListText(builder, element, singleLine); + AppendListText(builder, element, singleLine, linkContext); break; default: - AppendNodeText(builder, element.Nodes(), singleLine, codeBlocks); + AppendNodeText(builder, element.Nodes(), singleLine, codeBlocks, linkContext); break; } } @@ -830,7 +914,7 @@ private static void AppendElementText( /// (i.e. only from ), and the dedented content /// spans multiple lines; every other caller — including table-cell contexts /// (, whose callers such as - /// and feed raw Markdown table + /// and feed raw Markdown table /// rows where an embedded literal newline would corrupt the table), nested list items /// (), and <example> mixed prose /// () — always takes the inline-span branch instead, @@ -846,8 +930,8 @@ private static void AppendElementText( /// /// The string builder that accumulates the output text. /// The <code> element to render. - /// See . - /// See . + /// See . + /// See . private static void AppendCodeElementText( StringBuilder builder, XElement element, @@ -955,8 +1039,8 @@ private static string RestoreCodeBlockPlaceholders(string text, Dictionary<term>/<description>/<item> /// (such as <c>, <see>, and <paramref>) are rendered - /// via the existing dispatch. - /// In single-line mode (used for table cells and ), a real + /// via the existing dispatch. + /// In single-line mode (used for table cells and ), a real /// multi-line list would be collapsed into unreadable run-together text by /// , so the list instead renders inline as numbered /// markers — e.g. (1) item one (2) item two — for every list type @@ -968,11 +1052,12 @@ private static string RestoreCodeBlockPlaceholders(string text, Dictionary, renders the list as inline numbered markers instead of a /// real multi-line Markdown block. /// - private static void AppendListText(StringBuilder builder, XElement element, bool singleLine) + /// See . + private static void AppendListText(StringBuilder builder, XElement element, bool singleLine, CrefLinkContext? linkContext = null) { if (singleLine) { - AppendInlineMarkerList(builder, element); + AppendInlineMarkerList(builder, element, linkContext); return; } @@ -985,14 +1070,14 @@ private static void AppendListText(StringBuilder builder, XElement element, bool switch (listType) { case "table": - AppendTableList(builder, element); + AppendTableList(builder, element, linkContext); break; case "number": - AppendMarkerList(builder, element, ordered: true); + AppendMarkerList(builder, element, ordered: true, linkContext); break; default: // "bullet", absent, or any unknown type renders as a bullet list - AppendMarkerList(builder, element, ordered: false); + AppendMarkerList(builder, element, ordered: false, linkContext); break; } @@ -1007,7 +1092,7 @@ private static void AppendListText(StringBuilder builder, XElement element, bool /// /// /// Used wherever a real multi-line Markdown list cannot be rendered — table cells and the - /// single-line path — because the surrounding normalizer collapses + /// single-line path — because the surrounding normalizer collapses /// newlines into spaces, which would otherwise run every item together with no separation. /// Applied uniformly regardless of the list's type attribute (bullet, /// number, or table) since none of them can express real block structure @@ -1020,15 +1105,16 @@ private static void AppendListText(StringBuilder builder, XElement element, bool /// /// The string builder that accumulates the output text. /// The <list> element whose items to render. - private static void AppendInlineMarkerList(StringBuilder builder, XElement element) + /// See . + private static void AppendInlineMarkerList(StringBuilder builder, XElement element, CrefLinkContext? linkContext = null) { var itemTexts = element.Elements("item") - .Select(item => FormatTermDescription(RenderTermDescription(item))) + .Select(item => FormatTermDescription(RenderTermDescription(item, linkContext))) .Where(text => text.Length > 0) .ToList(); var header = element.Element("listheader"); - var headerText = header != null ? FormatListHeaderText(RenderTermDescription(header)) : string.Empty; + var headerText = header != null ? FormatListHeaderText(RenderTermDescription(header, linkContext)) : string.Empty; if (itemTexts.Count == 0 && headerText.Length == 0) { @@ -1069,14 +1155,15 @@ private static void AppendInlineMarkerList(StringBuilder builder, XElement eleme /// The string builder that accumulates the output text. /// The <list> element whose items to render. /// for a numbered list; for a bullet list. - private static void AppendMarkerList(StringBuilder builder, XElement element, bool ordered) + /// See . + private static void AppendMarkerList(StringBuilder builder, XElement element, bool ordered, CrefLinkContext? linkContext = null) { // A leading bold line is emitted for a when present so header // context is not lost in a bullet/number list (which has no header row). var header = element.Element("listheader"); if (header != null) { - var headerText = FormatListHeaderText(RenderTermDescription(header)); + var headerText = FormatListHeaderText(RenderTermDescription(header, linkContext)); if (headerText.Length > 0) { // headerText is already fully bolded by FormatListHeaderText; wrapping it again @@ -1086,7 +1173,7 @@ private static void AppendMarkerList(StringBuilder builder, XElement element, bo } var marker = ordered ? "1." : "-"; - foreach (var itemText in element.Elements("item").Select(item => FormatTermDescription(RenderTermDescription(item)))) + foreach (var itemText in element.Elements("item").Select(item => FormatTermDescription(RenderTermDescription(item, linkContext)))) { // Skip items that render to no text (for example an empty ) so no // stray "- "/"1. " marker line is emitted for an item with nothing to show @@ -1106,7 +1193,8 @@ private static void AppendMarkerList(StringBuilder builder, XElement element, bo /// /// The string builder that accumulates the output text. /// The <list> element whose items to render as table rows. - private static void AppendTableList(StringBuilder builder, XElement element) + /// See . + private static void AppendTableList(StringBuilder builder, XElement element, CrefLinkContext? linkContext = null) { var termHeader = "Term"; var descriptionHeader = "Description"; @@ -1114,7 +1202,7 @@ private static void AppendTableList(StringBuilder builder, XElement element) var header = element.Element("listheader"); if (header != null) { - var (headerTerm, headerDescription) = RenderTermDescription(header); + var (headerTerm, headerDescription) = RenderTermDescription(header, linkContext); if (headerTerm.Length > 0) { termHeader = headerTerm; @@ -1135,7 +1223,7 @@ private static void AppendTableList(StringBuilder builder, XElement element) foreach (var item in element.Elements("item")) { - var (term, description) = RenderTermDescription(item); + var (term, description) = RenderTermDescription(item, linkContext); // Skip rows whose term and description both render empty so no blank table row is // emitted; the header row is always kept so the table structure remains valid @@ -1167,8 +1255,9 @@ private static string EscapeTableCell(string cell) /// content is returned as the term. /// /// The <item> or <listheader> element. + /// See . /// A tuple of the rendered term and description; either may be empty. - private static (string Term, string Description) RenderTermDescription(XElement element) + private static (string Term, string Description) RenderTermDescription(XElement element, CrefLinkContext? linkContext = null) { var term = element.Element("term"); var description = element.Element("description"); @@ -1176,12 +1265,12 @@ private static (string Term, string Description) RenderTermDescription(XElement // A bare item (no /) contributes its inline content as the term if (term == null && description == null) { - return (RenderInlineElement(element), string.Empty); + return (RenderInlineElement(element, linkContext), string.Empty); } return ( - term != null ? RenderInlineElement(term) : string.Empty, - description != null ? RenderInlineElement(description) : string.Empty); + term != null ? RenderInlineElement(term, linkContext) : string.Empty, + description != null ? RenderInlineElement(description, linkContext) : string.Empty); } /// @@ -1229,16 +1318,17 @@ private static string FormatListHeaderText((string Term, string Description) par /// /// Renders the inline content of to a single line via the - /// shared dispatch, so + /// shared dispatch, so /// nested inline elements such as <c>, <see>, and /// <paramref> render correctly inside list items. /// /// The element whose inline content to render. + /// See . /// The single-line rendered text. - private static string RenderInlineElement(XElement element) + private static string RenderInlineElement(XElement element, CrefLinkContext? linkContext = null) { var builder = new StringBuilder(); - AppendNodeText(builder, element.Nodes()); + AppendNodeText(builder, element.Nodes(), linkContext: linkContext); return NormalizeSingleLine(builder.ToString()); } @@ -1323,10 +1413,29 @@ private static int ComputeFenceLength(string content) /// distinct from surrounding prose, matching how <c> content is rendered. A /// langword value, explicit inner text, and a constructor cref are all left /// unwrapped. + /// + /// When is supplied and the cref resolves — via + /// / + /// — to a symbol declared in the assembly being documented that will actually be emitted + /// (per /), + /// the formatted display text is wrapped in a real relative Markdown link (via + /// /) + /// before being wrapped in the code span, in exactly the position the plain text would + /// otherwise have been wrapped. In every other case — is + /// , the cref is external/malformed/unresolvable, or the resolved + /// symbol is filtered out of the generated documentation — rendering is completely + /// unchanged from the code-span-only fallback described above. A constructor cref (whose + /// ShouldWrapInCodeSpan result is always ) is never linked, + /// matching this existing precedent. + /// /// /// The inline reference element to render. + /// + /// Optional context enabling cross-reference linking for a resolvable intra-assembly + /// cref. See . + /// /// A non-null display string; may be empty when no renderable content is found. - private static string GetInlineReferenceText(XElement element) + private static string GetInlineReferenceText(XElement element, CrefLinkContext? linkContext = null) { var langword = element.Attribute("langword")?.Value; if (!string.IsNullOrWhiteSpace(langword)) @@ -1350,12 +1459,49 @@ private static string GetInlineReferenceText(XElement element) // this request and returns the escaped, prose-safe form internally, since it is the // only case never wrapped. var (text, shouldWrapInCodeSpan) = FormatCref(cref, forCodeSpan: true); - return shouldWrapInCodeSpan && text.Length > 0 ? FormatAsInlineCodeSpan(text) : text; + if (!shouldWrapInCodeSpan || text.Length == 0) + { + return text; + } + + var linkedText = TryLinkifyCref(cref, text, linkContext); + return FormatAsInlineCodeSpan(linkedText); } return string.Empty; } + /// + /// Attempts to resolve against and, + /// when resolution succeeds and the target will actually be emitted, returns + /// wrapped in a real relative Markdown link. Returns + /// unchanged in every other case (no context, external + /// type, filtered-out member, or malformed cref). + /// + /// The raw cref attribute value, including its kind prefix. + /// The already-formatted display text to wrap when linking. + /// Optional cross-reference linking context. + /// , or that text wrapped in a Markdown link. + private static string TryLinkifyCref(string cref, string displayText, CrefLinkContext? linkContext) + { + if (linkContext == null) + { + return displayText; + } + + if (linkContext.Targets.TryResolveType(cref, out var type) && linkContext.IsTypeEmitted(type)) + { + return linkContext.Resolver.LinkifyResolvedType(type, displayText, linkContext.CurrentFolder); + } + + if (linkContext.Targets.TryResolveMember(cref, out var member) && linkContext.IsMemberEmitted(member)) + { + return linkContext.Resolver.LinkifyResolvedMember(member, displayText, linkContext.CurrentFolder, linkContext.MemberPageIndex); + } + + return displayText; + } + /// /// Wraps in an inline Markdown code span via /// . From a1704a6562121cdbc9632b61f67675feb8c4dade Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 7 Oct 2026 19:45:15 -0400 Subject: [PATCH 04/13] Add unit tests for CrefTargetResolver, link helpers, and link context - Add CrefTargetResolverTests.cs: covers resolving top-level and nested types, methods and fields, resolving a private member (confirms no visibility filtering), and failing to resolve unknown identifiers, external-assembly types, and malformed/unresolvable cref strings. - Extend TypeLinkResolverTests.cs with cases for LinkifyResolvedType/LinkifyResolvedMember: link generation enabled vs. disabled, and a member found vs. not found in the supplied member page index. - Extend XmlDocReaderTests.cs with a dedicated CrefLinkContext region covering every fallback/success path from the approved plan, exercised directly against the fixture assembly (no full emitter run required): null link context (baseline unchanged), a resolved and emitted type, a resolved and emitted member, a resolved member whose IsMemberEmitted delegate returns false (filtered out), an external framework type, a malformed cref, a constructor cref (never linkified, matching existing precedent), and a seealso cref sharing the same resolution path as see. All 426 tests pass (404 pre-existing + 22 new) on net8.0. --- .../CrefTargetResolverTests.cs | 176 +++++++++ .../TypeLinkResolverTests.cs | 101 ++++++ .../ApiMark.DotNet.Tests/XmlDocReaderTests.cs | 335 ++++++++++++++++++ 3 files changed, 612 insertions(+) create mode 100644 test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs diff --git a/test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs b/test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs new file mode 100644 index 0000000..6f928a6 --- /dev/null +++ b/test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs @@ -0,0 +1,176 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +using ApiMark.DotNet; +using Mono.Cecil; +using Xunit; + +namespace ApiMark.DotNet.Tests; + +/// Unit tests for . +public class CrefTargetResolverTests : IDisposable +{ + private readonly AssemblyDefinition _assembly; + private readonly CrefTargetResolver _resolver; + + /// Initializes the test fixture by loading the fixture assembly and building the resolver. + public CrefTargetResolverTests() + { + _assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + _resolver = new CrefTargetResolver(_assembly); + } + + /// Disposes the loaded assembly after each test. + public void Dispose() + { + _assembly.Dispose(); + GC.SuppressFinalize(this); + } + + /// Validates that a top-level type declared in the assembly resolves by its XML-doc type identifier. + [Fact] + public void CrefTargetResolver_TryResolveType_TopLevelType_ReturnsTrueAndType() + { + // Arrange + var expected = _assembly.MainModule.Types.First(t => t.Name == "SampleClass"); + var crefId = DotNetEmitter.BuildTypeId(expected); + + // Act + var resolved = _resolver.TryResolveType(crefId, out var type); + + // Assert + Assert.True(resolved); + Assert.Same(expected, type); + } + + /// Validates that a nested type resolves by its XML-doc type identifier. + [Fact] + public void CrefTargetResolver_TryResolveType_NestedType_ReturnsTrueAndType() + { + // Arrange + var outer = _assembly.MainModule.Types.First(t => t.Name == "OuterClass"); + var expected = outer.NestedTypes.First(t => t.Name == "Inner"); + var crefId = DotNetEmitter.BuildTypeId(expected); + + // Act + var resolved = _resolver.TryResolveType(crefId, out var type); + + // Assert + Assert.True(resolved); + Assert.Same(expected, type); + } + + /// Validates that an unknown type identifier (never indexed) fails to resolve. + [Fact] + public void CrefTargetResolver_TryResolveType_UnknownIdentifier_ReturnsFalse() + { + // Act + var resolved = _resolver.TryResolveType("T:Does.Not.Exist", out var type); + + // Assert + Assert.False(resolved); + Assert.Null(type); + } + + /// + /// Validates that an external-assembly type identifier (e.g. a BCL type never declared in + /// the indexed assembly) fails to resolve, matching the fallback requirement for external + /// crefs. + /// + [Fact] + public void CrefTargetResolver_TryResolveType_ExternalAssemblyType_ReturnsFalse() + { + // Act + var resolved = _resolver.TryResolveType("T:System.ArgumentNullException", out var type); + + // Assert + Assert.False(resolved); + Assert.Null(type); + } + + /// Validates that a method declared in the assembly resolves by its XML-doc member identifier. + [Fact] + public void CrefTargetResolver_TryResolveMember_Method_ReturnsTrueAndMember() + { + // Arrange + var type = _assembly.MainModule.Types.First(t => t.Name == "SampleClass"); + var expected = type.Methods.First(m => m.Name == "Reset"); + var crefId = DotNetEmitter.BuildMemberId(expected); + + // Act + var resolved = _resolver.TryResolveMember(crefId, out var member); + + // Assert + Assert.True(resolved); + Assert.Same(expected, member); + } + + /// + /// Validates that a filtered-by-visibility member (e.g. a private member) is still indexed + /// and resolvable — applies no visibility filtering; the + /// emitted-visibility check is a separate, caller-supplied concern. + /// + [Fact] + public void CrefTargetResolver_TryResolveMember_PrivateMember_StillResolves() + { + // Arrange + var type = _assembly.MainModule.Types.First(t => t.Name == "ProtectedMembersClass"); + var expected = type.Methods.First(m => m.Name == "PrivateMethod" && m.IsPrivate); + var crefId = DotNetEmitter.BuildMemberId(expected); + + // Act + var resolved = _resolver.TryResolveMember(crefId, out var member); + + // Assert + Assert.True(resolved); + Assert.Same(expected, member); + } + + /// Validates that an unknown member identifier (never indexed) fails to resolve. + [Fact] + public void CrefTargetResolver_TryResolveMember_UnknownIdentifier_ReturnsFalse() + { + // Act + var resolved = _resolver.TryResolveMember("M:Does.Not.Exist", out var member); + + // Assert + Assert.False(resolved); + Assert.Null(member); + } + + /// + /// Validates that a malformed cref string (the compiler's !: "could not resolve" + /// prefix) fails to resolve as either a type or a member. + /// + [Fact] + public void CrefTargetResolver_TryResolveType_MalformedCref_ReturnsFalse() + { + // Act + var resolvedAsType = _resolver.TryResolveType("!:NotAWellFormedCrefString", out var type); + var resolvedAsMember = _resolver.TryResolveMember("!:NotAWellFormedCrefString", out var member); + + // Assert + Assert.False(resolvedAsType); + Assert.Null(type); + Assert.False(resolvedAsMember); + Assert.Null(member); + } + + /// Validates that a field declared in the assembly resolves by its XML-doc member identifier. + [Fact] + public void CrefTargetResolver_TryResolveMember_Field_ReturnsTrueAndMember() + { + // Arrange: find any type in the fixture assembly with a field, to avoid depending on a + // specific fixture file's internal layout beyond field existence. + var type = _assembly.MainModule.GetTypes().First(t => t.Fields.Count > 0); + var expected = type.Fields.First(); + var crefId = DotNetEmitter.BuildMemberId(expected); + + // Act + var resolved = _resolver.TryResolveMember(crefId, out var member); + + // Assert + Assert.True(resolved); + Assert.Same(expected, member); + } +} diff --git a/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs b/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs index acd7dab..fc42e50 100644 --- a/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs +++ b/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs @@ -442,4 +442,105 @@ public void TypeLinkResolver_Linkify_NestedType_FromParentPageVsOtherPage_Produc Assert.Equal("[Inner](Inner.md)", fromParentPage); Assert.Equal("[Inner](OuterClass/Inner.md)", fromOtherPage); } + + /// + /// Validates that produces a Markdown + /// link wrapping the supplied display text when link generation is enabled. + /// + [Fact] + public void TypeLinkResolver_LinkifyResolvedType_GenerateLinksTrue_ReturnsMarkdownLink() + { + // Arrange + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"], generateLinks: true); + var type = _assembly.MainModule.Types.First(t => t.Name == "SampleClass"); + + // Act + var result = resolver.LinkifyResolvedType(type, "SampleClass", "ApiMark.DotNet.Fixtures"); + + // Assert + Assert.Equal("[SampleClass](SampleClass.md)", result); + } + + /// + /// Validates that returns the display + /// text unchanged when link generation is disabled (single-file mode). + /// + [Fact] + public void TypeLinkResolver_LinkifyResolvedType_GenerateLinksFalse_ReturnsDisplayTextUnchanged() + { + // Arrange + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"], generateLinks: false); + var type = _assembly.MainModule.Types.First(t => t.Name == "SampleClass"); + + // Act + var result = resolver.LinkifyResolvedType(type, "SampleClass", "ApiMark.DotNet.Fixtures"); + + // Assert + Assert.Equal("SampleClass", result); + } + + /// + /// Validates that produces a Markdown + /// link using the supplied member page index when link generation is enabled and the + /// member has an entry. + /// + [Fact] + public void TypeLinkResolver_LinkifyResolvedMember_FoundInIndex_ReturnsMarkdownLink() + { + // Arrange + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"], generateLinks: true); + var type = _assembly.MainModule.Types.First(t => t.Name == "SampleClass"); + var method = type.Methods.First(m => m.Name == "Reset"); + var memberId = DotNetEmitter.BuildMemberId(method); + var memberPageIndex = new Dictionary { [memberId] = "SampleClass/Reset" }; + + // Act + var result = resolver.LinkifyResolvedMember(method, "SampleClass.Reset", "", memberPageIndex); + + // Assert + Assert.Equal("[SampleClass.Reset](SampleClass/Reset.md)", result); + } + + /// + /// Validates that returns the display + /// text unchanged when the member has no entry in the member page index (for example, it + /// was filtered out of the generated documentation). + /// + [Fact] + public void TypeLinkResolver_LinkifyResolvedMember_NotInIndex_ReturnsDisplayTextUnchanged() + { + // Arrange + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"], generateLinks: true); + var type = _assembly.MainModule.Types.First(t => t.Name == "SampleClass"); + var method = type.Methods.First(m => m.Name == "Reset"); + var memberPageIndex = new Dictionary(); + + // Act + var result = resolver.LinkifyResolvedMember(method, "SampleClass.Reset", "ApiMark.DotNet.Fixtures", memberPageIndex); + + // Assert + Assert.Equal("SampleClass.Reset", result); + } + + /// + /// Validates that returns the display + /// text unchanged when link generation is disabled (single-file mode), regardless of + /// whether the member has an entry in the member page index. + /// + [Fact] + public void TypeLinkResolver_LinkifyResolvedMember_GenerateLinksFalse_ReturnsDisplayTextUnchanged() + { + // Arrange + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"], generateLinks: false); + var type = _assembly.MainModule.Types.First(t => t.Name == "SampleClass"); + var method = type.Methods.First(m => m.Name == "Reset"); + var memberId = DotNetEmitter.BuildMemberId(method); + var memberPageIndex = new Dictionary { [memberId] = "SampleClass/Reset" }; + + // Act + var result = resolver.LinkifyResolvedMember(method, "SampleClass.Reset", "ApiMark.DotNet.Fixtures", memberPageIndex); + + // Assert + Assert.Equal("SampleClass.Reset", result); + } } diff --git a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs index 0a9614c..9a1d57d 100644 --- a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs +++ b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs @@ -1,5 +1,6 @@ using System.Xml.Linq; using ApiMark.DotNet; +using Mono.Cecil; using Xunit; namespace ApiMark.DotNet.Tests; @@ -3350,4 +3351,338 @@ line two File.Delete(path); } } + + #region CrefLinkContext fallback/success paths + + /// + /// Builds a over the fixture assembly, so cref-linking tests + /// can resolve real intra-assembly types/members without needing a full emitter run. + /// + /// Delegate controlling which resolved types count as "emitted". + /// Delegate controlling which resolved members count as "emitted". + /// The member XML-doc-ID-to-page-path index; defaults to empty. + /// The folder of the page doing the rendering; defaults to the root. + /// A tuple of the loaded assembly (caller must dispose) and the constructed context. + private static (AssemblyDefinition Assembly, CrefLinkContext Context) BuildFixtureLinkContext( + Func? isTypeEmitted = null, + Func? isMemberEmitted = null, + IReadOnlyDictionary? memberPageIndex = null, + string currentFolder = "ApiMark.DotNet.Fixtures") + { + var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var targets = new CrefTargetResolver(assembly); + var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"], generateLinks: true); + var context = new CrefLinkContext( + targets, + resolver, + memberPageIndex ?? new Dictionary(), + isTypeEmitted ?? (_ => true), + isMemberEmitted ?? (_ => true), + currentFolder); + return (assembly, context); + } + + /// + /// Validates that, with a link context, a resolvable intra-assembly + /// type cref still renders exactly as today (code-span, no link) — the baseline unchanged + /// behavior that all ~100 existing call sites rely on. + /// + [Fact] + public void XmlDocReader_GetSummary_NullLinkContext_RendersCodeSpanOnlyNoLink() + { + // Arrange + var path = WriteXmlDoc(""" + + See . + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var summary = reader.GetSummary("T:Foo.Bar", null); + + // Assert + Assert.Equal("See `SampleClass`.", summary); + } + finally + { + File.Delete(path); + } + } + + /// + /// Validates that a cref resolving to an intra-assembly type whose + /// IsTypeEmitted delegate returns renders as a real Markdown + /// link wrapped in the same code span the plain text would have used. + /// + [Fact] + public void XmlDocReader_GetSummary_ResolvedEmittedType_RendersLinkedCodeSpan() + { + // Arrange + var (assembly, context) = BuildFixtureLinkContext(); + try + { + var path = WriteXmlDoc(""" + + See . + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var summary = reader.GetSummary("T:Foo.Bar", context); + + // Assert + Assert.Equal("See `[SampleClass](SampleClass.md)`.", summary); + } + finally + { + File.Delete(path); + } + } + finally + { + assembly.Dispose(); + } + } + + /// + /// Validates that a cref resolving to an intra-assembly member whose + /// IsMemberEmitted delegate returns and which has an entry + /// in the member page index renders as a real Markdown link. + /// + [Fact] + public void XmlDocReader_GetSummary_ResolvedEmittedMember_RendersLinkedCodeSpan() + { + // Arrange + var (assembly, baseContext) = BuildFixtureLinkContext(); + try + { + var type = assembly.MainModule.Types.First(t => t.Name == "SampleClass"); + var method = type.Methods.First(m => m.Name == "Reset"); + var memberId = DotNetEmitter.BuildMemberId(method); + var context = baseContext with { MemberPageIndex = new Dictionary { [memberId] = "ApiMark.DotNet.Fixtures/SampleClass/Reset" } }; + + var path = WriteXmlDoc($""" + + See . + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var summary = reader.GetSummary("T:Foo.Bar", context); + + // Assert + Assert.Equal("See `[SampleClass.Reset](SampleClass/Reset.md)`.", summary); + } + finally + { + File.Delete(path); + } + } + finally + { + assembly.Dispose(); + } + } + + /// + /// Validates that a cref resolving to an intra-assembly type/member whose corresponding + /// IsTypeEmitted/IsMemberEmitted delegate returns + /// (filtered out of the generated documentation, e.g. by visibility) falls back to the + /// existing code-span-only, no-link behavior. + /// + [Fact] + public void XmlDocReader_GetSummary_ResolvedButNotEmittedMember_FallsBackToCodeSpanOnly() + { + // Arrange + var (assembly, baseContext) = BuildFixtureLinkContext(isMemberEmitted: _ => false); + try + { + var type = assembly.MainModule.Types.First(t => t.Name == "ProtectedMembersClass"); + var method = type.Methods.First(m => m.Name == "PrivateMethod"); + var memberId = DotNetEmitter.BuildMemberId(method); + + var path = WriteXmlDoc($""" + + See . + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var summary = reader.GetSummary("T:Foo.Bar", baseContext); + + // Assert: unlinked, same as the null-context baseline + Assert.Equal("See `ProtectedMembersClass.PrivateMethod()`.", summary); + } + finally + { + File.Delete(path); + } + } + finally + { + assembly.Dispose(); + } + } + + /// + /// Validates that a cref to an external (out-of-assembly) type falls back to the existing + /// code-span-only, no-link behavior, since cannot resolve + /// it. + /// + [Fact] + public void XmlDocReader_GetSummary_ExternalTypeCref_FallsBackToCodeSpanOnly() + { + // Arrange + var (assembly, context) = BuildFixtureLinkContext(); + try + { + var path = WriteXmlDoc(""" + + See . + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var summary = reader.GetSummary("T:Foo.Bar", context); + + // Assert + Assert.Equal("See `ArgumentNullException`.", summary); + } + finally + { + File.Delete(path); + } + } + finally + { + assembly.Dispose(); + } + } + + /// + /// Validates that a malformed/unresolvable cref (the compiler's !: prefix) falls + /// back to the existing behavior unchanged, with a non-null link context present. + /// + [Fact] + public void XmlDocReader_GetSummary_MalformedCref_FallsBackUnchanged() + { + // Arrange + var (assembly, context) = BuildFixtureLinkContext(); + try + { + var path = WriteXmlDoc(""" + + See . + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var summary = reader.GetSummary("T:Foo.Bar", context); + + // Assert + Assert.Equal("See NotAWellFormedCrefString.", summary); + } + finally + { + File.Delete(path); + } + } + finally + { + assembly.Dispose(); + } + } + + /// + /// Validates that a constructor cref (which FormatCref already renders without a + /// code-span wrap) is never linkified, per existing precedent, even when the constructor + /// resolves and is "emitted". + /// + [Fact] + public void XmlDocReader_GetSummary_ConstructorCref_FallsBackUnchanged() + { + // Arrange + var (assembly, context) = BuildFixtureLinkContext(); + try + { + var type = assembly.MainModule.Types.First(t => t.Name == "SampleClass"); + var ctor = type.Methods.First(m => m.IsConstructor); + var memberId = DotNetEmitter.BuildMemberId(ctor); + + var path = WriteXmlDoc($""" + + See . + + """); + try + { + // Act + var readerNoContext = new XmlDocReader(path); + var withoutContext = readerNoContext.GetSummary("T:Foo.Bar", null); + var readerWithContext = new XmlDocReader(path); + var withContext = readerWithContext.GetSummary("T:Foo.Bar", context); + + // Assert: identical rendering with or without a link context + Assert.Equal(withoutContext, withContext); + } + finally + { + File.Delete(path); + } + } + finally + { + assembly.Dispose(); + } + } + + /// + /// Validates that a <seealso cref> reference honors the same link-context + /// resolution as <see cref>. + /// + [Fact] + public void XmlDocReader_GetRemarks_SeeAlsoResolvedEmittedType_RendersLinkedCodeSpan() + { + // Arrange + var (assembly, context) = BuildFixtureLinkContext(); + try + { + var path = WriteXmlDoc(""" + + Also see . + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var remarks = reader.GetRemarks("T:Foo.Bar", context); + + // Assert + Assert.Contains("`[SampleClass](SampleClass.md)`", remarks); + } + finally + { + File.Delete(path); + } + } + finally + { + assembly.Dispose(); + } + } + + #endregion } From cd27f503379e3355013c7534702520fe210f39ae Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 7 Oct 2026 19:59:27 -0400 Subject: [PATCH 05/13] Wire CrefLinkContext through the gradual-disclosure emitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread CrefLinkContext into every call site in DotNetEmitterGradualDisclosure.cs that reads XmlDocReader Get* documentation text (GetSummary/GetSummaryMarkdown/GetRemarks/ GetReturns/GetParams/GetExampleParts), so / references now render as real relative Markdown links when their target is an intra-assembly type or member that will actually be emitted, and keep today's code-span-only fallback otherwise. - DotNetAstModel.cs: add a required CrefLinkContext LinkContext parameter to TypePageWriteContext and MethodDocContext so page writers carry a folder-scoped link context without needing access to the emitter instance. - DotNetEmitterGradualDisclosure.cs: add BuildCrefLinkContext(folder) to construct a CrefLinkContext bound to the model's CrefTargets, Resolver, and MemberPageIndex plus IsTypeEmitted/IsMemberEmitted delegates; wire it through WriteNamespacePage, WriteTypeHeaderSections, WriteNestedTypesSection, ProcessSingleMember, ProcessOverloadGroup, ProcessCollisionMember, WriteMemberPage, WriteMethodOverloadPage, WriteTypeOperatorsPage, WriteCombinedMemberPage, WriteNonMethodMemberContent, and WriteMethodDocumentation. Page writers at a deeper folder than their caller derive a re-scoped context via `linkContext with { CurrentFolder = ... }` rather than needing the emitter instance passed in. - DotNetEmitterSingleFile.cs is intentionally untouched: single-file mode never supplies a CrefLinkContext, so it keeps rendering crefs exactly as it does today (code-span-only, never linked). Tests: - test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs: new fixture exercising every cref resolution outcome — a to a visible in-assembly type, a to a visible in-assembly member, a to a member filtered out by visibility, a to an external framework type, a malformed/unresolvable cref string, and a to a visible type (nested in , since top-level siblings of are not read by this pipeline). - DotNetEmitterGradualDisclosureTests.cs: new end-to-end tests proving each of the above renders correctly in generated gradual-disclosure output, plus a test asserting every DotNetAstModel.MemberPageIndex entry corresponds to an actually-written page (mitigating the BuildMemberPageIndex duplicated-logic scope reduction). - DotNetEmitterSingleFileTests.cs: new regression test proving the same cref still renders as plain code-span text with no link in single-file mode. - DotNetGeneratorTests.cs: updated DotNetGenerator_Generate_InlineReferences_PreserveSymbolNamesInMarkdown expectations, since the SampleStatus/SampleStatus.Active/ SampleStatus.Pending crefs it asserts on are now resolvable intra-assembly symbols and correctly render as real links under the new feature. --- src/ApiMark.DotNet/DotNetAstModel.cs | 13 +- .../DotNetEmitterGradualDisclosure.cs | 104 ++++++++---- .../CrefLinkingClass.cs | 54 ++++++ .../DotNetEmitterGradualDisclosureTests.cs | 158 ++++++++++++++++++ .../DotNetEmitterSingleFileTests.cs | 27 +++ .../DotNetGeneratorTests.cs | 8 +- 6 files changed, 330 insertions(+), 34 deletions(-) create mode 100644 test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs diff --git a/src/ApiMark.DotNet/DotNetAstModel.cs b/src/ApiMark.DotNet/DotNetAstModel.cs index ed9cfeb..52aa56a 100644 --- a/src/ApiMark.DotNet/DotNetAstModel.cs +++ b/src/ApiMark.DotNet/DotNetAstModel.cs @@ -17,13 +17,20 @@ namespace ApiMark.DotNet; /// Type definition whose pages are being generated. /// Documentation index for member-level lookups. /// Type link resolver for table cell generation. +/// +/// Cross-reference resolution context for <see cref>/<seealso cref> +/// linking, scoped to this type's own page folder (). Member +/// pages living in a deeper folder derive their own context from this one via a +/// expression overriding . +/// internal sealed record TypePageWriteContext( IMarkdownWriterFactory Factory, string NamespaceName, string NamespaceFolderPath, TypeDefinition Type, XmlDocReader XmlDocs, - TypeLinkResolver Resolver); + TypeLinkResolver Resolver, + CrefLinkContext LinkContext); /// /// Bundles the per-method documentation writing context passed to @@ -35,12 +42,14 @@ internal sealed record TypePageWriteContext( /// Type link resolver for table cell generation. /// Folder path of the containing Markdown file, relative to the documentation output root. /// Mutable accumulator for external type references found during table cell generation. +/// Cross-reference resolution context for <see cref>/<seealso cref> linking, scoped to . internal sealed record MethodDocContext( string NamespaceName, XmlDocReader XmlDocs, TypeLinkResolver Resolver, string CurrentFolder, - ISet ExternalTypes); + ISet ExternalTypes, + CrefLinkContext LinkContext); /// /// Bundles the namespace-level documentation sourced from a NamespaceDoc carrier diff --git a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs index decc93f..2e73f3a 100644 --- a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs +++ b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs @@ -67,6 +67,37 @@ internal DotNetEmitterGradualDisclosure(DotNetEmitter emitter, DotNetAstModel mo _model = model; } + /// + /// Builds a scoped to , + /// binding the assembly-wide cref-resolution pieces (, + /// , ) and + /// the "will this symbol actually be emitted" delegates to the current page's folder. + /// + /// + /// A type is considered emitted when its XML-doc type identifier is present in + /// ; a member is considered emitted when its + /// XML-doc member identifier is present in — + /// both sets are populated once, up front, by DotNetGenerator.Parse using the exact + /// same visibility rules this emitter itself uses when actually writing pages, so no + /// visibility logic is duplicated here. + /// + /// Callers writing a page in a different folder than the one most recently built (e.g. a + /// member page living one level below its type's own page) derive a new context via + /// with { CurrentFolder = ... } instead of calling this method again, since the + /// other fields never change within a single emit run. + /// + /// + /// Folder path of the Markdown file about to be rendered. + /// A scoped to . + private CrefLinkContext BuildCrefLinkContext(string currentFolder) => + new( + _model.CrefTargets, + _model.Resolver, + _model.MemberPageIndex, + type => _model.EmittedTypeIds.Contains(BuildTypeId(type)), + member => _model.MemberPageIndex.ContainsKey(BuildMemberId(member)), + currentFolder); + /// Dispatches to . /// Factory for creating per-file Markdown writers. /// Output configuration (unused for gradual-disclosure format). @@ -278,10 +309,11 @@ private void WriteNamespacePage( } var typeHeaders = new[] { "Type", DotNetEmitter.DescriptionColumnHeader }; + var namespaceLinkContext = BuildCrefLinkContext(subFolder); var typeRows = nsTypes.Select(t => { var typeMemberId = BuildTypeId(t); - var summary = ctx.XmlDocs.GetSummary(typeMemberId) ?? DotNetEmitter.NoDescriptionPlaceholder; + var summary = ctx.XmlDocs.GetSummary(typeMemberId, namespaceLinkContext) ?? DotNetEmitter.NoDescriptionPlaceholder; var typeDisplayName = StripArity(t.Name); var link = $"{shortName}/{FlattenArity(t.Name)}.md"; return new[] { $"[{typeDisplayName}]({link})", summary }; @@ -290,7 +322,7 @@ private void WriteNamespacePage( foreach (var type in nsTypes) { - var typeCtx = new TypePageWriteContext(factory, namespaceName, folderPath, type, ctx.XmlDocs, ctx.Resolver); + var typeCtx = new TypePageWriteContext(factory, namespaceName, folderPath, type, ctx.XmlDocs, ctx.Resolver, BuildCrefLinkContext(folderPath)); WriteTypePage(typeCtx); } } @@ -365,8 +397,8 @@ private static void WriteTypeHeaderSections(IMarkdownWriter typeWriter, TypePage // Fetch remarks first so the placeholder decision below can see whether remarks // content will be shown even when summary is absent (see WriteTypeHeaderSections remarks). - var typeSummary = ctx.XmlDocs.GetSummaryMarkdown(typeMemberId); - var typeRemarks = ctx.XmlDocs.GetRemarks(typeMemberId); + var typeSummary = ctx.XmlDocs.GetSummaryMarkdown(typeMemberId, ctx.LinkContext); + var typeRemarks = ctx.XmlDocs.GetRemarks(typeMemberId, ctx.LinkContext); // Always emit a summary paragraph — use the placeholder only when BOTH summary and // remarks are absent; a remarks-only member already has visible description content @@ -386,7 +418,7 @@ private static void WriteTypeHeaderSections(IMarkdownWriter typeWriter, TypePage } // Emit structured example blocks for this type - WriteExampleParts(typeWriter, ctx.XmlDocs.GetExampleParts(typeMemberId)); + WriteExampleParts(typeWriter, ctx.XmlDocs.GetExampleParts(typeMemberId, ctx.LinkContext)); } /// @@ -532,7 +564,7 @@ private static void WriteMemberRowSections(IMarkdownWriter typeWriter, MemberRow /// The type's operator overload methods; must be non-empty. private static void WriteTypeOperatorsSection(IMarkdownWriter typeWriter, TypePageWriteContext ctx, List operatorMethods) { - WriteTypeOperatorsPage(ctx.Factory, ctx.NamespaceName, ctx.NamespaceFolderPath, ctx.Type, operatorMethods, ctx.XmlDocs, ctx.Resolver); + WriteTypeOperatorsPage(ctx.Factory, ctx.NamespaceName, ctx.NamespaceFolderPath, ctx.Type, operatorMethods, ctx.XmlDocs, ctx.Resolver, ctx.LinkContext); typeWriter.WriteHeading(2, "Operators"); typeWriter.WriteTable( new[] { MemberColumnHeader, DotNetEmitter.DescriptionColumnHeader }, @@ -558,7 +590,7 @@ private void WriteNestedTypesSection(IMarkdownWriter typeWriter, TypePageWriteCo var nestedTypeRows = visibleNestedTypes.Select(nested => { var nestedTypeId = BuildTypeId(nested); - var nestedSummary = ctx.XmlDocs.GetSummary(nestedTypeId) ?? DotNetEmitter.NoDescriptionPlaceholder; + var nestedSummary = ctx.XmlDocs.GetSummary(nestedTypeId, ctx.LinkContext) ?? DotNetEmitter.NoDescriptionPlaceholder; var nestedDisplayName = StripArity(nested.Name); var nestedLink = $"{FlattenArity(ctx.Type.Name)}/{FlattenArity(nested.Name)}.md"; return new[] { $"[{nestedDisplayName}]({nestedLink})", nestedSummary }; @@ -570,7 +602,7 @@ private void WriteNestedTypesSection(IMarkdownWriter typeWriter, TypePageWriteCo var nestedFolderPath = $"{ctx.NamespaceFolderPath}/{FlattenArity(ctx.Type.Name)}"; foreach (var nested in visibleNestedTypes) { - WriteTypePage(new TypePageWriteContext(ctx.Factory, ctx.NamespaceName, nestedFolderPath, nested, ctx.XmlDocs, ctx.Resolver)); + WriteTypePage(new TypePageWriteContext(ctx.Factory, ctx.NamespaceName, nestedFolderPath, nested, ctx.XmlDocs, ctx.Resolver, BuildCrefLinkContext(nestedFolderPath))); } } @@ -598,6 +630,7 @@ private static void WriteMemberPage( { var sanitizedName = GetSanitizedMemberFileName(member, ctx.Type); var memberCurrentFolder = $"{ctx.NamespaceFolderPath}/{FlattenArity(ctx.Type.Name)}"; + var memberLinkContext = ctx.LinkContext with { CurrentFolder = memberCurrentFolder }; using var memberWriter = ctx.Factory.CreateMarkdown(memberCurrentFolder, sanitizedName); var displayName = GetMemberDisplayName(member); @@ -607,12 +640,12 @@ private static void WriteMemberPage( { // Method pages use the resolver for parameter type cells var externalTypes = new SortedSet(); - WriteMethodDocumentation(memberWriter, method, memberId, new MethodDocContext(ctx.NamespaceName, ctx.XmlDocs, ctx.Resolver, memberCurrentFolder, externalTypes)); + WriteMethodDocumentation(memberWriter, method, memberId, new MethodDocContext(ctx.NamespaceName, ctx.XmlDocs, ctx.Resolver, memberCurrentFolder, externalTypes, memberLinkContext)); WriteExternalTypesSection(memberWriter, externalTypes); return; } - WriteNonMethodMemberContent(memberWriter, member, memberId, new MethodDocContext(ctx.NamespaceName, ctx.XmlDocs, ctx.Resolver, memberCurrentFolder, new SortedSet())); + WriteNonMethodMemberContent(memberWriter, member, memberId, new MethodDocContext(ctx.NamespaceName, ctx.XmlDocs, ctx.Resolver, memberCurrentFolder, new SortedSet(), memberLinkContext)); } /// @@ -631,6 +664,10 @@ private static void WriteMemberPage( /// Ordered list of overload methods (at least one element). /// Documentation index for per-overload member-ID lookups. /// Type link resolver for table cell link generation. + /// + /// Cross-reference resolution context scoped to the containing type's own page folder; + /// re-scoped internally to this overload page's folder via a expression. + /// private static void WriteMethodOverloadPage( IMarkdownWriterFactory factory, string namespaceName, @@ -638,10 +675,12 @@ private static void WriteMethodOverloadPage( TypeDefinition type, List overloads, XmlDocReader xmlDocs, - TypeLinkResolver resolver) + TypeLinkResolver resolver, + CrefLinkContext linkContext) { var sanitizedName = BuildMethodFileName(overloads[0], type); var overloadCurrentFolder = $"{namespaceFolderPath}/{FlattenArity(type.Name)}"; + var overloadLinkContext = linkContext with { CurrentFolder = overloadCurrentFolder }; using var memberWriter = factory.CreateMarkdown(overloadCurrentFolder, sanitizedName); memberWriter.WriteHeading(1, GetMethodGroupName(overloads[0])); @@ -651,7 +690,7 @@ private static void WriteMethodOverloadPage( foreach (var overload in overloads) { memberWriter.WriteHeading(2, BuildMethodDisplayName(overload)); - WriteMethodDocumentation(memberWriter, overload, BuildMemberId(overload), new MethodDocContext(namespaceName, xmlDocs, resolver, overloadCurrentFolder, externalTypes)); + WriteMethodDocumentation(memberWriter, overload, BuildMemberId(overload), new MethodDocContext(namespaceName, xmlDocs, resolver, overloadCurrentFolder, externalTypes, overloadLinkContext)); } WriteExternalTypesSection(memberWriter, externalTypes); @@ -680,6 +719,10 @@ private static void WriteMethodOverloadPage( /// /// XML documentation index for summary and detail lookups. /// Type link resolver used to linkify parameter type cells. + /// + /// Cross-reference resolution context scoped to the containing type's own page folder; + /// re-scoped internally to the operators page's folder via a expression. + /// private static void WriteTypeOperatorsPage( IMarkdownWriterFactory factory, string namespaceName, @@ -687,9 +730,11 @@ private static void WriteTypeOperatorsPage( TypeDefinition type, IReadOnlyList operators, XmlDocReader xmlDocs, - TypeLinkResolver resolver) + TypeLinkResolver resolver, + CrefLinkContext linkContext) { var operatorsCurrentFolder = $"{namespaceFolderPath}/{FlattenArity(type.Name)}"; + var operatorsLinkContext = linkContext with { CurrentFolder = operatorsCurrentFolder }; using var writer = factory.CreateMarkdown(operatorsCurrentFolder, "operators"); writer.WriteHeading(1, "Operators"); @@ -697,7 +742,7 @@ private static void WriteTypeOperatorsPage( foreach (var op in operators) { writer.WriteHeading(2, BuildMethodDisplayName(op)); - WriteMethodDocumentation(writer, op, BuildMemberId(op), new MethodDocContext(namespaceName, xmlDocs, resolver, operatorsCurrentFolder, externalTypes)); + WriteMethodDocumentation(writer, op, BuildMemberId(op), new MethodDocContext(namespaceName, xmlDocs, resolver, operatorsCurrentFolder, externalTypes, operatorsLinkContext)); } WriteExternalTypesSection(writer, externalTypes); @@ -729,6 +774,7 @@ private static void WriteCombinedMemberPage( IReadOnlyList members) { var combinedCurrentFolder = $"{ctx.NamespaceFolderPath}/{FlattenArity(ctx.Type.Name)}"; + var combinedLinkContext = ctx.LinkContext with { CurrentFolder = combinedCurrentFolder }; using var writer = ctx.Factory.CreateMarkdown(combinedCurrentFolder, lowerKey); // The shared lowercase key serves as the page heading so every member in the group @@ -749,11 +795,11 @@ private static void WriteCombinedMemberPage( { // Reuse the method documentation writer so formatting is consistent // with single-method pages and overload pages - WriteMethodDocumentation(writer, method, memberId, new MethodDocContext(ctx.NamespaceName, ctx.XmlDocs, ctx.Resolver, combinedCurrentFolder, externalTypes)); + WriteMethodDocumentation(writer, method, memberId, new MethodDocContext(ctx.NamespaceName, ctx.XmlDocs, ctx.Resolver, combinedCurrentFolder, externalTypes, combinedLinkContext)); } else { - WriteNonMethodMemberContent(writer, member, memberId, new MethodDocContext(ctx.NamespaceName, ctx.XmlDocs, ctx.Resolver, combinedCurrentFolder, externalTypes)); + WriteNonMethodMemberContent(writer, member, memberId, new MethodDocContext(ctx.NamespaceName, ctx.XmlDocs, ctx.Resolver, combinedCurrentFolder, externalTypes, combinedLinkContext)); } } @@ -977,7 +1023,7 @@ private static void ProcessSingleMember( MemberRowBuckets buckets) { var memberId = BuildMemberId(member); - var memberSummary = ctx.XmlDocs.GetSummary(memberId) ?? DotNetEmitter.NoDescriptionPlaceholder; + var memberSummary = ctx.XmlDocs.GetSummary(memberId, ctx.LinkContext) ?? DotNetEmitter.NoDescriptionPlaceholder; var memberTypeRef = GetMemberTypeRef(member); var memberTypeName = memberTypeRef != null ? ctx.Resolver.Linkify(memberTypeRef, ctx.NamespaceFolderPath, ctx.NamespaceName, buckets.ExternalTypes, IsMemberTypeNullableAnnotated(member)) @@ -1050,7 +1096,7 @@ private static void ProcessOverloadGroup( var representative = orderedOverloads[0]; var representativeMemberId = BuildMemberId(representative); - var representativeSummary = ctx.XmlDocs.GetSummary(representativeMemberId) ?? DotNetEmitter.NoDescriptionPlaceholder; + var representativeSummary = ctx.XmlDocs.GetSummary(representativeMemberId, ctx.LinkContext) ?? DotNetEmitter.NoDescriptionPlaceholder; var representativeTypeRef = GetMemberTypeRef(representative); var representativeTypeName = representativeTypeRef != null ? ctx.Resolver.Linkify(representativeTypeRef, ctx.NamespaceFolderPath, ctx.NamespaceName, externalTypes, IsMemberTypeNullableAnnotated(representative)) @@ -1060,7 +1106,7 @@ private static void ProcessOverloadGroup( var memberLink = $"{FlattenArity(ctx.Type.Name)}/{overloadFileName}.md"; var isConstructorGroup = representative.Name == DotNetEmitter.ConstructorMethodName; - WriteMethodOverloadPage(ctx.Factory, ctx.NamespaceName, ctx.NamespaceFolderPath, ctx.Type, orderedOverloads, ctx.XmlDocs, ctx.Resolver); + WriteMethodOverloadPage(ctx.Factory, ctx.NamespaceName, ctx.NamespaceFolderPath, ctx.Type, orderedOverloads, ctx.XmlDocs, ctx.Resolver, ctx.LinkContext); if (isConstructorGroup) { @@ -1106,7 +1152,7 @@ private static void ProcessCollisionMember( // 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 = ctx.XmlDocs.GetSummary(memberId) ?? DotNetEmitter.NoDescriptionPlaceholder; + var memberSummary = ctx.XmlDocs.GetSummary(memberId, ctx.LinkContext) ?? DotNetEmitter.NoDescriptionPlaceholder; var memberTypeRef = GetMemberTypeRef(member); var memberTypeName = memberTypeRef != null ? ctx.Resolver.Linkify(memberTypeRef, ctx.NamespaceFolderPath, ctx.NamespaceName, buckets.ExternalTypes, IsMemberTypeNullableAnnotated(member)) @@ -1163,8 +1209,8 @@ private static void WriteNonMethodMemberContent( // Fetch remarks first so the placeholder decision below can account for it // (see the remarks on WriteTypeHeaderSections for the placeholder-suppression rationale). - var summary = ctx.XmlDocs.GetSummaryMarkdown(memberId); - var remarks = ctx.XmlDocs.GetRemarks(memberId); + var summary = ctx.XmlDocs.GetSummaryMarkdown(memberId, ctx.LinkContext); + var remarks = ctx.XmlDocs.GetRemarks(memberId, ctx.LinkContext); if (!string.IsNullOrEmpty(summary)) { writer.WriteParagraph(summary); @@ -1174,7 +1220,7 @@ private static void WriteNonMethodMemberContent( writer.WriteParagraph(DotNetEmitter.NoDescriptionPlaceholder); } - var returns = ctx.XmlDocs.GetReturns(memberId); + var returns = ctx.XmlDocs.GetReturns(memberId, ctx.LinkContext); if (!string.IsNullOrEmpty(returns)) { writer.WriteParagraph($"**Returns:** {returns}"); @@ -1193,7 +1239,7 @@ private static void WriteNonMethodMemberContent( writer.WriteParagraph(remarks); } - WriteExampleParts(writer, ctx.XmlDocs.GetExampleParts(memberId)); + WriteExampleParts(writer, ctx.XmlDocs.GetExampleParts(memberId, ctx.LinkContext)); } /// @@ -1221,8 +1267,8 @@ private static void WriteMethodDocumentation( // Fetch remarks first so the placeholder decision below can account for it // (see the remarks on WriteTypeHeaderSections for the placeholder-suppression rationale). - var summary = ctx.XmlDocs.GetSummaryMarkdown(memberId); - var remarks = ctx.XmlDocs.GetRemarks(memberId); + var summary = ctx.XmlDocs.GetSummaryMarkdown(memberId, ctx.LinkContext); + var remarks = ctx.XmlDocs.GetRemarks(memberId, ctx.LinkContext); if (!string.IsNullOrEmpty(summary)) { memberWriter.WriteParagraph(summary); @@ -1234,7 +1280,7 @@ private static void WriteMethodDocumentation( if (method.HasParameters) { - var paramDocs = ctx.XmlDocs.GetParams(memberId); + var paramDocs = ctx.XmlDocs.GetParams(memberId, ctx.LinkContext); var paramHeaders = new[] { "Parameter", "Type", DotNetEmitter.DescriptionColumnHeader }; // Linkify parameter types — resolver tracks external types and emits links for intra-assembly types @@ -1247,7 +1293,7 @@ private static void WriteMethodDocumentation( memberWriter.WriteTable(paramHeaders, paramRows); } - var returns = ctx.XmlDocs.GetReturns(memberId); + var returns = ctx.XmlDocs.GetReturns(memberId, ctx.LinkContext); if (!string.IsNullOrEmpty(returns)) { memberWriter.WriteParagraph($"**Returns:** {returns}"); @@ -1266,7 +1312,7 @@ private static void WriteMethodDocumentation( memberWriter.WriteParagraph(remarks); } - WriteExampleParts(memberWriter, ctx.XmlDocs.GetExampleParts(memberId)); + WriteExampleParts(memberWriter, ctx.XmlDocs.GetExampleParts(memberId, ctx.LinkContext)); } /// diff --git a/test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs b/test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs new file mode 100644 index 0000000..c19f324 --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs @@ -0,0 +1,54 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +namespace ApiMark.DotNet.Fixtures; + +/// +/// A fixture class exercising every <see cref>/<seealso cref> +/// resolution outcome exercised by the cross-reference linking feature: a real intra-assembly +/// type link, a real intra-assembly member link, a fallback for a member filtered out by +/// visibility, a fallback for an external framework type, and a fallback for a malformed +/// cref string. +/// +/// See also . +public class CrefLinkingClass +{ + /// + /// References another in-assembly, publicly visible type: + /// . + /// + public void ReferencesVisibleType() + { + } + + /// + /// References another in-assembly, publicly visible member: + /// . + /// + public void ReferencesVisibleMember() + { + } + + /// + /// References a member filtered out of the generated documentation by the active + /// visibility setting: . + /// + public void ReferencesFilteredMember() + { + } + + /// + /// References an external framework type: . + /// + public void ReferencesExternalType() + { + } + + /// + /// References a malformed, unresolvable cref string: + /// . + /// + public void ReferencesMalformedCref() + { + } +} diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs index 573afa5..9b2832d 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs @@ -19,6 +19,32 @@ public class DotNetEmitterGradualDisclosureTests Visibility = ApiVisibility.Public, }; + /// + /// Validates that every page path computed by BuildMemberPageIndex (surfaced on + /// DotNetAstModel.MemberPageIndex) corresponds to an actual page written by the + /// gradual-disclosure emitter — mitigating the risk that the index's independently + /// reimplemented grouping logic silently drifts from the real page-writing logic. + /// + [Fact] + public void BuildMemberPageIndex_AllEntries_MatchActualWrittenPages() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: every computed member page path is an actual page the emitter wrote + Assert.NotEmpty(emitter.Model.MemberPageIndex); + foreach (var (memberId, pagePath) in emitter.Model.MemberPageIndex) + { + Assert.True( + factory.Writers.ContainsKey(pagePath), + $"MemberPageIndex entry for '{memberId}' points at '{pagePath}', but no such page was written."); + } + } + /// Validates that the gradual-disclosure emitter creates the api index page. [Fact] public void DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesApiIndexPage() @@ -497,4 +523,136 @@ public void DotNetEmitterGradualDisclosure_Emit_TypeWithRemarksOnly_SuppressesPl File.Delete(docPath); } } + + /// + /// Validates end-to-end that a <see cref> to another in-assembly, visible + /// type renders as a real relative Markdown link (still code-span-wrapped), not the + /// plain code-span fallback. + /// + [Fact] + public void CrefLinking_SeeCrefToVisibleType_RendersAsMarkdownLink() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: the method summary links directly to the SampleClass type page + var memberWriter = factory.Writers["ApiMark.DotNet.Fixtures/CrefLinkingClass/ReferencesVisibleType"]; + var paragraphs = memberWriter.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains( + paragraphs, + p => p.Contains("`[SampleClass](../SampleClass.md)`", StringComparison.Ordinal)); + } + + /// + /// Validates end-to-end that a <see cref> to another in-assembly, visible + /// member renders as a real relative Markdown link to that member's own page. + /// + [Fact] + public void CrefLinking_SeeCrefToVisibleMember_RendersAsMarkdownLink() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: the method summary links directly to the SampleClass.Reset member page + var memberWriter = factory.Writers["ApiMark.DotNet.Fixtures/CrefLinkingClass/ReferencesVisibleMember"]; + var paragraphs = memberWriter.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains( + paragraphs, + p => p.Contains("`[SampleClass.Reset](../SampleClass/Reset.md)`", StringComparison.Ordinal)); + } + + /// + /// Validates end-to-end that a <see cref> to a member filtered out of the + /// generated documentation by the active visibility setting keeps today's plain + /// code-span fallback — no link is generated for symbols that won't be emitted. + /// + [Fact] + public void CrefLinking_SeeCrefToFilteredMember_RendersFallbackWithNoLink() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: the private method is not emitted under Public visibility, so the cref falls + // back to plain code-span text with no Markdown link + var memberWriter = factory.Writers["ApiMark.DotNet.Fixtures/CrefLinkingClass/ReferencesFilteredMember"]; + var paragraphs = memberWriter.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains(paragraphs, p => p.Contains("`ProtectedMembersClass.PrivateMethod()`", StringComparison.Ordinal)); + Assert.DoesNotContain(paragraphs, p => p.Contains("](", StringComparison.Ordinal)); + } + + /// + /// Validates end-to-end that a <see cref> to an external framework type + /// keeps today's plain code-span fallback — external crefs are never linked. + /// + [Fact] + public void CrefLinking_SeeCrefToExternalType_RendersFallbackWithNoLink() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert + var memberWriter = factory.Writers["ApiMark.DotNet.Fixtures/CrefLinkingClass/ReferencesExternalType"]; + var paragraphs = memberWriter.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains(paragraphs, p => p.Contains("`ArgumentNullException`", StringComparison.Ordinal)); + Assert.DoesNotContain(paragraphs, p => p.Contains("](", StringComparison.Ordinal)); + } + + /// + /// Validates end-to-end that a malformed, unresolvable <see cref> string + /// keeps today's fallback rendering — no link is ever generated for it. + /// + [Fact] + public void CrefLinking_SeeCrefMalformed_RendersFallbackWithNoLink() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert + var memberWriter = factory.Writers["ApiMark.DotNet.Fixtures/CrefLinkingClass/ReferencesMalformedCref"]; + var paragraphs = memberWriter.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains(paragraphs, p => p.Contains("NotAWellFormedCrefString", StringComparison.Ordinal)); + Assert.DoesNotContain(paragraphs, p => p.Contains("](", StringComparison.Ordinal)); + } + + /// + /// Validates end-to-end that a <seealso cref> to another + /// in-assembly, visible type also renders as a real relative Markdown link, proving + /// both <see> and <seealso> share the same linking path. + /// + [Fact] + public void CrefLinking_SeeAlsoCrefToVisibleType_RendersAsMarkdownLink() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: the type-level See also . + // renders as a real Markdown link on the CrefLinkingClass type page + var typeWriter = factory.Writers["ApiMark.DotNet.Fixtures/CrefLinkingClass"]; + var paragraphs = typeWriter.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains(paragraphs, p => p.Contains("`[SampleClass](SampleClass.md)`", StringComparison.Ordinal)); + } } diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs index 2775d98..5646e37 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs @@ -618,4 +618,31 @@ public void DotNetEmitterSingleFile_Emit_MemberWithRemarksOnly_SuppressesPlaceho File.Delete(docPath); } } + + /// + /// Validates that single-file mode's rendering of <see cref> references is + /// completely unaffected by the cross-reference linking feature: crefs that would render + /// as real Markdown links in gradual-disclosure mode must still render as plain + /// code-span-only text in single-file mode, because single-file mode never supplies a + /// CrefLinkContext to the XmlDocReader. + /// + [Fact] + public void CrefLinking_SingleFileMode_SeeCrefToVisibleType_RemainsCodeSpanOnly() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterSingleFile(emitter, emitter.Model).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: the same cref that gradual-disclosure mode links to "../SampleClass.md" renders + // here as plain code-span text with no Markdown link syntax + var writer = factory.GetWriter("", "api"); + var paragraphs = writer.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains(paragraphs, p => p.Contains("`SampleClass`", StringComparison.Ordinal) && + p.Contains("References another in-assembly, publicly visible type", StringComparison.Ordinal)); + Assert.DoesNotContain(paragraphs, p => p.Contains("References another in-assembly, publicly visible type", StringComparison.Ordinal) && + p.Contains("](", StringComparison.Ordinal)); + } } diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index b1b6a01..646dc19 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -546,10 +546,12 @@ public void DotNetGenerator_Generate_InlineReferences_PreserveSymbolNamesInMarkd // Act generator.Parse(new InMemoryContext()).Emit(factory, new EmitConfig(), new InMemoryContext()); - // Assert + // Assert: SampleStatus is an intra-assembly enum emitted in gradual-disclosure + // mode, so its cref now renders as a real relative Markdown link (still + // code-span-wrapped), per the cref-cross-reference-linking feature. var typeWriter = factory.Writers["ApiMark.DotNet.Fixtures/SampleStatusExtensions"]; var typeParagraphs = typeWriter.Operations.OfType().Select(p => p.Text).ToList(); - Assert.Contains("Extensions for the `SampleStatus` enum.", typeParagraphs); + Assert.Contains("Extensions for the `[SampleStatus](SampleStatus.md)` enum.", typeParagraphs); var memberWriters = factory.Writers .Where(kvp => kvp.Key.StartsWith("ApiMark.DotNet.Fixtures/SampleStatusExtensions/IsPassed", StringComparison.Ordinal)) @@ -558,7 +560,7 @@ public void DotNetGenerator_Generate_InlineReferences_PreserveSymbolNamesInMarkd Assert.Contains( memberWriters, writer => writer.Operations.OfType() - .Any(p => p.Text == "Returns true when status is `SampleStatus.Active` or `SampleStatus.Pending`.")); + .Any(p => p.Text == "Returns true when status is `[SampleStatus.Active](../SampleStatus/Active.md)` or `[SampleStatus.Pending](../SampleStatus/Pending.md)`.")); } /// Validates that static types render a static class signature. From 79da70ea5f3edb24428d53cad7e78a440474e95c Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 7 Oct 2026 20:01:55 -0400 Subject: [PATCH 06/13] Add DotNetAstModel tests for CrefTargets and MemberPageIndex Cover the new DotNetAstModel.CrefTargets, MemberPageIndex, and EmittedTypeIds members added for cross-reference linking: - CrefTargets is constructed eagerly during DotNetGenerator.Parse and can resolve a known fixture type by its XML-doc cref ID. - MemberPageIndex and EmittedTypeIds are populated by the gradual-disclosure emitter's post-construction setup step and contain entries for known visible fixture symbols. --- .../DotNetAstModelTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/ApiMark.DotNet.Tests/DotNetAstModelTests.cs b/test/ApiMark.DotNet.Tests/DotNetAstModelTests.cs index 0b5ccd5..26f10e8 100644 --- a/test/ApiMark.DotNet.Tests/DotNetAstModelTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetAstModelTests.cs @@ -1,6 +1,7 @@ // Copyright (c) DemaConsulting LLC. All rights reserved. // Licensed under the MIT License. +using ApiMark.Core; using ApiMark.Core.TestHelpers; using ApiMark.DotNet; using Xunit; @@ -111,6 +112,48 @@ public void DotNetAstModel_NamespaceDescriptions_AfterParse_IsNotNull() Assert.NotNull(emitter.Model.NamespaceDescriptions); } + /// + /// Validates that is constructed eagerly during + /// and can resolve a known fixture type. + /// + [Fact] + public void DotNetAstModel_CrefTargets_AfterParse_ResolvesKnownFixtureType() + { + // Arrange + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + var resolved = emitter.Model.CrefTargets.TryResolveType("T:ApiMark.DotNet.Fixtures.SampleClass", out var type); + + // Assert + Assert.True(resolved); + Assert.Equal("SampleClass", type.Name); + } + + /// + /// Validates that and + /// are populated (via + /// DotNetGenerator.Parse's post-construction setup step) once a + /// has processed the model, and that the + /// index contains an entry for a known visible fixture member. + /// + [Fact] + public void DotNetAstModel_MemberPageIndexAndEmittedTypeIds_AfterGradualDisclosureEmit_ArePopulated() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert + Assert.NotEmpty(emitter.Model.MemberPageIndex); + Assert.NotEmpty(emitter.Model.EmittedTypeIds); + Assert.Contains("M:ApiMark.DotNet.Fixtures.SampleClass.Reset", emitter.Model.MemberPageIndex.Keys); + Assert.Contains("T:ApiMark.DotNet.Fixtures.SampleClass", emitter.Model.EmittedTypeIds); + } + /// /// Validates that the collection-type properties of satisfy the /// read-only interface constraints at runtime — confirming the compiler's static guarantees From 662a51a4eabcc97fb7116c3d350e881aa1c67c31 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 7 Oct 2026 20:21:22 -0400 Subject: [PATCH 07/13] Add companion documentation for cref cross-reference linking Update design, verification, and reqstream docs for DotNetAstModel, DotNetGenerator, DotNetEmitterGradualDisclosure, DotNetEmitterSingleFile, TypeLinkResolver, and XmlDocReader to cover the new cref cross-reference linking feature. Add a new CrefTargetResolver unit's design, verification, and reqstream docs. Register the new unit and requirement IDs in docs/reqstream/api-mark-dot-net.yaml and requirements.yaml. Add a new ApiMark-DotNet-CrefTargetResolver entry to .reviewmark.yaml and extend the existing XmlDocReader entry to cover the new CrefLinkContext.cs file. Update the subsystem-level docs/design/api-mark-dot-net.md with the new unit, its dependency edges, and a cref-linking data-flow step. --- .reviewmark.yaml | 12 ++ docs/design/api-mark-dot-net.md | 43 ++++++- .../api-mark-dot-net/cref-target-resolver.md | 118 ++++++++++++++++++ .../api-mark-dot-net/dot-net-ast-model.md | 33 +++++ .../dot-net-emitter-gradual-disclosure.md | 80 +++++++++++- .../api-mark-dot-net/dot-net-generator.md | 54 ++++++++ .../api-mark-dot-net/type-link-resolver.md | 46 +++++++ .../design/api-mark-dot-net/xml-doc-reader.md | 88 ++++++++++++- docs/reqstream/api-mark-dot-net.yaml | 10 ++ .../cref-target-resolver.yaml | 56 +++++++++ .../api-mark-dot-net/dot-net-ast-model.yaml | 18 +++ .../dot-net-emitter-gradual-disclosure.yaml | 44 +++++++ .../dot-net-emitter-single-file.yaml | 18 +++ .../api-mark-dot-net/dot-net-generator.yaml | 26 ++++ .../api-mark-dot-net/type-link-resolver.yaml | 25 ++++ .../api-mark-dot-net/xml-doc-reader.yaml | 37 ++++++ .../api-mark-dot-net/cref-target-resolver.md | 87 +++++++++++++ .../api-mark-dot-net/dot-net-ast-model.md | 19 +++ .../dot-net-emitter-gradual-disclosure.md | 57 +++++++++ .../dot-net-emitter-single-file.md | 11 ++ .../api-mark-dot-net/dot-net-generator.md | 23 ++++ .../api-mark-dot-net/type-link-resolver.md | 36 ++++++ .../api-mark-dot-net/xml-doc-reader.md | 61 +++++++++ requirements.yaml | 1 + 24 files changed, 995 insertions(+), 8 deletions(-) create mode 100644 docs/design/api-mark-dot-net/cref-target-resolver.md create mode 100644 docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml create mode 100644 docs/verification/api-mark-dot-net/cref-target-resolver.md diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 90227c6..294929b 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -302,6 +302,7 @@ reviews: - docs/design/api-mark-dot-net/xml-doc-reader.md - docs/verification/api-mark-dot-net/xml-doc-reader.md - src/ApiMark.DotNet/XmlDocReader.cs + - src/ApiMark.DotNet/CrefLinkContext.cs - test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs - id: ApiMark-DotNet-ExternalXmlDocResolver title: Review that ApiMark DotNet ExternalXmlDocResolver Implementation is Correct @@ -337,6 +338,17 @@ reviews: - docs/verification/api-mark-dot-net/type-link-resolver.md - src/ApiMark.DotNet/TypeLinkResolver.cs - test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs + - id: ApiMark-DotNet-CrefTargetResolver + title: Review that ApiMark DotNet CrefTargetResolver Implementation is Correct + context: + - docs/design/api-mark-dot-net.md + - docs/reqstream/api-mark-dot-net.yaml + paths: + - docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml + - docs/design/api-mark-dot-net/cref-target-resolver.md + - docs/verification/api-mark-dot-net/cref-target-resolver.md + - src/ApiMark.DotNet/CrefTargetResolver.cs + - test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs - id: ApiMark-DotNet-TypeNameSimplifier title: Review that ApiMark DotNet TypeNameSimplifier Implementation is Correct context: diff --git a/docs/design/api-mark-dot-net.md b/docs/design/api-mark-dot-net.md index d7b8db1..819693a 100644 --- a/docs/design/api-mark-dot-net.md +++ b/docs/design/api-mark-dot-net.md @@ -9,7 +9,7 @@ ApiMarkDotNet provides C#/.NET language support. It reads a compiled .NET assembly and its associated XML documentation file, then produces the Markdown output -defined by the Core interfaces. The system contains ten units: +defined by the Core interfaces. The system contains eleven units: - **DotNetGenerator** — reads the assembly via Mono.Cecil, processes XML doc comments, applies visibility filtering, builds an inheritance chain map from @@ -29,6 +29,11 @@ defined by the Core interfaces. The system contains ten units: `enforceTier` is null or empty. - **DotNetAstModel** — immutable data class holding all parsed assembly data (namespaces, types, XML docs, resolver, options) produced by DotNetGenerator.Parse. + Also holds the cref cross-reference linking indices: `CrefTargets` (a + `CrefTargetResolver`, built eagerly in the constructor since it needs only the + assembly) and `MemberPageIndex`/`EmittedTypeIds` (populated via an internal + post-construction call from `DotNetGenerator.Parse`, after `DotNetEmitter` exists, + since they require the emitter's own visibility rules). - **DotNetEmitter** — IApiEmitter dispatcher; reads EmitConfig.Format and forwards the call to DotNetEmitterGradualDisclosure or DotNetEmitterSingleFile. Also provides shared static helper methods used by both sub-emitters, including @@ -44,12 +49,25 @@ defined by the Core interfaces. The system contains ten units: the containing type's folder. - **DotNetEmitterSingleFile** — writes all documentation into a single api.md file. - **TypeLinkResolver** — resolves Mono.Cecil TypeReference instances to Markdown link - text for use in table cells. + text for use in table cells; also resolves already-resolved ``/`` targets (a `TypeDefinition` or `IMemberDefinition`) to Markdown links via + `LinkifyResolvedType`/`LinkifyResolvedMember`, for full cref cross-reference linking. +- **CrefTargetResolver** — indexes every type and member declared in a single + assembly, keyed by XML-doc identifier, so a raw `cref` attribute string can be + resolved to the Mono.Cecil symbol it names. Applies no visibility filtering of + its own; answers only whether a symbol with that identifier exists in the + assembly, letting `XmlDocReader` distinguish "external/malformed cref" from + "filtered out by visibility" when deciding whether to render a real link. - **TypeNameSimplifier** — applies a deterministic set of simplification rules to Mono.Cecil type references to produce idiomatic C# type names in output. - **XmlDocReader** — reads and indexes a .NET XML documentation file for fast member-level lookups; resolves `` references using the inheritance - chain map supplied by DotNetGenerator. + chain map supplied by DotNetGenerator. Every public rendering method also accepts + an optional, trailing `CrefLinkContext?` parameter (default `null`) bundling + `CrefTargetResolver`, `TypeLinkResolver`, the member-page index, and + visibility-emitted delegates; when supplied and a ``/`` + target resolves to a symbol that will actually be emitted, the reference renders + as a real relative Markdown link instead of its default code-span-only fallback. - **ExternalXmlDocResolver** — locates and lazily parses the XML documentation files of externally referenced assemblies (e.g. NuGet package dependencies), caching both per-file results and per-(declaring-assembly-hint, member-ID) @@ -70,6 +88,9 @@ flowchart TD DotNetGenerator --> XmlDocReader DotNetGenerator --> ExternalXmlDocResolver XmlDocReader --> ExternalXmlDocResolver + XmlDocReader --> CrefTargetResolver + XmlDocReader --> TypeLinkResolver + DotNetAstModel --> CrefTargetResolver DotNetGenerator --> MonoCecil["Mono.Cecil (OTS)"] DotNetGenerator --> DocumentationCoverageChecker DocumentationCoverageChecker --> XmlDocReader @@ -234,6 +255,22 @@ N/A - not a safety-classified software item. falls back to scanning every remaining configured reference path in order, so a miss is not guaranteed to avoid the full scan. +11. `DotNetAstModel`'s constructor builds `CrefTargets` (a `CrefTargetResolver`) + eagerly from the assembly, since indexing every declared type/member by its + XML-doc identifier requires nothing beyond the `AssemblyDefinition` itself. + After `DotNetGenerator.Parse` constructs the `DotNetEmitter` (which owns the + visibility rules needed to decide which symbols will actually be emitted), it + calls `BuildCrefLinkIndices`, which walks the visible type tree (recursing into + nested types via `DotNetEmitter.GetVisibleNestedTypes`) calling + `DotNetEmitterGradualDisclosure.BuildMemberPageIndex` per type to merge a + complete `{member XML-doc ID -> gradual-disclosure page path}` map, and records + every visible type's own XML-doc ID into a companion `emittedTypeIds` set. Both + results are stored on the model via `DotNetAstModel.SetMemberPageIndex`. At + render time, `XmlDocReader` uses `CrefTargets` to resolve a raw `cref` string to + a symbol, the `emittedTypeIds`/`MemberPageIndex` to decide whether that symbol + will actually be emitted, and `TypeLinkResolver.LinkifyResolvedType`/ + `LinkifyResolvedMember` to build the Markdown link text when it will be. + ## Design Constraints - Platform: targets .NET 8 as a class library; no platform-specific code. diff --git a/docs/design/api-mark-dot-net/cref-target-resolver.md b/docs/design/api-mark-dot-net/cref-target-resolver.md new file mode 100644 index 0000000..6d17fcf --- /dev/null +++ b/docs/design/api-mark-dot-net/cref-target-resolver.md @@ -0,0 +1,118 @@ +## CrefTargetResolver + +![CrefTargetResolver Structure](ApiMarkDotNetView.svg) + + + +### Purpose + +CrefTargetResolver resolves a raw XML-doc `cref` attribute string (e.g. +`T:MyNamespace.MyClass` or `M:MyNamespace.MyClass.DoWork(System.Int32)`) to the +Mono.Cecil type or member it names, when that symbol is declared in a single, +specific assembly. It is the first stage of full ``/`` +cross-reference linking: before a reference can be rendered as a real Markdown +link, the engine must know whether a symbol with that exact XML-doc identifier +even exists in the assembly being documented (as opposed to an external +framework type, or a malformed/misspelled cref string). + +This resolver intentionally applies no visibility, obsolete, or +exclude-pattern filtering — it answers only "does a symbol with this +XML-doc-ID string exist in this assembly". Deciding whether a resolved symbol +will actually be *emitted* as a page under the active visibility settings is a +separate, caller-supplied concern (see `CrefLinkContext`'s +`IsTypeEmitted`/`IsMemberEmitted` delegates in the `XmlDocReader` design doc). +Keeping these concerns separate lets a caller distinguish "no such symbol" +(external assembly, or a malformed cref) from "the symbol exists but is +filtered out of the generated documentation" — both cases fall back to the +pre-existing code-span-only rendering, but for different reasons. + +### Data Model + +CrefTargetResolver is immutable after construction; all indexing happens once, +in the constructor. + +- *_types* (`Dictionary`): Index of type definitions + keyed by their XML-doc type identifier (e.g. `T:Namespace.Type`), built via + `DotNetEmitter.BuildTypeId`. +- *_members* (`Dictionary`): Index of member + definitions keyed by their XML-doc member identifier (e.g. + `M:Namespace.Type.Method`, `P:Namespace.Type.Property`), built via + `DotNetEmitter.BuildMemberId`. + +No new identifier-formatting or parsing logic is introduced: both indexes are +built with the exact same builders `XmlDocReader` uses to key its own +documentation index, so a dictionary lookup against either index is +byte-for-byte compatible with the identifier format that appears in a raw +`cref="..."` attribute value for an intra-assembly target. + +### Key Methods + +**CrefTargetResolver constructor**: Builds both indexes by walking every type +(including nested types, at every depth) and member declared in the supplied +assembly. + +- *Parameters*: `AssemblyDefinition assembly` — the assembly whose types and + members to index. +- *Algorithm*: Iterates `assembly.MainModule.GetTypes()` — Mono.Cecil's + flattening enumerator, which already includes nested types at every depth, + so no separate recursive walk is needed. For each type, computes its type ID + via `DotNetEmitter.BuildTypeId` and adds it to `_types` (first occurrence + wins on a duplicate — tolerated defensively the same way `XmlDocReader` + tolerates duplicate member IDs in the XML documentation file, though this + should not occur for well-formed assemblies). For each of the type's + `Methods`/`Properties`/`Fields`/`Events`, computes the member ID via + `DotNetEmitter.BuildMemberId` and adds it to `_members`, skipping any member + for which `BuildMemberId` returns `string.Empty` (a member kind the builder + does not recognize — indexing an empty key would be collision-prone). + +**CrefTargetResolver.TryResolveType**: Attempts to resolve a `cref` string to a +type declared in the indexed assembly. + +- *Parameters*: `string crefId` — the raw `cref` attribute value, including its + `T:` kind prefix (e.g. `T:MyNamespace.MyClass`); `out TypeDefinition type` — + the resolved type when the method returns `true`. +- *Returns*: `true` when a type with this exact identifier was indexed; + otherwise `false`. + +**CrefTargetResolver.TryResolveMember**: Attempts to resolve a `cref` string to +a member declared in the indexed assembly. + +- *Parameters*: `string crefId` — the raw `cref` attribute value, including its + `M:`/`P:`/`F:`/`E:` kind prefix (e.g. + `M:MyNamespace.MyClass.DoWork(System.Int32)`); `out IMemberDefinition member` + — the resolved member when the method returns `true`. +- *Returns*: `true` when a member with this exact identifier was indexed; + otherwise `false`. + +### Error Handling + +CrefTargetResolver does not throw. An unresolvable `crefId` (external +assembly, malformed string, or any other reason) simply yields `false` from +`TryResolveType`/`TryResolveMember`, matching the existing fallback behavior +for crefs that cannot be linked. + +### Dependencies + +- **Mono.Cecil** — CrefTargetResolver walks `AssemblyDefinition`, + `ModuleDefinition.GetTypes()`, `TypeDefinition`, and `IMemberDefinition`. +- **DotNetEmitter.BuildTypeId** / **DotNetEmitter.BuildMemberId** — reused, + unmodified, to compute index keys; no XML-doc-ID formatting logic is + duplicated. + +### Callers + +- **DotNetAstModel** — constructs a `CrefTargetResolver` eagerly in its own + constructor (needs only the assembly, so no deferred post-construction step + is required, unlike `MemberPageIndex`) and exposes it via the `CrefTargets` + property. +- **XmlDocReader** — consults `CrefLinkContext.Targets.TryResolveType`/ + `TryResolveMember` from `GetInlineReferenceText` to decide whether a ``/`` reference can participate in cross-reference + linking at all, before consulting the caller-supplied + `IsTypeEmitted`/`IsMemberEmitted` delegates to decide whether it actually + will be. + +### External Interfaces + +N/A — this is an internal class with no external interfaces exposed beyond its assembly. diff --git a/docs/design/api-mark-dot-net/dot-net-ast-model.md b/docs/design/api-mark-dot-net/dot-net-ast-model.md index ba7331f..be49704 100644 --- a/docs/design/api-mark-dot-net/dot-net-ast-model.md +++ b/docs/design/api-mark-dot-net/dot-net-ast-model.md @@ -52,6 +52,19 @@ boundary between parse and emit. root namespaces for gradual-disclosure output. - *Options* (`DotNetGeneratorOptions`): Generator configuration options including assembly path, XML doc path, visibility, and obsolete filter. +- *CrefTargets* (`CrefTargetResolver`): Visibility-agnostic index resolving a + raw ``/`` value to the type or member it names when + that symbol is declared in this assembly. Built eagerly in the constructor + (needs only `Assembly`). +- *MemberPageIndex* (`IReadOnlyDictionary`): Global index + mapping a member's XML-doc cref ID to its gradual-disclosure page path + (`"{folder}/{fileNameNoExt}"`), covering every member that will actually be + emitted as a page. Empty until `SetMemberPageIndex` is called; used by + `TypeLinkResolver.LinkifyResolvedMember`. +- *EmittedTypeIds* (`IReadOnlySet`): Set of XML-doc type IDs for every + type that will actually be emitted as a page in gradual-disclosure mode + (top-level visible types plus visible nested types, transitively). Empty + until `SetMemberPageIndex` is called. **TypePageWriteContext** (internal sealed record): Bundles the per-type-page writing context that is constant across all member pages generated for a single @@ -64,6 +77,11 @@ pages and table rows. - *Type* (`TypeDefinition`): The type definition being documented. - *XmlDocs* (`XmlDocReader`): Documentation index for member lookups. - *Resolver* (`TypeLinkResolver`): Type link resolver for table cells. +- *LinkContext* (`CrefLinkContext`): Cross-reference resolution context for + ``/`` linking, scoped to this type's own page + folder (`NamespaceFolderPath`). Member pages living in a deeper folder + derive their own context from this one via a `with` expression overriding + `CrefLinkContext.CurrentFolder`. **MethodDocContext** (internal sealed record): Bundles the per-method documentation writing context passed to `DotNetEmitterGradualDisclosure` so @@ -74,6 +92,8 @@ callers do not need to thread five constant parameters through each call site. - *Resolver* (`TypeLinkResolver`): Type link resolver. - *CurrentFolder* (`string`): Folder path of the containing Markdown file. - *ExternalTypes* (`ISet`): Accumulator for external type references found during table cell generation. +- *LinkContext* (`CrefLinkContext`): Cross-reference resolution context for + ``/`` linking, scoped to `CurrentFolder`. **NamespaceDocContext** (internal sealed record): Bundles the per-assembly namespace documentation context that is constant across all namespace page @@ -108,6 +128,17 @@ read-only properties. - *Preconditions*: No parameter may be null. - *Postconditions*: All properties are initialized; no mutation is possible. +**SetMemberPageIndex**: Sets `MemberPageIndex` and `EmittedTypeIds` after +construction. Called exactly once, by `DotNetGenerator.Parse`, immediately +after the owning `DotNetEmitter` has been constructed — deferred because the +index depends on visibility rules that are instance methods on +`DotNetEmitter`, which does not exist yet while the model's constructor runs. + +- *Parameters*: `IReadOnlyDictionary memberPageIndex`, + `IReadOnlySet emittedTypeIds`. +- *Postconditions*: `MemberPageIndex` and `EmittedTypeIds` reflect the + supplied values for the remainder of the emit run. + ### Error Handling DotNetAstModel does not throw after construction. All validation is the @@ -119,6 +150,8 @@ responsibility of `DotNetGenerator.Parse` before constructing the model. Mono.Cecil types. - **XmlDocReader** — held by reference for per-member documentation lookups. - **TypeLinkResolver** — held by reference for type-to-link resolution. +- **CrefTargetResolver** — held by reference (`CrefTargets`) for cref-target + resolution; constructed eagerly by the model constructor itself. ### Callers diff --git a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md index 2e10ba6..e6c7cba 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md @@ -224,7 +224,76 @@ non-method member (property, field, or event) to the supplied Markdown writer. exceptions, remarks, and example sections when present, following the same pattern as `WriteMethodDocumentation` for non-method member kinds. -#### Placeholder Suppression +#### BuildMemberPageIndex and BuildCrefLinkContext + +**BuildMemberPageIndex** (`internal static`): Pure function reproducing the +exact same member-grouping decisions `WriteMembersSection` makes (overload +grouping, operator grouping, case-insensitive-filename-collision grouping, +single-member fallback) without writing any Markdown, returning a map from +each emitted member's XML-doc ID to its page key. + +- *Parameters*: `TypeDefinition type` — the type whose members to index; + `string namespaceFolderPath` — folder path of `type`'s own page; `Func + isMemberEmitted` — visibility predicate deciding + which members are included (typically `DotNetEmitter.ShouldIncludeMember`). +- *Returns*: `IReadOnlyDictionary` — `DotNetEmitter.BuildMemberId(member) + -> "{folder}/{fileNameNoExt}"` for every member where `isMemberEmitted(member)` + is `true`. +- *Why this exists*: Before this refactor, the grouping decisions (which + members share an overload page, which operators share `operators.md`, which + case-colliding members share a combined page) existed only inline inside + `WriteMembersSection` and its four called methods + (`WriteMemberPage`/`WriteMethodOverloadPage`/`WriteTypeOperatorsPage`/ + `WriteCombinedMemberPage`), with no reusable, Markdown-free way to answer + "what page will member X end up on". `CrefTargetResolver`/`XmlDocReader` + cross-reference linking needs exactly that answer, for every member in the + assembly, before any page is written — recomputing the grouping logic a + second time (even "equivalently") would create two sources of truth that + could silently drift apart over time. `BuildMemberPageIndex` extracts the + grouping decisions into a single, independently testable, Markdown-free + function; `WriteMembersSection` (and the methods it calls, where they need + the same per-member grouping/page-key facts) now call this exact same + function + rather than duplicating its logic, so there is exactly one source of truth + for "what page does this member live on". This refactor is required to be, + and was verified to be, fully behavior-preserving: it changes no generated + Markdown output. +- *Callers*: `WriteMembersSection` (via its grouping-decision logic, now + delegating to this function); `DotNetGenerator.CollectTypeCrefLinkIndex` + (merges every type's result into the assembly-wide + `DotNetAstModel.MemberPageIndex`). + +**BuildCrefLinkContext** (private): Constructs a `CrefLinkContext` scoped to a +given folder, bound to the ambient model's cref-linking indices and the +emitter's own visibility delegates. + +- *Parameters*: `string currentFolder` — folder path of the Markdown file + about to be rendered. +- *Returns*: `CrefLinkContext` — with `Targets` bound to + `_model.CrefTargets`, `Resolver` bound to the model's `TypeLinkResolver`, + `MemberPageIndex` bound to `_model.MemberPageIndex`, `IsTypeEmitted`/ + `IsMemberEmitted` bound to this emitter's own + `IsTypeVisible`/`ShouldIncludeMember`-equivalent visibility methods (so + visibility logic is never duplicated), and `CurrentFolder` set to the + supplied folder. +- *Usage pattern*: Built once per type-processing pass at the folder level + where it first becomes relevant (e.g. once per `WriteNamespacePage` call, + once per `WriteTypePage`/`WriteNestedTypesSection` call) rather than freshly + per XML-doc getter call site, since it is mostly stable per current folder. + Page-writer methods that execute at a deeper folder than their caller (e.g. + `WriteMemberPage` relative to `WriteTypePage`) derive a re-scoped + `CrefLinkContext` via the C# record `with` expression (`linkContext with { + CurrentFolder = memberFolder }`) rather than calling `BuildCrefLinkContext` + again or threading the emitter instance itself into those (mostly `static`) + page-writer methods — consistent with the existing `TypePageWriteContext`/ + `MethodDocContext` context-record pattern this class already uses to avoid + passing the emitter instance into its static helpers. +- *Call sites*: Every `ctx.XmlDocs.Get*` call this class makes (except + `GetExceptions`/`GetExceptionDetails`, explicitly out of scope for linking) + passes the resulting (or re-scoped) `CrefLinkContext` as its trailing + argument, so a resolvable intra-assembly ``/`` + reference renders as a real Markdown link instead of the plain code-span + fallback. `WriteTypeHeaderSections`, `WriteNonMethodMemberContent`, and `WriteMethodDocumentation` each fetch the member's (or type's) remarks text @@ -271,9 +340,14 @@ this class. ### Dependencies - **DotNetEmitter** — parent emitter providing shared static helpers. -- **DotNetAstModel** — provides assembly data. +- **DotNetAstModel** — provides assembly data, including `CrefTargets` and + `MemberPageIndex` used for cref cross-reference linking. - **TypeLinkResolver** — used to resolve type references to Markdown links in - table cells. + table cells, and (via `CrefLinkContext`) to resolve ``/`` references to Markdown links. +- **CrefTargetResolver** (indirectly, via `_model.CrefTargets` bundled into a + `CrefLinkContext`) — used to decide whether a cref targets a symbol + declared in the documented assembly. - **XmlDocReader** — used to retrieve documentation text for each member. - **IMarkdownWriterFactory** — received from `DotNetEmitter.Emit`. 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 5d45eb4..f7a7628 100644 --- a/docs/design/api-mark-dot-net/dot-net-generator.md +++ b/docs/design/api-mark-dot-net/dot-net-generator.md @@ -205,6 +205,60 @@ memory and returns a `DotNetEmitter` ready to emit. constructed `XmlDocReader` into private fields (`_assembly`, `_xmlDocs`) so that a later `CheckDocumentationCoverage(string? enforceTier)` call can reuse them without re-parsing the assembly or XML doc file. +- *Cref-linking indices*: After constructing `model` (and therefore + `model.CrefTargets`, built eagerly by `DotNetAstModel`'s own constructor, + which needs only the assembly) `Parse` constructs the `DotNetEmitter`, then + calls the private `BuildCrefLinkIndices(emitter, byNamespace, + rootNamespaces)` helper and passes its result to + `model.SetMemberPageIndex(memberPageIndex, emittedTypeIds)`. This ordering + is required because `MemberPageIndex`/`EmittedTypeIds` need visibility + predicates (`ShouldIncludeMember`, `GetVisibleNestedTypes`-equivalents) that + exist only as instance methods on `DotNetEmitter`, which is itself + constructed from `model` — so these two indices cannot be populated until + after the emitter exists, unlike `CrefTargets`. `BuildCrefLinkIndices` + recursively walks every visible top-level and nested type (via the private + `CollectTypeCrefLinkIndex` helper) adding each type's XML-doc ID to + `emittedTypeIds` and merging + `DotNetEmitterGradualDisclosure.BuildMemberPageIndex`'s per-type result into + a single assembly-wide `memberPageIndex` dictionary. This preserves the + existing one-pass `Parse` → `new DotNetEmitter(model)` → `return emitter` + contract: no second constructor pass over `DotNetAstModel` or `DotNetEmitter` + is introduced, only a post-construction setup call on the already-returned + `model` instance. + +**DotNetGenerator.BuildCrefLinkIndices** (private static): Computes the +assembly-wide member-to-page index and emitted-type-id set used for ``/`` link resolution. + +- *Parameters*: `DotNetEmitter emitter` — supplies the visibility predicates; + `IReadOnlyDictionary> byNamespace`, + `IReadOnlyList rootNamespaces` — the same namespace/type data + already collected during `Parse`. +- *Returns*: `(IReadOnlyDictionary MemberPageIndex, + IReadOnlySet EmittedTypeIds)` — see `DotNetAstModel.MemberPageIndex`/ + `EmittedTypeIds` for the exact field semantics. +- *Algorithm*: Iterates every visible top-level type under every root + namespace and recursively delegates to `CollectTypeCrefLinkIndex` for each. + +**DotNetGenerator.CollectTypeCrefLinkIndex** (private static): Recursively +adds one type's (and all its nested types') XML-doc ID and member-page-index +entries to the shared accumulators. + +- *Parameters*: `DotNetEmitter emitter`, `TypeDefinition type`, `string + namespaceFolderPath`, `Dictionary memberPageIndex`, + `HashSet emittedTypeIds` — all mutated in place. +- *Algorithm*: Adds `type`'s XML-doc ID (via `DotNetEmitter.BuildTypeId`) to + `emittedTypeIds` unconditionally (the walk only ever visits types that are + already known to be visible/emitted, since it starts from `byNamespace`'s + pre-filtered type lists and recurses only into `emitter`'s own + visible-nested-type enumeration). Merges every entry returned by + `DotNetEmitterGradualDisclosure.BuildMemberPageIndex(type, + namespaceFolderPath, emitter.ShouldIncludeMember)` into `memberPageIndex`. + Recurses into each visible nested type with its own nested folder path, so + nested types at any depth are indexed identically to top-level types — this + is the exact same grouping/visibility logic `WriteMembersSection` itself + uses, reused rather than re-derived, per the single-source-of-truth + refactor described in the `DotNetEmitterGradualDisclosure` design doc. **DotNetGenerator.CheckDocumentationCoverage(string? enforceTier)**: Scans the assembly parsed by the most recent `Parse` call for types and members lacking diff --git a/docs/design/api-mark-dot-net/type-link-resolver.md b/docs/design/api-mark-dot-net/type-link-resolver.md index 7bf0a2b..4df6e6b 100644 --- a/docs/design/api-mark-dot-net/type-link-resolver.md +++ b/docs/design/api-mark-dot-net/type-link-resolver.md @@ -89,6 +89,49 @@ reference collected during table cell generation. 8. Non-System external → track in `externalTypes`, return plain name. - *Intra-assembly detection*: `TypeReference.Scope is ModuleDefinition`. +**TypeLinkResolver.LinkifyResolvedType**: Resolves a `cref` target already +known to be a type declared in the assembly being documented to a Markdown +link wrapping an already-formatted display text — used for cross-reference +linking of ``/`` references, as distinct from +`Linkify`'s table-cell type-reference resolution. + +- *Parameters*: `TypeDefinition type` — the resolved intra-assembly type; + `string displayText` — the already-formatted display text (e.g. from + `XmlDocReader.FormatCref`) to wrap in the Markdown link; `string + currentFolder` — folder path of the containing Markdown file. +- *Returns*: `[displayText](relative/path.md)`, or `displayText` unchanged + when `_generateLinks` is `false` (single-file mode). +- *Algorithm*: Reuses `GetTypePageKey`/`ComputeRelativePath` — the same + helpers `Linkify` already uses for intra-assembly `TypeReference` values, + since `TypeDefinition` is itself a `TypeReference`. Callers are expected to + have already confirmed (via `CrefTargetResolver` and the emitter's + visibility rules) that `type` will actually be emitted as a page before + calling this method; it performs no such check itself and always computes a + link when link generation is enabled. + +**TypeLinkResolver.LinkifyResolvedMember**: Resolves a `cref` target already +known to be a member declared in the assembly being documented to a Markdown +link wrapping an already-formatted display text, using the gradual-disclosure +member-to-page index. + +- *Parameters*: `IMemberDefinition member` — the resolved intra-assembly + member; `string displayText` — the already-formatted display text (e.g. + from `XmlDocReader.FormatMemberReference`); `string currentFolder` — folder + path of the containing Markdown file; `IReadOnlyDictionary + memberPageIndex` — map from a member's XML-doc identifier (see + `DotNetEmitter.BuildMemberId`) to its page key. +- *Returns*: `[displayText](relative/path.md)`, or `displayText` unchanged + when `_generateLinks` is `false`, or when `member` has no entry in + `memberPageIndex` (e.g. filtered out of the generated documentation). +- *Why a lookup table and not a closed-form computation*: unlike + `LinkifyResolvedType`, there is no closed-form page-key computation for a + member — in gradual-disclosure mode, members may share a page with their + overloads, with same-named-but-different-kind members (case-collision + grouping), or with every other operator overload of the same type; + decisions made per-type while generating that type's own pages (see + `DotNetEmitterGradualDisclosure.BuildMemberPageIndex`). `memberPageIndex` is + therefore required to resolve the member's actual page path. + **TypeLinkResolver.GetTypePageKey** (private): Computes the documentation page key (relative path without extension) for an intra-assembly `TypeReference`, correctly handling nested types of arbitrary nesting depth. @@ -152,6 +195,9 @@ a `DotNetAstModel` that carries the resolver, making it available to sub-emitter encountered in member table rows. - **DotNetEmitterSingleFile** — constructs a TypeLinkResolver with `generateLinks: false` for parameter type display in single-file output. +- **XmlDocReader** — calls `LinkifyResolvedType`/`LinkifyResolvedMember` (via + `CrefLinkContext.Resolver`) from `GetInlineReferenceText` when a resolved + ``/`` target will actually be emitted as a page. ### External Interfaces diff --git a/docs/design/api-mark-dot-net/xml-doc-reader.md b/docs/design/api-mark-dot-net/xml-doc-reader.md index eb50c4b..bbddd06 100644 --- a/docs/design/api-mark-dot-net/xml-doc-reader.md +++ b/docs/design/api-mark-dot-net/xml-doc-reader.md @@ -42,6 +42,12 @@ discarded. This is a defensive policy for malformed but real-world XML doc files where the compiler emits the same member ID more than once (e.g., due to partial-class splits or tooling bugs). +**CrefLinkContext** (external `internal sealed record`, defined in +`CrefLinkContext.cs`, not a field of `XmlDocReader` itself): Optional +cross-reference linking context, threaded as a trailing nullable parameter +through every public rendering method — see "Cross-reference linking +(CrefLinkContext)" below for its full field list and semantics. + ### Key Methods **XmlDocReader constructor**: Parses the XML documentation file and builds the @@ -504,6 +510,72 @@ text in an inline Markdown code span via `AppendMarkdownCodeSpan`. Used by `Type.Member` text from surrounding prose — see "Member-cref inline code span wrapping" below. +#### Cross-reference linking (`CrefLinkContext`) + +Every public `XmlDocReader` method (`GetSummary`, `GetSummaryMarkdown`, +`GetRemarks`, `GetParams`, `GetReturns`, `GetExample`, `GetExampleParts` — +explicitly NOT `GetExceptions`/`GetExceptionDetails`, which are out of scope +for linking) accepts an additional optional, trailing, nullable +`CrefLinkContext? linkContext = null` parameter. This parameter is threaded, +unchanged, down through the entire private rendering chain: +`GetDocumentationText`/`GetSingleLineDocumentationText` → +`AppendNodeText` (both overloads) → `AppendElementText`/`AppendListText` → +`GetInlineReferenceText`, where it is finally consulted — only in the +``/`` dispatch branch, via the new private helper +`TryLinkifyCref`. + +**`CrefLinkContext`** (`internal sealed record`, in `CrefLinkContext.cs`): +Bundles everything `XmlDocReader` needs to resolve a ``/`` reference to a real relative Markdown link instead of its default +code-span-only fallback. Fields: `CrefTargetResolver Targets` (resolves a raw +`cref` identifier to the intra-assembly symbol it names); `TypeLinkResolver +Resolver` (builds the actual Markdown link once a target has been resolved +and confirmed to be emitted); `IReadOnlyDictionary +MemberPageIndex` (member XML-doc-ID → gradual-disclosure page key, consulted +by `TypeLinkResolver.LinkifyResolvedMember`); `Func +IsTypeEmitted` and `Func IsMemberEmitted` (typically +bound to the active `DotNetEmitter`'s own visibility rules, so visibility +logic is never duplicated here); `string CurrentFolder` (folder path of the +Markdown file currently being rendered, used to compute the relative link +path). It deliberately carries only primitives, delegates, and +`ApiMark.DotNet`-internal peer types (`TypeLinkResolver`, +`CrefTargetResolver`) — never a `DotNetEmitter`/`DotNetAstModel` reference — +so `XmlDocReader` does not take on a hard dependency on the emitter/model +layer merely to support cref linking. + +**TryLinkifyCref** (private static): Attempts to resolve a `cref` string +against `linkContext` and, on success, returns the display text wrapped in a +real Markdown link; otherwise returns the display text unchanged. + +- *Parameters*: `string cref` — the raw `cref` attribute value, including its + kind prefix; `string displayText` — the already-formatted display text (from + `FormatCref`) to wrap when linking; `CrefLinkContext? linkContext`. +- *Algorithm*: Returns `displayText` unchanged immediately when `linkContext` + is `null` (this is how single-file mode opts out of linking entirely — + `DotNetEmitterSingleFile` never constructs or passes a `CrefLinkContext`, + so every call site there always resolves this branch). Otherwise tries + `linkContext.Targets.TryResolveType` first; if it succeeds AND + `linkContext.IsTypeEmitted(type)` is `true`, returns + `linkContext.Resolver.LinkifyResolvedType(type, displayText, + linkContext.CurrentFolder)`. Otherwise tries + `linkContext.Targets.TryResolveMember`; if it succeeds AND + `linkContext.IsMemberEmitted(member)` is `true`, returns + `linkContext.Resolver.LinkifyResolvedMember(member, displayText, + linkContext.CurrentFolder, linkContext.MemberPageIndex)`. Falls back to + `displayText` unchanged in every other case — external type, filtered-out + member, or malformed/unresolvable cref — identical to today's rendering. +- *Call site*: `GetInlineReferenceText`'s ``/`` cref branch + calls `TryLinkifyCref` only when `FormatCref` already reported + `ShouldWrapInCodeSpan: true` (i.e. the exact same subset of crefs that were + already eligible for code-span wrapping before this feature existed — + constructor crefs are excluded by construction, since they report + `ShouldWrapInCodeSpan: false` and short-circuit before `TryLinkifyCref` is + ever called). The returned text (linked or not) is then wrapped in the code + span exactly where the current code would have wrapped plain text, so a + successful link renders as `` `[Type.Member](path.md)` `` — a Markdown link + nested inside the code span — while an unresolved cref renders exactly as + it does today, `` `Type.Member` ``. + ### Error Handling `XmlDocReader` throws `FileNotFoundException` when the XML documentation file @@ -520,6 +592,13 @@ degrades to `null`/empty identically to a purely local miss. - **System.Xml.Linq** — used to parse and navigate the XML documentation file. - **System.Xml.XPath** — used to evaluate the `path` XPath attribute in `` elements. +- **CrefTargetResolver** / **TypeLinkResolver** — consulted (via + `CrefLinkContext`, an optional parameter) by `TryLinkifyCref` to resolve and + render ``/`` references as real Markdown links when + possible — see "Cross-reference linking (CrefLinkContext)" above. Both + dependencies are optional at the type level: `XmlDocReader` compiles and + behaves identically when every caller omits `linkContext` (passing + `null`), so these are not hard dependencies. ### Callers @@ -528,9 +607,14 @@ degrades to `null`/empty identically to a purely local miss. `DotNetGeneratorOptions.ReferencePaths` is non-empty) an `ExternalXmlDocResolver` instance's `TryGetMember` method as the external member lookup delegate. - **DotNetEmitterGradualDisclosure** — calls all getter methods when writing - member detail pages. + member detail pages, passing a `CrefLinkContext` built from the ambient + `DotNetAstModel`'s `CrefTargets`/`MemberPageIndex`/`Resolver` and the + emitter's own visibility delegates, so resolvable intra-assembly crefs + render as real links. - **DotNetEmitterSingleFile** — calls getter methods when writing member - sections in the single-file output. + sections in the single-file output, never passing a `CrefLinkContext` (all + calls omit the parameter, defaulting it to `null`), so single-file output + remains code-span-only exactly as before this feature existed. ### External Interfaces diff --git a/docs/reqstream/api-mark-dot-net.yaml b/docs/reqstream/api-mark-dot-net.yaml index b1cca45..0cc8a93 100644 --- a/docs/reqstream/api-mark-dot-net.yaml +++ b/docs/reqstream/api-mark-dot-net.yaml @@ -30,6 +30,7 @@ sections: - ApiMarkDotNet-DotNetGenerator-ResolveInheritdocAcrossReferencedAssemblies - ApiMarkDotNet-DotNetGenerator-ResolveInheritdocAcrossMultipleExternalHops - ApiMarkDotNet-DotNetGenerator-AcceptLibraryDescriptionOption + - ApiMarkDotNet-DotNetGenerator-BuildCrefLinkingIndicesAfterEmitterConstruction - ApiMarkDotNet-DotNetAstModel-HoldParsedAssemblyData - ApiMarkDotNet-DotNetEmitter-DispatchToFormatSpecificEmitter - ApiMarkDotNet-DotNetEmitter-RejectNullArguments @@ -53,6 +54,8 @@ sections: - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitOperatorsSummaryDocumentation - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitAssemblyDescription - ApiMarkDotNet-DotNetEmitterGradualDisclosure-SuppressPlaceholderWhenRemarksPresent + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-ExtractBuildMemberPageIndex + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-WireCrefLinkContextThroughPageWriters - ApiMarkDotNet-DotNetEmitterSingleFile-EmitAllApiIntoSingleFile - ApiMarkDotNet-DotNetEmitterSingleFile-OffsetHeadingsByHeadingDepth - ApiMarkDotNet-DotNetEmitterSingleFile-EmitAssemblyDescription @@ -64,6 +67,7 @@ sections: - ApiMarkDotNet-DotNetEmitterSingleFile-SkipMemberListForDelegates - ApiMarkDotNet-DotNetEmitterSingleFile-EmitNestedTypeParentNotice - ApiMarkDotNet-DotNetEmitterSingleFile-EmitPlainTextParameterTypes + - ApiMarkDotNet-DotNetEmitterSingleFile-NeverLinkCrefReferences - ApiMarkDotNet-TypeLinkResolver-ResolveIntraAssemblyTypesToMarkdownLinks - ApiMarkDotNet-TypeLinkResolver-ResolvePrimitiveAndSystemTypesToPlainText - ApiMarkDotNet-TypeLinkResolver-TrackNonSystemExternalTypesForExternalTypesSection @@ -71,6 +75,11 @@ sections: - ApiMarkDotNet-TypeLinkResolver-RenderGenericTypesWithTypeArguments - ApiMarkDotNet-TypeLinkResolver-UnwrapNullableValueTypes - ApiMarkDotNet-TypeLinkResolver-ResolveNestedTypesToCorrectPageKey + - ApiMarkDotNet-TypeLinkResolver-ResolveCrefTargetsToMarkdownLinks + - ApiMarkDotNet-CrefTargetResolver-IndexAssemblyTypesAndMembersByXmlDocId + - ApiMarkDotNet-CrefTargetResolver-ResolveWithoutVisibilityFiltering + - ApiMarkDotNet-CrefTargetResolver-ReturnFalseForUnresolvableIdentifiers + - ApiMarkDotNet-DotNetAstModel-HoldCrefLinkingIndices - ApiMarkDotNet-XmlDocReader-ProvideMemberDocumentationFromXmlFile - ApiMarkDotNet-XmlDocReader-RenderXmlDocLists - ApiMarkDotNet-XmlDocReader-InheritDoc @@ -80,6 +89,7 @@ sections: - ApiMarkDotNet-XmlDocReader-RenderBrAndCodeInRemarksAndSummary - ApiMarkDotNet-XmlDocReader-CollapseExcessBlankLineRuns - ApiMarkDotNet-XmlDocReader-WrapMemberCrefDisplayInCodeSpan + - ApiMarkDotNet-XmlDocReader-LinkifyResolvedCrefReferences - ApiMarkDotNet-ExternalXmlDocResolver-LocateAndCacheReferencedAssemblyDocs - ApiMarkDotNet-ExternalXmlDocResolver-RejectNullReferencePaths - ApiMarkDotNet-ExternalXmlDocResolver-ProbeHintedAssemblyBeforeFullScan diff --git a/docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml b/docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml new file mode 100644 index 0000000..db84f59 --- /dev/null +++ b/docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml @@ -0,0 +1,56 @@ +--- +# CrefTargetResolver unit requirements +sections: + - title: ApiMarkDotNet Requirements + sections: + - title: CrefTargetResolver Requirements + requirements: + - id: ApiMarkDotNet-CrefTargetResolver-IndexAssemblyTypesAndMembersByXmlDocId + title: CrefTargetResolver shall index every type (including nested types at + any depth) and every method, property, field, and event declared in a + single assembly, keyed by the same XML-doc identifier format the C# + compiler emits, so that a raw cref attribute string can be resolved to + the Mono.Cecil symbol it names. + justification: | + Full / cross-reference linking requires knowing + whether a cref target is declared in the assembly being documented + before a link can be generated for it. Reusing the exact identifier + format already produced by DotNetEmitter.BuildTypeId/BuildMemberId + (the same format XmlDocReader indexes XML documentation entries by) + means no new identifier-parsing or formatting logic is introduced, and + resolution is a simple dictionary lookup. + tests: + - CrefTargetResolver_TryResolveType_TopLevelType_ReturnsTrueAndType + - CrefTargetResolver_TryResolveType_NestedType_ReturnsTrueAndType + - CrefTargetResolver_TryResolveMember_Method_ReturnsTrueAndMember + - CrefTargetResolver_TryResolveMember_Field_ReturnsTrueAndMember + - id: ApiMarkDotNet-CrefTargetResolver-ResolveWithoutVisibilityFiltering + title: CrefTargetResolver shall resolve cref identifiers purely by symbol + existence in the assembly, applying no visibility, obsolete, or + exclude-pattern filtering of its own. + justification: | + Distinguishing "no such symbol" (external assembly, or a malformed + cref) from "the symbol exists but is filtered out of the generated + documentation" requires that this resolver answer only the former + question; a caller-supplied visibility check (see CrefLinkContext) is + applied separately. Mixing the two concerns into one resolver would + make it impossible for XmlDocReader to decide independently whether a + resolved symbol will actually be emitted as a page. + tests: + - CrefTargetResolver_TryResolveMember_PrivateMember_StillResolves + - id: ApiMarkDotNet-CrefTargetResolver-ReturnFalseForUnresolvableIdentifiers + title: CrefTargetResolver shall return false, without throwing, from + TryResolveType/TryResolveMember for any identifier not found in the + indexed assembly, including external-assembly types and malformed cref + strings. + justification: | + External framework types (e.g. System.ArgumentNullException) and + malformed/misspelled cref strings must fall back to the existing + code-span-only rendering rather than causing a resolution error, so + callers can treat "not found" uniformly regardless of the underlying + reason. + tests: + - CrefTargetResolver_TryResolveType_UnknownIdentifier_ReturnsFalse + - CrefTargetResolver_TryResolveType_ExternalAssemblyType_ReturnsFalse + - CrefTargetResolver_TryResolveMember_UnknownIdentifier_ReturnsFalse + - CrefTargetResolver_TryResolveType_MalformedCref_ReturnsFalse diff --git a/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml b/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml index b5bade9..99962d6 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml @@ -23,3 +23,21 @@ sections: - DotNetAstModel_XmlDocs_AfterParse_IsNotNull - DotNetAstModel_NamespaceDescriptions_AfterParse_IsNotNull - DotNetAstModel_Collections_ExposeReadOnlyInterfaces + - id: ApiMarkDotNet-DotNetAstModel-HoldCrefLinkingIndices + title: DotNetAstModel shall expose a `CrefTargetResolver` (`CrefTargets`) + built eagerly during construction, and a member-to-page index + (`MemberPageIndex`) plus a set of emitted type identifiers + (`EmittedTypeIds`) populated via `SetMemberPageIndex` once the owning + `DotNetEmitter` has been constructed, so that cross-reference linking + can resolve a ``/`` target to a real page. + justification: | + Full cross-reference linking requires knowing, for a given cref + string, both whether a symbol with that XML-doc ID exists in this + assembly (`CrefTargets`, visibility-agnostic) and whether it will + actually be emitted as a page under the active visibility settings + (`MemberPageIndex`/`EmittedTypeIds`, which depend on `DotNetEmitter` + instance methods and therefore cannot be computed in the model's own + constructor). + tests: + - DotNetAstModel_CrefTargets_AfterParse_ResolvesKnownFixtureType + - DotNetAstModel_MemberPageIndexAndEmittedTypeIds_AfterGradualDisclosureEmit_ArePopulated diff --git a/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml index 71502ef..3231482 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml @@ -146,3 +146,47 @@ sections: - DotNetEmitterGradualDisclosure_Emit_MemberWithRemarksOnly_SuppressesPlaceholderAndShowsRemarks - DotNetEmitterGradualDisclosure_Emit_MemberWithNeitherSummaryNorRemarks_StillShowsPlaceholder - DotNetEmitterGradualDisclosure_Emit_TypeWithRemarksOnly_SuppressesPlaceholderAndShowsRemarksOnTypePage + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-ExtractBuildMemberPageIndex + title: DotNetEmitterGradualDisclosure shall expose an internal static + BuildMemberPageIndex function that reproduces WriteMembersSection's + exact member-grouping decisions (overload grouping, operator grouping, + case-collision grouping, single-member fallback) without writing any + Markdown, and WriteMembersSection shall itself call this same function + for its grouping decisions so there is a single source of truth, with + no change to any existing generated output. + justification: | + Full / cross-reference linking needs to know, + for every member in the assembly, the exact gradual-disclosure page path + it will be written to — before any page is written. Re-deriving the + grouping logic a second time, even if initially equivalent, would create + two sources of truth that could silently drift apart on a future edit to + only one of them. Extracting a pure, independently testable function and + having WriteMembersSection itself delegate to it removes that risk by + construction. This refactor is required to be fully behavior-preserving. + tests: + - BuildMemberPageIndex_AllEntries_MatchActualWrittenPages + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-WireCrefLinkContextThroughPageWriters + title: DotNetEmitterGradualDisclosure shall construct a CrefLinkContext + (bound to the model's CrefTargets/MemberPageIndex/Resolver and this + emitter's own visibility delegates) and pass it as the trailing + argument to every ctx.XmlDocs.Get* call it makes, except + GetExceptions/GetExceptionDetails, so that a resolvable ``/`` reference renders as a real relative Markdown + link instead of the plain code-span fallback. + justification: | + Code-span-wrapped plain text for every cref is strictly less useful than + a real clickable cross-reference whenever the target is actually present + in the generated documentation. Constructing the CrefLinkContext once per + type-processing pass (re-scoped per folder via the record `with` + expression where a page writer executes at a deeper folder than its + caller) avoids rebuilding it freshly at every call site while keeping + page-writer methods free of a direct emitter-instance dependency, + consistent with the existing TypePageWriteContext/MethodDocContext + pattern. + tests: + - CrefLinking_SeeCrefToVisibleType_RendersAsMarkdownLink + - CrefLinking_SeeCrefToVisibleMember_RendersAsMarkdownLink + - CrefLinking_SeeCrefToFilteredMember_RendersFallbackWithNoLink + - CrefLinking_SeeCrefToExternalType_RendersFallbackWithNoLink + - CrefLinking_SeeCrefMalformed_RendersFallbackWithNoLink + - CrefLinking_SeeAlsoCrefToVisibleType_RendersAsMarkdownLink diff --git a/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml b/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml index 08daa56..d9487fd 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml @@ -129,3 +129,21 @@ sections: references. tests: - DotNetEmitterSingleFile_Emit_MethodWithParameter_TypeCellIsPlainText + - id: ApiMarkDotNet-DotNetEmitterSingleFile-NeverLinkCrefReferences + title: DotNetEmitterSingleFile shall never construct or pass a + CrefLinkContext to any XmlDocReader call site, so that a ``/`` reference in single-file output continues to + render exactly as it did before cross-reference linking existed — + code-span-only, never a Markdown link — regardless of whether the + cref's target would otherwise be resolvable and emitted. + justification: | + Relative Markdown links are meaningless inside a single document, the + same reason parameter type cells are already rendered as plain text + rather than links (see EmitPlainTextParameterTypes above). Full ``/`` cross-reference linking is explicitly out of + scope for single-file mode; leaving every XmlDocReader call site in this + class completely unmodified, so the new optional CrefLinkContext + parameter always defaults to null, is simpler and safer than adding an + explicit opt-out branch. + tests: + - CrefLinking_SingleFileMode_SeeCrefToVisibleType_RemainsCodeSpanOnly 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 7e02a92..8739c7a 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml @@ -211,3 +211,29 @@ sections: AssemblyDescriptionAttribute. tests: - DotNetGenerator_Parse_WithLibraryDescription_PropagatesToModelOptions + - id: ApiMarkDotNet-DotNetGenerator-BuildCrefLinkingIndicesAfterEmitterConstruction + title: DotNetGenerator.Parse shall, after constructing the DotNetEmitter, + populate DotNetAstModel's MemberPageIndex and EmittedTypeIds by + recursively walking every visible top-level and nested type using the + emitter's own visibility rules, merging each type's + DotNetEmitterGradualDisclosure.BuildMemberPageIndex result into a + single assembly-wide index, without introducing a second construction + pass over DotNetAstModel/DotNetEmitter. + justification: | + Full / cross-reference linking requires knowing, + for every member, the exact gradual-disclosure page path it will be + written to (accounting for overload grouping, operator grouping, and + case-collision grouping) and whether a type/member will be emitted at + all under the active visibility settings. Both require visibility + predicates that exist only as instance methods on DotNetEmitter, which + is itself constructed from DotNetAstModel — so this index can only be + computed after the emitter exists, unlike CrefTargetResolver (built + eagerly by DotNetAstModel's own constructor since it needs only the + assembly). Populating it via a post-construction setup call on the + already-returned model, rather than restructuring Parse's single-pass + flow, keeps the existing Parse -> new DotNetEmitter(model) -> return + emitter contract intact. + tests: + - DotNetAstModel_MemberPageIndexAndEmittedTypeIds_AfterGradualDisclosureEmit_ArePopulated + - DotNetGenerator_Generate_GradualDisclosure_EveryGeneratedLinkResolvesToExistingPage + - BuildMemberPageIndex_AllEntries_MatchActualWrittenPages diff --git a/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml b/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml index 06fc1e1..d4bfecb 100644 --- a/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml +++ b/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml @@ -96,3 +96,28 @@ sections: - TypeLinkResolver_Linkify_TwoLevelsDeepNestedType_ResolvesFullAncestorPath - TypeLinkResolver_Linkify_NestedType_FromParentPageVsOtherPage_ProducesDifferentRelativePaths - DotNetGenerator_Generate_GradualDisclosure_EveryGeneratedLinkResolvesToExistingPage + - id: ApiMarkDotNet-TypeLinkResolver-ResolveCrefTargetsToMarkdownLinks + title: TypeLinkResolver shall expose LinkifyResolvedType and + LinkifyResolvedMember methods that resolve an already-resolved + intra-assembly cref target (a TypeDefinition or IMemberDefinition) to + a Markdown link wrapping caller-supplied display text, returning the + display text unchanged when link generation is disabled or (for + members) when the target has no entry in the supplied member-page + index. + justification: | + Full cross-reference linking for ``/`` + references requires converting an already-resolved cref target (from + CrefTargetResolver) to a Markdown link using the same page-key and + relative-path machinery Linkify already uses for table-cell type + references, without re-deriving that logic. LinkifyResolvedType + reuses GetTypePageKey directly (TypeDefinition is a TypeReference); + LinkifyResolvedMember instead consults a member-to-page index because + gradual-disclosure member page assignment (overload grouping, + operator grouping, case-collision grouping) has no closed-form + computation. + tests: + - TypeLinkResolver_LinkifyResolvedType_GenerateLinksTrue_ReturnsMarkdownLink + - TypeLinkResolver_LinkifyResolvedType_GenerateLinksFalse_ReturnsDisplayTextUnchanged + - TypeLinkResolver_LinkifyResolvedMember_FoundInIndex_ReturnsMarkdownLink + - TypeLinkResolver_LinkifyResolvedMember_NotInIndex_ReturnsDisplayTextUnchanged + - TypeLinkResolver_LinkifyResolvedMember_GenerateLinksFalse_ReturnsDisplayTextUnchanged diff --git a/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml b/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml index ce91a5e..ec5723a 100644 --- a/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml +++ b/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml @@ -246,3 +246,40 @@ sections: - XmlDocReader_GetExampleParts_WithSeeCref_RendersInlineReferenceInProsePart - XmlDocReader_GetExampleParts_WithMixedInlineElements_ProseAccumulatedAsOnePart - DotNetGenerator_Generate_InlineReferences_PreserveSymbolNamesInMarkdown + - id: ApiMarkDotNet-XmlDocReader-LinkifyResolvedCrefReferences + title: XmlDocReader shall accept an optional, trailing, nullable + CrefLinkContext parameter on every public rendering method (except + GetExceptions/GetExceptionDetails) and, when supplied and the cref + target resolves to an intra-assembly type or member that will actually + be emitted, render the cref's existing code-span display text as a + real relative Markdown link to that target's page; otherwise it shall + continue rendering exactly as before (code-span text, no link) for + external types, filtered-out members, malformed crefs, and + constructors, and single-file output shall be entirely unaffected + because it never supplies a CrefLinkContext. + justification: | + Code-span-wrapped plain text for every cref (the PR #54 fallback) is + strictly less useful than a real clickable cross-reference whenever the + target is actually present in the generated documentation. Threading an + optional CrefLinkContext through the existing rendering chain, rather + than branching the whole chain on a new mode flag, keeps the ~100 + existing call sites (and the single-file emitter, which never opts in) + compiling and behaving identically by construction, while letting the + gradual-disclosure emitter opt in with no duplicated rendering logic. + Constructors are excluded because FormatCref already reports + ShouldWrapInCodeSpan: false for them, so no new special case is needed. + tests: + - XmlDocReader_GetSummary_NullLinkContext_RendersCodeSpanOnlyNoLink + - XmlDocReader_GetSummary_ResolvedEmittedType_RendersLinkedCodeSpan + - XmlDocReader_GetSummary_ResolvedEmittedMember_RendersLinkedCodeSpan + - XmlDocReader_GetSummary_ExternalTypeCref_FallsBackToCodeSpanOnly + - XmlDocReader_GetSummary_MalformedCref_FallsBackUnchanged + - XmlDocReader_GetSummary_ConstructorCref_FallsBackUnchanged + - XmlDocReader_GetRemarks_SeeAlsoResolvedEmittedType_RendersLinkedCodeSpan + - CrefLinking_SeeCrefToVisibleType_RendersAsMarkdownLink + - CrefLinking_SeeCrefToVisibleMember_RendersAsMarkdownLink + - CrefLinking_SeeCrefToFilteredMember_RendersFallbackWithNoLink + - CrefLinking_SeeCrefToExternalType_RendersFallbackWithNoLink + - CrefLinking_SeeCrefMalformed_RendersFallbackWithNoLink + - CrefLinking_SeeAlsoCrefToVisibleType_RendersAsMarkdownLink + - CrefLinking_SingleFileMode_SeeCrefToVisibleType_RemainsCodeSpanOnly diff --git a/docs/verification/api-mark-dot-net/cref-target-resolver.md b/docs/verification/api-mark-dot-net/cref-target-resolver.md new file mode 100644 index 0000000..553eb44 --- /dev/null +++ b/docs/verification/api-mark-dot-net/cref-target-resolver.md @@ -0,0 +1,87 @@ +## CrefTargetResolver + +### Verification Approach + +`CrefTargetResolver` is unit-tested in `test/ApiMark.DotNet.Tests/` using real +Mono.Cecil type/member definitions obtained from the fixture assembly. Tests +construct a resolver over the fixture assembly and verify that +`TryResolveType`/`TryResolveMember` succeed for every symbol kind indexed (a +top-level type, a nested type, a method, a field) and fail for identifiers +that do not exist in the assembly (unknown identifier, external-assembly +type, and a malformed cref string). No visibility filtering is exercised by +these tests — CrefTargetResolver applies none — including a test that proves +a private member still resolves (visibility filtering is explicitly a +separate, caller-supplied concern). No mocking is required. + +### Test Environment + +Tests require the compiled fixture assembly so that real Mono.Cecil type and +member definitions can be obtained. No external service, network dependency, +or writable output location is needed. + +### Acceptance Criteria + +- All `CrefTargetResolver` tests pass with zero failures. +- A top-level type's XML-doc identifier resolves via `TryResolveType`. +- A nested type's XML-doc identifier resolves via `TryResolveType`. +- An unknown type identifier returns `false` from `TryResolveType`. +- An external-assembly type's identifier (e.g. a `System.*` type) returns `false` from `TryResolveType`. +- A method's XML-doc identifier resolves via `TryResolveMember`. +- A private member's XML-doc identifier still resolves via `TryResolveMember` — CrefTargetResolver applies no visibility filtering. +- An unknown member identifier returns `false` from `TryResolveMember`. +- A malformed cref string returns `false` from `TryResolveType`. +- A field's XML-doc identifier resolves via `TryResolveMember`. + +### Test Scenarios + +**Top-level type resolves via TryResolveType**: Verifies that a well-formed +`T:` identifier for a top-level type declared in the indexed assembly +resolves to that type. This scenario is tested by +`CrefTargetResolver_TryResolveType_TopLevelType_ReturnsTrueAndType`. + +**Nested type resolves via TryResolveType**: Verifies that a `T:` identifier +for a nested type resolves correctly, confirming that the constructor's walk +over `ModuleDefinition.GetTypes()` — Mono.Cecil's flattening enumerator — +indexes nested types at every depth. This scenario is tested by +`CrefTargetResolver_TryResolveType_NestedType_ReturnsTrueAndType`. + +**Unknown type identifier returns false**: Verifies that a syntactically +well-formed but non-existent `T:` identifier is not resolved. This scenario +is tested by +`CrefTargetResolver_TryResolveType_UnknownIdentifier_ReturnsFalse`. + +**External-assembly type identifier returns false**: Verifies that a `T:` +identifier for a type declared in a different assembly (e.g. +`System.ArgumentNullException`) is not resolved, confirming that +CrefTargetResolver only indexes the single assembly it was constructed over. +This scenario is tested by +`CrefTargetResolver_TryResolveType_ExternalAssemblyType_ReturnsFalse`. + +**Method resolves via TryResolveMember**: Verifies that a well-formed `M:` +identifier for a method declared in the indexed assembly resolves to that +method. This scenario is tested by +`CrefTargetResolver_TryResolveMember_Method_ReturnsTrueAndMember`. + +**Private member still resolves**: Verifies that CrefTargetResolver applies no +visibility filtering of its own — a private member's `M:` identifier still +resolves via `TryResolveMember`, confirming that visibility-based exclusion +from the generated documentation is strictly a separate, caller-supplied +concern layered on top of this resolver. This scenario is tested by +`CrefTargetResolver_TryResolveMember_PrivateMember_StillResolves`. + +**Unknown member identifier returns false**: Verifies that a syntactically +well-formed but non-existent member identifier is not resolved. This scenario +is tested by +`CrefTargetResolver_TryResolveMember_UnknownIdentifier_ReturnsFalse`. + +**Malformed cref string returns false**: Verifies that a malformed/unparsable +cref string (not matching any indexed identifier) returns `false` from +`TryResolveType` rather than throwing, matching the existing fallback +rendering behavior for crefs that cannot be resolved. This scenario is +tested by `CrefTargetResolver_TryResolveType_MalformedCref_ReturnsFalse`. + +**Field resolves via TryResolveMember**: Verifies that a well-formed `F:` +identifier for a field declared in the indexed assembly resolves to that +field, confirming that all four member kinds (`Methods`/`Properties`/ +`Fields`/`Events`) are indexed, not just methods. This scenario is tested by +`CrefTargetResolver_TryResolveMember_Field_ReturnsTrueAndMember`. diff --git a/docs/verification/api-mark-dot-net/dot-net-ast-model.md b/docs/verification/api-mark-dot-net/dot-net-ast-model.md index 99bcb2e..b13fbb7 100644 --- a/docs/verification/api-mark-dot-net/dot-net-ast-model.md +++ b/docs/verification/api-mark-dot-net/dot-net-ast-model.md @@ -26,6 +26,11 @@ No external service, network dependency, or privileged configuration is needed. - `XmlDocs` is non-null after parsing. - `NamespaceDescriptions` is non-null after parsing. - All collection-type properties (`AllNamespaces`, `ByNamespace`, `RootNamespaces`) expose read-only interfaces. +- `CrefTargets` is populated eagerly by `Parse` and resolves a known fixture + type's XML-doc cref ID. +- `MemberPageIndex` and `EmittedTypeIds` are populated once the + gradual-disclosure emitter has processed the model, and contain entries for + known visible fixture symbols. ### Test Scenarios @@ -80,3 +85,17 @@ test is required for the static guarantee. Supplementary runtime test: `DotNetAstModel_Collections_ExposeReadOnlyInterfaces` asserts that the runtime types satisfy the interface constraints by calling `Assert.IsAssignableFrom` on each collection property. + +**CrefTargets resolves a known fixture type after Parse**: Verifies that +`DotNetAstModel.CrefTargets` is constructed eagerly during `DotNetGenerator.Parse` +(it needs only the assembly, not the emitter's visibility rules) and can resolve +a known fixture type's XML-doc cref ID to its `TypeDefinition`. This scenario is +tested by `DotNetAstModel_CrefTargets_AfterParse_ResolvesKnownFixtureType`. + +**MemberPageIndex and EmittedTypeIds are populated after gradual-disclosure emit**: +Verifies that `DotNetAstModel.MemberPageIndex` and `DotNetAstModel.EmittedTypeIds` +— both empty immediately after `Parse` — are populated once a +`DotNetEmitterGradualDisclosure` has run `Emit` against the model, and that the +populated index/set contain entries for a known visible fixture member/type. +This scenario is tested by +`DotNetAstModel_MemberPageIndexAndEmittedTypeIds_AfterGradualDisclosureEmit_ArePopulated`. diff --git a/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md b/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md index 79711f9..20694eb 100644 --- a/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md @@ -35,6 +35,13 @@ service or network dependency is needed. - A type whose `` contains a `` renders the list as real multi-line ordered Markdown items on the type page body, with trailing prose as a separate paragraph. - A member with no `` but present `` content does not show the "No description provided." placeholder on its detail page, and the remarks content is shown. - A member with neither `` nor `` still shows the "No description provided." placeholder on its detail page (regression guard). +- A `` to a visible intra-assembly type renders as a Markdown link in gradual-disclosure output. +- A `` to a visible intra-assembly member renders as a Markdown link in gradual-disclosure output. +- A `` to a member filtered out by visibility renders as plain code-span text with no link. +- A `` to an external framework type renders as plain code-span text with no link. +- A malformed/unresolvable `` renders as plain code-span text with no link. +- A `` to a visible intra-assembly type renders as a Markdown link. +- Every entry in `DotNetAstModel.MemberPageIndex` corresponds to a page actually written during `Emit`. ### Test Scenarios @@ -150,3 +157,53 @@ the "No description provided." placeholder. Exercises the type-level branch in `WriteTypeHeaderSections` directly, which the member-level test above does not cover. This scenario is tested by `DotNetEmitterGradualDisclosure_Emit_TypeWithRemarksOnly_SuppressesPlaceholderAndShowsRemarksOnTypePage`. + +**CrefLinking: see cref to a visible type renders as a Markdown link**: +Verifies end-to-end that a `` pointing at another in-assembly, +visible type renders as a real relative Markdown link (code-span wrapped) in +gradual-disclosure output, confirming the full `CrefLinkContext` +construction/threading chain from `DotNetGenerator.Parse` through +`DotNetEmitterGradualDisclosure`'s page writers to `XmlDocReader` works +end-to-end. This scenario is tested by +`CrefLinking_SeeCrefToVisibleType_RendersAsMarkdownLink`. + +**CrefLinking: see cref to a visible member renders as a Markdown link**: +Verifies the member counterpart: a `` pointing at a visible +in-assembly member renders as a Markdown link resolved via +`DotNetAstModel.MemberPageIndex`. This scenario is tested by +`CrefLinking_SeeCrefToVisibleMember_RendersAsMarkdownLink`. + +**CrefLinking: see cref to a member filtered out by visibility falls back +with no link**: Verifies that a `` pointing at a member excluded +from the generated documentation by the active visibility settings renders +as plain code-span text, with no link, exactly as it did before this feature +existed. This scenario is tested by +`CrefLinking_SeeCrefToFilteredMember_RendersFallbackWithNoLink`. + +**CrefLinking: see cref to an external type falls back with no link**: +Verifies that a `` pointing at an external framework type (not +indexed by `CrefTargetResolver`) renders as plain code-span text, with no +link. This scenario is tested by +`CrefLinking_SeeCrefToExternalType_RendersFallbackWithNoLink`. + +**CrefLinking: malformed cref falls back with no link**: Verifies that a +malformed/unresolvable cref string renders as plain code-span text, with no +link, matching today's fallback. This scenario is tested by +`CrefLinking_SeeCrefMalformed_RendersFallbackWithNoLink`. + +**CrefLinking: seealso cref to a visible type renders as a Markdown link**: +Verifies that `` (nested inside ``) shares the same +end-to-end linking path as `` in gradual-disclosure output. This +scenario is tested by +`CrefLinking_SeeAlsoCrefToVisibleType_RendersAsMarkdownLink`. + +**Every MemberPageIndex entry matches an actually-written page**: Verifies, +across the full fixture assembly, that every entry in +`DotNetAstModel.MemberPageIndex` (populated by `BuildMemberPageIndex` via +`DotNetGenerator.CollectTypeCrefLinkIndex`) corresponds to a page key that a +writer actually wrote during `Emit` — directly guarding against +`BuildMemberPageIndex`'s grouping logic silently drifting out of sync with +`WriteMembersSection`'s real output, since both now call the same extracted +function but could in principle diverge again through a future edit to only +one call site. This scenario is tested by +`BuildMemberPageIndex_AllEntries_MatchActualWrittenPages`. diff --git a/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md b/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md index e1554f9..2b34f85 100644 --- a/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md +++ b/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md @@ -41,6 +41,7 @@ service or network dependency is needed. - Delegate types do not emit compiler-generated member sections (Invoke, BeginInvoke, EndInvoke). - Nested types include a parent-context notice paragraph (e.g., "Nested type of `OuterClass`."). - Parameter type cells in tables contain plain text, not Markdown links. +- A `` to a visible in-assembly type renders as plain code-span text with no link, even though the same cref would resolve to a real link in gradual-disclosure mode. ### Test Scenarios @@ -169,3 +170,13 @@ remarks**: Verifies the same placeholder-suppression rule at the member level content is shown instead — confirming `WriteSingleFileMemberSection` applies the identical rule as `WriteSingleFileTypeSections`. This scenario is tested by `DotNetEmitterSingleFile_Emit_MemberWithRemarksOnly_SuppressesPlaceholderAndShowsRemarks`. + +**Single-file mode never links a cref, even when the same target would be +linked in gradual-disclosure mode**: Verifies that a `` pointing at +an in-assembly, visible type — the same fixture type that gradual-disclosure +output links in `CrefLinking_SeeCrefToVisibleType_RendersAsMarkdownLink` — +renders as plain code-span text with no link in single-file output, proving +`DotNetEmitterSingleFile` never constructs or passes a `CrefLinkContext` to +any `XmlDocReader` call site, so the new optional parameter always defaults +to `null` here. This scenario is tested by +`CrefLinking_SingleFileMode_SeeCrefToVisibleType_RemainsCodeSpanOnly`. diff --git a/docs/verification/api-mark-dot-net/dot-net-generator.md b/docs/verification/api-mark-dot-net/dot-net-generator.md index fb1d3de..0cdda49 100644 --- a/docs/verification/api-mark-dot-net/dot-net-generator.md +++ b/docs/verification/api-mark-dot-net/dot-net-generator.md @@ -68,6 +68,11 @@ network dependency, or privileged configuration is needed. prior behavior. - When `LibraryDescription` is set on `DotNetGeneratorOptions`, `Parse` propagates it unchanged into the parsed model's `Options.LibraryDescription`. +- After `Parse` returns, `DotNetAstModel.MemberPageIndex` and `EmittedTypeIds` are populated + with entries for every type/member that gradual-disclosure emission actually writes a + page for. +- Every relative Markdown link produced by gradual-disclosure emission (including cref-based + links) resolves to a page that was actually written. ### Test Scenarios @@ -329,3 +334,21 @@ unchanged into the resulting model's `Options.LibraryDescription`, confirming th generator correctly carries this option through to the emitters that implement the actual override-with-fallback precedence. This scenario is tested by `DotNetGenerator_Parse_WithLibraryDescription_PropagatesToModelOptions`. + +**MemberPageIndex and EmittedTypeIds are populated after Parse**: Verifies that after +`Parse` constructs the `DotNetEmitter` and a subsequent `Emit` call writes +gradual-disclosure output, `DotNetAstModel.MemberPageIndex` contains an entry for every +member actually emitted to its own page, and `EmittedTypeIds` contains every emitted +type's XML-doc ID — confirming `BuildCrefLinkIndices`/`CollectTypeCrefLinkIndex` recurse +correctly through every visible top-level and nested type using the emitter's own +visibility rules. This scenario is tested by +`DotNetAstModel_MemberPageIndexAndEmittedTypeIds_AfterGradualDisclosureEmit_ArePopulated`. + +**Every generated gradual-disclosure link resolves to an actually-written page**: +Verifies, across the full fixture assembly, that every relative Markdown link emitted +by gradual-disclosure output — including the new ``/`` +cross-reference links built from `MemberPageIndex`/`CrefTargets` — resolves to a page +path that a writer actually wrote, confirming `BuildMemberPageIndex`'s grouping +decisions (overload grouping, operator grouping, case-collision grouping) exactly match +`WriteMembersSection`'s real output with no drift between the two. This scenario is +tested by `DotNetGenerator_Generate_GradualDisclosure_EveryGeneratedLinkResolvesToExistingPage`. diff --git a/docs/verification/api-mark-dot-net/type-link-resolver.md b/docs/verification/api-mark-dot-net/type-link-resolver.md index 0271e8e..a6b0240 100644 --- a/docs/verification/api-mark-dot-net/type-link-resolver.md +++ b/docs/verification/api-mark-dot-net/type-link-resolver.md @@ -39,6 +39,11 @@ output location is needed. - A nested type referenced as a generic type argument resolves to the correct nested-type page path. - A type nested two levels deep (Outer.Middle.Inner) resolves to the full ancestor-chain page path. - Linking to a nested type from within its own parent type's page produces a shorter relative path than linking to the same nested type from an unrelated namespace/type's page. +- `LinkifyResolvedType` returns a Markdown link to a resolved intra-assembly type's page when link generation is enabled. +- `LinkifyResolvedType` returns the display text unchanged when link generation is disabled. +- `LinkifyResolvedMember` returns a Markdown link when the member is found in the supplied member-page index. +- `LinkifyResolvedMember` returns the display text unchanged when the member has no entry in the member-page index. +- `LinkifyResolvedMember` returns the display text unchanged when link generation is disabled. ### Test Scenarios @@ -159,3 +164,34 @@ from a completely different namespace/type's page — confirming the page-key construction is independent of the link's source location and only the relative-path computation varies. This scenario is tested by `TypeLinkResolver_Linkify_NestedType_FromParentPageVsOtherPage_ProducesDifferentRelativePaths`. + +**LinkifyResolvedType returns a Markdown link when link generation is enabled**: +Verifies that a resolved intra-assembly `TypeDefinition` cref target produces a +Markdown link to its page, wrapping the caller-supplied display text, reusing +the same `GetTypePageKey`/`ComputeRelativePath` helpers `Linkify` uses for +intra-assembly `TypeReference` values. This scenario is tested by +`TypeLinkResolver_LinkifyResolvedType_GenerateLinksTrue_ReturnsMarkdownLink`. + +**LinkifyResolvedType returns the display text unchanged when link generation is +disabled**: Verifies that single-file mode (`generateLinks: false`) short-circuits +`LinkifyResolvedType` to return the display text verbatim, with no link markup. +This scenario is tested by +`TypeLinkResolver_LinkifyResolvedType_GenerateLinksFalse_ReturnsDisplayTextUnchanged`. + +**LinkifyResolvedMember returns a Markdown link when found in the member-page +index**: Verifies that a resolved intra-assembly member cref target, present in +the supplied `memberPageIndex`, produces a Markdown link to its page. This +scenario is tested by +`TypeLinkResolver_LinkifyResolvedMember_FoundInIndex_ReturnsMarkdownLink`. + +**LinkifyResolvedMember returns the display text unchanged when not in the +member-page index**: Verifies that a member with no entry in `memberPageIndex` +(e.g. filtered out of the generated documentation by visibility) falls back to +the display text unchanged, with no link markup. This scenario is tested by +`TypeLinkResolver_LinkifyResolvedMember_NotInIndex_ReturnsDisplayTextUnchanged`. + +**LinkifyResolvedMember returns the display text unchanged when link generation +is disabled**: Verifies that single-file mode (`generateLinks: false`) +short-circuits `LinkifyResolvedMember` before consulting `memberPageIndex` at +all. This scenario is tested by +`TypeLinkResolver_LinkifyResolvedMember_GenerateLinksFalse_ReturnsDisplayTextUnchanged`. diff --git a/docs/verification/api-mark-dot-net/xml-doc-reader.md b/docs/verification/api-mark-dot-net/xml-doc-reader.md index 8d877c7..1030678 100644 --- a/docs/verification/api-mark-dot-net/xml-doc-reader.md +++ b/docs/verification/api-mark-dot-net/xml-doc-reader.md @@ -93,6 +93,13 @@ assembly is needed. - `GetRemarks` collapses three or more consecutive `
` tags to at most one blank line. - A multi-line `` element nested inside a `` item's `` renders as an inline span, not a fenced block. - A multi-line `` element reached through `` mixed-prose accumulation (nested inside a ``, not a direct `` child) renders as an inline span, not a fenced block. +- Every public `XmlDocReader` method called without a `linkContext` argument (or with `linkContext: null`) renders a resolvable cref exactly as it did before cross-reference linking existed — code-span-only, never linked. +- A `` to an intra-assembly type that will be emitted, rendered with a non-null `linkContext` resolving that type and reporting it as emitted, produces a Markdown link nested inside the code span. +- A `` to an intra-assembly member that will be emitted, rendered with a non-null `linkContext` resolving that member and reporting it as emitted, produces a Markdown link (via the member-page index) nested inside the code span. +- A `` to an external (non-indexed) type falls back to code-span-only rendering even with a non-null `linkContext`. +- A malformed/unresolvable `cref` string falls back to code-span-only rendering even with a non-null `linkContext`. +- A `` to a constructor falls back to its existing unwrapped-text rendering even with a non-null `linkContext` — constructors are never linked. +- A `` to a resolved, emitted intra-assembly type (nested inside ``) produces a Markdown link nested inside the code span, confirming `` shares the same `TryLinkifyCref` rendering path as ``. ### Test Scenarios @@ -607,3 +614,57 @@ inline, not fenced**: Verifies that a multi-line `` nested inside a rather than a mangled fenced block, since the example-prose pipeline never supplies a `codeBlocks` placeholder map. This scenario is tested by `XmlDocReader_GetExampleParts_WithMixedInlineElementsContainingMultiLineCodeOutsideCodeTag_RendersAsInlineSpan`. + +**No linkContext renders a resolvable cref exactly as before linking existed**: +Verifies that calling `GetSummary` without a `linkContext` argument (the +pre-existing 1-arg call shape used throughout the rest of this file, and by +every single-file-emitter call site) continues to render a `` to a +type that would in fact be resolvable and emitted, as plain code-span text +with no link — proving the optional parameter is purely additive and changes +no existing behavior by default. This scenario is tested by +`XmlDocReader_GetSummary_NullLinkContext_RendersCodeSpanOnlyNoLink`. + +**A resolved, emitted intra-assembly type renders as a linked code span**: +Verifies that `GetSummary`, given a `linkContext` whose `Targets` resolves the +cref to a `TypeDefinition` and whose `IsTypeEmitted` delegate reports `true` +for it, renders `` `[Type](path.md)` `` — a Markdown link nested inside the +code span — rather than plain code-span text. This scenario is tested by +`XmlDocReader_GetSummary_ResolvedEmittedType_RendersLinkedCodeSpan`. + +**A resolved, emitted intra-assembly member renders as a linked code span**: +Verifies the member counterpart of the above: `GetSummary`, given a +`linkContext` whose `Targets` resolves the cref to an `IMemberDefinition` and +whose `IsMemberEmitted` delegate reports `true` for it, and whose +`MemberPageIndex` contains an entry for that member, renders a Markdown link +nested inside the code span. This scenario is tested by +`XmlDocReader_GetSummary_ResolvedEmittedMember_RendersLinkedCodeSpan`. + +**An external type cref falls back to code-span-only even with a non-null +linkContext**: Verifies that a cref pointing at a type not indexed by +`Targets` (e.g. a framework type outside the documented assembly) renders +plain code-span text with no link, exactly as it did before this feature +existed, even though `linkContext` is non-null — proving the fallback is keyed +on resolution success, not merely on `linkContext`'s presence. This scenario +is tested by `XmlDocReader_GetSummary_ExternalTypeCref_FallsBackToCodeSpanOnly`. + +**A malformed cref falls back unchanged**: Verifies that a cref string that +does not match any indexed type or member identifier renders plain code-span +text with no link, matching today's fallback for unresolvable crefs. This +scenario is tested by `XmlDocReader_GetSummary_MalformedCref_FallsBackUnchanged`. + +**A constructor cref falls back unchanged (never linked)**: Verifies that a +constructor cref renders exactly as it did before this feature existed — the +bare declaring type name, with no inline code span and no link — even with a +non-null `linkContext` that would otherwise resolve the declaring type, +confirming constructors are deliberately excluded from linking because +`FormatCref` already reports `ShouldWrapInCodeSpan: false` for them, so +`TryLinkifyCref` is never reached. This scenario is tested by +`XmlDocReader_GetSummary_ConstructorCref_FallsBackUnchanged`. + +**A `` to a resolved, emitted type renders as a linked code +span**: Verifies that `` (nested inside `` text, the only +place it is ever rendered — see "Top-level `` is never rendered" in +the technical notes) shares the exact same `TryLinkifyCref` rendering path as +``, producing a Markdown link nested inside the code span when its +target resolves and is reported as emitted. This scenario is tested by +`XmlDocReader_GetRemarks_SeeAlsoResolvedEmittedType_RendersLinkedCodeSpan`. diff --git a/requirements.yaml b/requirements.yaml index be036ad..727f225 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -30,6 +30,7 @@ includes: - docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml - docs/reqstream/api-mark-dot-net/external-xml-doc-resolver.yaml - docs/reqstream/api-mark-dot-net/documentation-coverage-checker.yaml + - docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml - docs/reqstream/api-mark-msbuild.yaml - docs/reqstream/api-mark-msbuild/api-mark-task.yaml - docs/reqstream/api-mark-tool.yaml From b47b81faf37ee8458fe6e07aad75d2ec0e15af4c Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 7 Oct 2026 21:07:40 -0400 Subject: [PATCH 08/13] Fix remaining stale cref doc references flagged on PR #54 The verification checklist still claimed type-only crefs were unwrapped, and two design-doc passages still pointed at the old Member-cref inline code span wrapping section name or excluded type-only crefs from the wrap condition. These were flagged in PR #54 review comments but missed by that PR's fix commit. --- .../design/api-mark-dot-net/xml-doc-reader.md | 27 ++++++++++--------- .../api-mark-dot-net/xml-doc-reader.md | 2 +- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/design/api-mark-dot-net/xml-doc-reader.md b/docs/design/api-mark-dot-net/xml-doc-reader.md index bbddd06..8db3e21 100644 --- a/docs/design/api-mark-dot-net/xml-doc-reader.md +++ b/docs/design/api-mark-dot-net/xml-doc-reader.md @@ -344,13 +344,14 @@ according to the following element-to-text mappings: type-kind prefix, strips the namespace path to leave just the type name, and replaces generic arity markers with angle-bracket type-parameter placeholders via `FormatTypeArity`, e.g. `List\`1` → `List`,`Dictionary\`2` → `Dictionary`). - When the cref refers to a type member (property, field, event, or non-constructor - method — see "Member-cref inline code span wrapping" below), the formatted text is - additionally wrapped in an inline Markdown code span, and in that case the generic - arity placeholder is rendered with raw, unescaped angle brackets (`List`) rather - than the backslash-escaped prose form (`List\`), since a code span's content is - literal and the escaping backslash would otherwise show up as a stray visible - character — see "Generic cref escaping inside a code span" below. + Unless the cref is a constructor (`#ctor`), the formatted text is additionally + wrapped in an inline Markdown code span — this applies to type-only crefs as well + as crefs to a type member (property, field, event, or non-constructor method); see + "Member and type-only cref inline code span wrapping" below. In that case the + generic arity placeholder is rendered with raw, unescaped angle brackets + (`List`) rather than the backslash-escaped prose form (`List\`), since a + code span's content is literal and the escaping backslash would otherwise show up + as a stray visible character — see "Generic cref escaping inside a code span" below. - `` → the `langword` attribute value directly (e.g., `null`, `true`, `false`). - `` and `` → the `name` attribute @@ -402,9 +403,9 @@ display string, returning a `(string Text, bool ShouldWrapInCodeSpan)` tuple. `FormatTypeName`) and for `M:`/`P:`/`F:`/`E:` crefs that are not constructors. This single boolean is the sole source of truth `GetInlineReferenceText` uses to decide - whether to wrap the returned text in an inline code span (see "Member-cref - inline code span wrapping" below) — no cref-kind parsing is duplicated at - the call site. + whether to wrap the returned text in an inline code span (see "Member and + type-only cref inline code span wrapping" below) — no cref-kind parsing is + duplicated at the call site. **FormatMemberReference** (private): Formats a `` reference whose kind indicates a type member (`M:`, `P:`, `F:`, or `E:`) as `Type.Member` text, @@ -506,9 +507,9 @@ result. Returns `string.Empty` for whitespace-only input so existing **FormatAsInlineCodeSpan** (private static): Wraps already-formatted display text in an inline Markdown code span via `AppendMarkdownCodeSpan`. Used by -`GetInlineReferenceText` to visually distinguish a member-cref's rendered -`Type.Member` text from surrounding prose — see "Member-cref inline code span -wrapping" below. +`GetInlineReferenceText` to visually distinguish a type- or member-cref's +rendered text from surrounding prose — see "Member and type-only cref inline +code span wrapping" below. #### Cross-reference linking (`CrefLinkContext`) diff --git a/docs/verification/api-mark-dot-net/xml-doc-reader.md b/docs/verification/api-mark-dot-net/xml-doc-reader.md index 1030678..f0713c3 100644 --- a/docs/verification/api-mark-dot-net/xml-doc-reader.md +++ b/docs/verification/api-mark-dot-net/xml-doc-reader.md @@ -80,7 +80,7 @@ assembly is needed. - `GetSummary` renders a `` reference as `` `Type.Member` `` wrapped in an inline code span. - `GetSummary` renders a `` reference as `` `Type.Member` `` wrapped in an inline code span. - `GetSummary` continues to render a `` reference as `` `Type.Member` `` wrapped in an inline code span (regression guard; method crefs were unaffected by the P/F/E type-name fix, but are included in the new code-span-wrapping behavior). -- A type-only (`T:`) cref and a constructor (`#ctor`) cref render their display text unwrapped (no inline code span), distinguishing them from member crefs. +- A constructor (`#ctor`) cref renders its display text unwrapped (no inline code span). A type-only (`T:`) cref is wrapped in an inline code span the same as the `P`/`F`/`E`/`M` member crefs above (see "GetSummary wraps a plain type-only cref in a code span" in the Test Scenarios below). - `GetRemarks` renders a `
` element as a paragraph break (a blank line) between the surrounding text. - `GetSummary` collapses a `
` element to a single space, since a blank line has no meaning in a single-line context. - `GetRemarks` renders a single-line `` element as an inline backtick code span. From d53d39577bc9c0e9093d3678b562abbb54cc0b7c Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 7 Oct 2026 21:53:57 -0400 Subject: [PATCH 09/13] Fix critical cref-link nesting bug and address review findings Critical fix: GetInlineReferenceText/TryLinkifyCref were composing `[Type.Member](path.md)` (a Markdown link nested inside a code span, which CommonMark renders literally) instead of [`Type.Member`](path.md) (code span inside the link label). This made the cref cross-reference linking feature produce zero working links despite the existing tests passing, because those tests asserted the broken shape as correct. Fixed the implementation, all affected test assertions, and the design docs that described the broken nesting. Also addresses findings from the code-review and formal-review agents run across the affected review-sets: - Corrected design docs describing public XmlDocReader methods as threading CrefLinkContext directly; the actual change added internal overloads and left the public API surface unchanged. - Reworded several HOW-oriented requirement titles (method/parameter names, refactor mandates) to state only observable behavior, moving implementation detail into their justification fields. - Documented DotNetAstModelArgs (the constructor parameter-bundling record) in the DotNetAstModel design doc, including its Data Model entry and corrected constructor description. - Documented GetCandidateMembers in the DotNetEmitter design doc as the structural superset consumed by both GetVisibleMembers and DotNetEmitterGradualDisclosure.BuildMemberPageIndex. - Documented that DotNetEmitterSingleFile always uses parameterless XmlDocReader overloads so cref references never link in single-file output. - Added missing file-to-unit map entries (CrefTargetResolver, DocumentationCoverageChecker) to the DotNetGenerator design doc. - Added Acceptance Criteria and Test Scenario coverage for cref cross-reference linking to the api-mark-dot-net verification doc. - Added a "Cross-Reference Linking" subsection to the dotnet user guide describing the new linking behavior and its single-file limitation. Validation: dotnet build (0 warnings/errors); ApiMark.DotNet.Tests 436/436 passing on net8.0/net9.0/net10.0; ApiMark.Cpp.Tests 195/195 passing (with clang on PATH); fix.ps1 and lint.ps1 clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-dot-net.md | 14 +++-- .../api-mark-dot-net/dot-net-ast-model.md | 60 ++++++++++++------- .../dot-net-emitter-single-file.md | 12 +++- .../api-mark-dot-net/dot-net-emitter.md | 27 ++++++--- .../api-mark-dot-net/dot-net-generator.md | 7 +++ .../design/api-mark-dot-net/xml-doc-reader.md | 48 +++++++++------ .../api-mark-dot-net/dot-net-ast-model.yaml | 15 ++--- .../dot-net-emitter-gradual-disclosure.yaml | 45 +++++++------- .../dot-net-emitter-single-file.yaml | 19 +++--- .../api-mark-dot-net/dot-net-generator.yaml | 21 +++---- .../api-mark-dot-net/xml-doc-reader.yaml | 39 ++++++------ docs/user_guide/dotnet.md | 18 ++++++ docs/verification/api-mark-dot-net.md | 14 +++++ src/ApiMark.DotNet/XmlDocReader.cs | 35 ++++++----- .../DotNetEmitterGradualDisclosureTests.cs | 6 +- .../DotNetGeneratorTests.cs | 4 +- .../ApiMark.DotNet.Tests/XmlDocReaderTests.cs | 6 +- 17 files changed, 245 insertions(+), 145 deletions(-) diff --git a/docs/design/api-mark-dot-net.md b/docs/design/api-mark-dot-net.md index 819693a..8b4f09c 100644 --- a/docs/design/api-mark-dot-net.md +++ b/docs/design/api-mark-dot-net.md @@ -62,12 +62,16 @@ defined by the Core interfaces. The system contains eleven units: Mono.Cecil type references to produce idiomatic C# type names in output. - **XmlDocReader** — reads and indexes a .NET XML documentation file for fast member-level lookups; resolves `` references using the inheritance - chain map supplied by DotNetGenerator. Every public rendering method also accepts - an optional, trailing `CrefLinkContext?` parameter (default `null`) bundling + chain map supplied by DotNetGenerator. Every public rendering method is + internally backed by a new `internal` overload accepting an optional, + trailing `CrefLinkContext?` parameter (default `null`) bundling `CrefTargetResolver`, `TypeLinkResolver`, the member-page index, and - visibility-emitted delegates; when supplied and a ``/`` - target resolves to a symbol that will actually be emitted, the reference renders - as a real relative Markdown link instead of its default code-span-only fallback. + visibility-emitted delegates — the public overloads themselves are + unchanged, so the public API surface of `XmlDocReader` is unaffected; when + the internal overload is supplied a context and a ``/`` target resolves to a symbol that will actually be emitted, the + reference renders as a real relative Markdown link instead of its default + code-span-only fallback. - **ExternalXmlDocResolver** — locates and lazily parses the XML documentation files of externally referenced assemblies (e.g. NuGet package dependencies), caching both per-file results and per-(declaring-assembly-hint, member-ID) diff --git a/docs/design/api-mark-dot-net/dot-net-ast-model.md b/docs/design/api-mark-dot-net/dot-net-ast-model.md index be49704..67c2e75 100644 --- a/docs/design/api-mark-dot-net/dot-net-ast-model.md +++ b/docs/design/api-mark-dot-net/dot-net-ast-model.md @@ -7,19 +7,24 @@ ### Purpose -DotNetAstModel is an immutable data class that holds all parsed .NET assembly -data required during the emit phase. It is created exclusively by +DotNetAstModel is a data class that holds all parsed .NET assembly data +required during the emit phase. It is created exclusively by `DotNetGenerator.Parse` and transferred to `DotNetEmitter`. All properties are -read-only after construction and the collections they expose use read-only -interfaces (`IReadOnlyList`, `IReadOnlyDictionary`), making the model's entire -observable state immutable after construction. The emitter can safely share the -model across its internal helper methods without defensive copies. - -The three context records defined in the same file — `TypePageWriteContext`, -`MethodDocContext`, and `NamespaceDocContext` — reduce parameter counts on the -helper methods by bundling constant values that are threaded through multiple -call levels. A fourth record, `NamespaceDescription`, carries the summary, -remarks, and example parts extracted from a `NamespaceDoc` carrier. +read-only — set either at construction or, for `MemberPageIndex`/ +`EmittedTypeIds`, once via `SetMemberPageIndex` shortly after construction +(see Key Methods below) — and the collections they expose use read-only +interfaces (`IReadOnlyList`, `IReadOnlyDictionary`), so no caller can mutate +the model's collections directly. The emitter can safely share the model +across its internal helper methods without defensive copies. + +The four context records defined in the same file — `TypePageWriteContext`, +`MethodDocContext`, `NamespaceDocContext`, and `NamespaceDescription` — reduce +parameter counts on the helper methods by bundling constant values that are +threaded through multiple call levels: the first three bundle per-page-type +writing context (`NamespaceDescription` carries the summary, remarks, and +example parts extracted from a `NamespaceDoc` carrier). A fifth record, +`DotNetAstModelArgs`, bundles the `DotNetAstModel` constructor's own +parameters (see Key Methods below). ### Data Model @@ -115,18 +120,31 @@ surface on namespace output the same way they do for a type. - *ExampleParts* (`IReadOnlyList<(bool IsCode, string Content)>`): Structured example parts, each flagged as code or prose; empty when no `` is present. +**DotNetAstModelArgs** (internal sealed record): Bundles the `DotNetAstModel` +constructor's own parameters into a single value, so the constructor takes one +parameter instead of nine and the DotNetAstModelArgs record's own XML doc +comments remain the authoritative per-field description. + +- *Assembly* (`AssemblyDefinition`), *AssemblyResolver* (`IAssemblyResolver`), + *XmlDocs* (`XmlDocReader`), *AllNamespaces* (`IReadOnlyList`), + *ByNamespace* (`IReadOnlyDictionary>`), + *RootNamespaces* (`IReadOnlyList`), + *NamespaceDescriptions* (`IReadOnlyDictionary`), + *Resolver* (`TypeLinkResolver`), *Options* (`DotNetGeneratorOptions`) — one + field per constructor parameter of `DotNetAstModel`, described above under + the `DotNetAstModel` entry. + ### Key Methods -**DotNetAstModel constructor**: Accepts all parsed data and stores it in -read-only properties. +**DotNetAstModel constructor**: Accepts a `DotNetAstModelArgs` bundling all +parsed data and stores each field in a read-only property. -- *Parameters*: `AssemblyDefinition assembly`, `IAssemblyResolver assemblyResolver`, - `XmlDocReader xmlDocs`, `IReadOnlyList allNamespaces`, - `IReadOnlyDictionary> byNamespace`, - `IReadOnlyList rootNamespaces`, `IReadOnlyDictionary namespaceDescriptions`, - `TypeLinkResolver resolver`, `DotNetGeneratorOptions options`. -- *Preconditions*: No parameter may be null. -- *Postconditions*: All properties are initialized; no mutation is possible. +- *Parameters*: `DotNetAstModelArgs args`. +- *Preconditions*: No field of `args` may be null. +- *Postconditions*: All properties are initialized from `args`; no further + mutation of the assigned properties is possible, aside from the deferred + `MemberPageIndex`/`EmittedTypeIds` population performed by + `SetMemberPageIndex` below. **SetMemberPageIndex**: Sets `MemberPageIndex` and `EmittedTypeIds` after construction. Called exactly once, by `DotNetGenerator.Parse`, immediately diff --git a/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md b/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md index 5ffd0bb..1e09cd4 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md @@ -47,7 +47,13 @@ into that writer at heading levels `HeadingDepth` (assembly title), appendix are emitted. - A `TypeLinkResolver` with `generateLinks: false` is used so that parameter type cells contain plain text rather than relative file links that are - meaningless inside a single document. + meaningless inside a single document. For the same reason, every + `ctx.XmlDocs.Get*` call in this class uses the parameterless overload, so + the optional `CrefLinkContext?` parameter always defaults to `null`: a + ``/`` reference always renders as code-span-only + text in single-file output, never as a Markdown link, since relative + anchors/links are just as meaningless inside a single flat document as the + relative file links the `generateLinks: false` resolver already avoids. **WriteSingleFileTypeSections** (private): Writes all content for a single type — type heading, signature code block, summary, remarks, example, compact member @@ -105,7 +111,9 @@ this class. - **TypeLinkResolver** — constructed with `generateLinks: false` to produce plain-text type names without Markdown link generation; used when rendering parameter type cells in single-file output. -- **XmlDocReader** — used to retrieve documentation text for each member. +- **XmlDocReader** — used to retrieve documentation text for each member; + every call site uses the parameterless overloads (no `CrefLinkContext`), so + ``/`` references never link in single-file output. - **IMarkdownWriterFactory** — received from `DotNetEmitter.Emit`. ### Callers diff --git a/docs/design/api-mark-dot-net/dot-net-emitter.md b/docs/design/api-mark-dot-net/dot-net-emitter.md index a6b4742..180b368 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter.md @@ -93,13 +93,20 @@ These helpers are grouped by concern: `SplitPath`: compute namespace folder paths and enumerate direct child namespaces. - *Visibility filters* — `IsTypeVisible`, `GetVisibleNestedTypes`, `IsMemberVisible`, `IsMemberPublic`, `IsMemberPublicOrProtected`, `IsPropertyPublicOrProtected`, - `GetVisibleMembers`, `ShouldIncludeMember`: determine which types and members are - included based on the configured visibility level and `IncludeObsolete` flag. - `GetVisibleNestedTypes` always excludes compiler-generated nested types (the - cached-lambda class, closures/display classes, async/iterator state machines, - etc.), even at `ApiVisibility.All`: they carry no source-level documentation, - and several of their names contain characters (`<`, `>`, `|`) that are invalid - in Windows file paths, which would otherwise break output generation. + `GetCandidateMembers`, `GetVisibleMembers`, `ShouldIncludeMember`: determine + which types and members are included based on the configured visibility level + and `IncludeObsolete` flag. `GetVisibleNestedTypes` always excludes + compiler-generated nested types (the cached-lambda class, closures/display + classes, async/iterator state machines, etc.), even at `ApiVisibility.All`: + they carry no source-level documentation, and several of their names contain + characters (`<`, `>`, `|`) that are invalid in Windows file paths, which would + otherwise break output generation. `GetCandidateMembers` returns the + structurally eligible superset of a type's methods, properties, fields, and + events — before any visibility/obsolete policy is applied — and is itself + consumed both by `GetVisibleMembers` (`GetVisibleMembers(type) => + GetCandidateMembers(type).Where(ShouldIncludeMember)`) and directly by + `DotNetEmitterGradualDisclosure.BuildMemberPageIndex`, which needs the same + structural superset to build its cref-linking member-page index. `GetVisibleMembers` likewise excludes compiler-generated properties — most notably a record's synthesized `EqualityContract` property (used by the generated `Equals` to distinguish types in an inheritance hierarchy), which @@ -298,8 +305,10 @@ of success or failure — see the known single-use limitation noted under the XML-doc member ID for each NamespaceDoc carrier's summary lookup). - **DotNetEmitterGradualDisclosure** — holds a `_emitter` reference and calls shared static helpers (`BuildTypeSignature`, `GetNamespaceFolderPath`, - `GetMemberDisplayName`, `FlattenArity`, and others) throughout gradual-disclosure - emission. + `GetMemberDisplayName`, `FlattenArity`, `GetCandidateMembers`, and others) + throughout gradual-disclosure emission; `GetCandidateMembers` is called + directly by `BuildMemberPageIndex` to obtain the same structurally eligible + member superset used by `GetVisibleMembers`. - **DotNetEmitterSingleFile** — holds a `_emitter` reference and calls shared static helpers (`BuildTypeSignature`, `GetNamespaceFolderPath`, `GetMemberDisplayName`, and others) throughout single-file emission. 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 f7a7628..6a64ff7 100644 --- a/docs/design/api-mark-dot-net/dot-net-generator.md +++ b/docs/design/api-mark-dot-net/dot-net-generator.md @@ -37,6 +37,13 @@ for format selection and Markdown writing. The split across implementation units - **ExternalXmlDocResolver.cs** — locates and lazily parses the XML documentation files of externally referenced assemblies for cross-assembly `` resolution. See ExternalXmlDocResolver Design for full details. +- **CrefTargetResolver.cs** — indexes every type and member declared in the + assembly, keyed by XML-doc identifier, so a raw `cref` attribute string can be + resolved to the Mono.Cecil symbol it names. See CrefTargetResolver Design for + full details. +- **DocumentationCoverageChecker.cs** — checks parsed types and members against + the configured documentation-coverage enforcement level, reporting + undocumented items. See DocumentationCoverageChecker Design for full details. ### Data Model diff --git a/docs/design/api-mark-dot-net/xml-doc-reader.md b/docs/design/api-mark-dot-net/xml-doc-reader.md index 8db3e21..b268200 100644 --- a/docs/design/api-mark-dot-net/xml-doc-reader.md +++ b/docs/design/api-mark-dot-net/xml-doc-reader.md @@ -45,7 +45,8 @@ partial-class splits or tooling bugs). **CrefLinkContext** (external `internal sealed record`, defined in `CrefLinkContext.cs`, not a field of `XmlDocReader` itself): Optional cross-reference linking context, threaded as a trailing nullable parameter -through every public rendering method — see "Cross-reference linking +through new internal overloads of the rendering methods, reached internally +from every public rendering method — see "Cross-reference linking (CrefLinkContext)" below for its full field list and semantics. ### Key Methods @@ -513,11 +514,15 @@ code span wrapping" below. #### Cross-reference linking (`CrefLinkContext`) -Every public `XmlDocReader` method (`GetSummary`, `GetSummaryMarkdown`, -`GetRemarks`, `GetParams`, `GetReturns`, `GetExample`, `GetExampleParts` — -explicitly NOT `GetExceptions`/`GetExceptionDetails`, which are out of scope -for linking) accepts an additional optional, trailing, nullable -`CrefLinkContext? linkContext = null` parameter. This parameter is threaded, +Every public rendering method (`GetSummary`, `GetSummaryMarkdown`, +`GetRemarks`, `GetParams`, `GetReturns`, `GetExampleParts` — explicitly NOT +`GetExceptions`/`GetExceptionDetails`, which are out of scope for linking) +gains a new `internal` overload accepting an additional optional, trailing, +nullable `CrefLinkContext? linkContext = null` parameter. The original public +single-argument overloads are unchanged and simply forward to the new +internal overload with `linkContext: null`, so the public API surface of +`XmlDocReader` is completely unaffected by this feature (`GetExample` has no +such overload — see its own entry above). This parameter is threaded, unchanged, down through the entire private rendering chain: `GetDocumentationText`/`GetSingleLineDocumentationText` → `AppendNodeText` (both overloads) → `AppendElementText`/`AppendListText` → @@ -545,25 +550,27 @@ so `XmlDocReader` does not take on a hard dependency on the emitter/model layer merely to support cref linking. **TryLinkifyCref** (private static): Attempts to resolve a `cref` string -against `linkContext` and, on success, returns the display text wrapped in a -real Markdown link; otherwise returns the display text unchanged. +against `linkContext` and, on success, returns the already code-span-wrapped +display text wrapped in a real Markdown link, with the code span as the +link's label; otherwise returns the code-span text unchanged. - *Parameters*: `string cref` — the raw `cref` attribute value, including its - kind prefix; `string displayText` — the already-formatted display text (from - `FormatCref`) to wrap when linking; `CrefLinkContext? linkContext`. -- *Algorithm*: Returns `displayText` unchanged immediately when `linkContext` + kind prefix; `string codeSpanText` — the already backtick-wrapped display + text (produced by `FormatAsInlineCodeSpan` from `FormatCref`'s result) to + use as the link label when linking; `CrefLinkContext? linkContext`. +- *Algorithm*: Returns `codeSpanText` unchanged immediately when `linkContext` is `null` (this is how single-file mode opts out of linking entirely — `DotNetEmitterSingleFile` never constructs or passes a `CrefLinkContext`, so every call site there always resolves this branch). Otherwise tries `linkContext.Targets.TryResolveType` first; if it succeeds AND `linkContext.IsTypeEmitted(type)` is `true`, returns - `linkContext.Resolver.LinkifyResolvedType(type, displayText, + `linkContext.Resolver.LinkifyResolvedType(type, codeSpanText, linkContext.CurrentFolder)`. Otherwise tries `linkContext.Targets.TryResolveMember`; if it succeeds AND `linkContext.IsMemberEmitted(member)` is `true`, returns - `linkContext.Resolver.LinkifyResolvedMember(member, displayText, + `linkContext.Resolver.LinkifyResolvedMember(member, codeSpanText, linkContext.CurrentFolder, linkContext.MemberPageIndex)`. Falls back to - `displayText` unchanged in every other case — external type, filtered-out + `codeSpanText` unchanged in every other case — external type, filtered-out member, or malformed/unresolvable cref — identical to today's rendering. - *Call site*: `GetInlineReferenceText`'s ``/`` cref branch calls `TryLinkifyCref` only when `FormatCref` already reported @@ -571,11 +578,14 @@ real Markdown link; otherwise returns the display text unchanged. already eligible for code-span wrapping before this feature existed — constructor crefs are excluded by construction, since they report `ShouldWrapInCodeSpan: false` and short-circuit before `TryLinkifyCref` is - ever called). The returned text (linked or not) is then wrapped in the code - span exactly where the current code would have wrapped plain text, so a - successful link renders as `` `[Type.Member](path.md)` `` — a Markdown link - nested inside the code span — while an unresolved cref renders exactly as - it does today, `` `Type.Member` ``. + ever called). The code span is built first via `FormatAsInlineCodeSpan`, + and that code-span string is then passed into `TryLinkifyCref` as the + link's label, so a successful link renders as `` [`Type.Member`](path.md) `` + — the code span nested inside the link's label — while an unresolved cref + renders exactly as it does today, `` `Type.Member` ``. The nesting is + deliberately link-outside/code-span-inside rather than the reverse: Markdown + code-span content is rendered literally, so a link placed inside a code span + would show as inert `[...](...)` text rather than a clickable link. ### Error Handling diff --git a/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml b/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml index 99962d6..2ecce03 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-ast-model.yaml @@ -24,12 +24,10 @@ sections: - DotNetAstModel_NamespaceDescriptions_AfterParse_IsNotNull - DotNetAstModel_Collections_ExposeReadOnlyInterfaces - id: ApiMarkDotNet-DotNetAstModel-HoldCrefLinkingIndices - title: DotNetAstModel shall expose a `CrefTargetResolver` (`CrefTargets`) - built eagerly during construction, and a member-to-page index - (`MemberPageIndex`) plus a set of emitted type identifiers - (`EmittedTypeIds`) populated via `SetMemberPageIndex` once the owning - `DotNetEmitter` has been constructed, so that cross-reference linking - can resolve a ``/`` target to a real page. + title: DotNetAstModel shall expose a `CrefTargetResolver` (`CrefTargets`), + a member-to-page index (`MemberPageIndex`), and a set of emitted type + identifiers (`EmittedTypeIds`), so that cross-reference linking can + resolve a ``/`` target to a real page. justification: | Full cross-reference linking requires knowing, for a given cref string, both whether a symbol with that XML-doc ID exists in this @@ -37,7 +35,10 @@ sections: actually be emitted as a page under the active visibility settings (`MemberPageIndex`/`EmittedTypeIds`, which depend on `DotNetEmitter` instance methods and therefore cannot be computed in the model's own - constructor). + constructor). `CrefTargets` is built eagerly during construction, since + it needs only the assembly; `MemberPageIndex`/`EmittedTypeIds` are + populated via `SetMemberPageIndex` once the owning `DotNetEmitter` has + been constructed. tests: - DotNetAstModel_CrefTargets_AfterParse_ResolvesKnownFixtureType - DotNetAstModel_MemberPageIndexAndEmittedTypeIds_AfterGradualDisclosureEmit_ArePopulated diff --git a/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml index 3231482..a19c356 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml @@ -147,37 +147,40 @@ sections: - DotNetEmitterGradualDisclosure_Emit_MemberWithNeitherSummaryNorRemarks_StillShowsPlaceholder - DotNetEmitterGradualDisclosure_Emit_TypeWithRemarksOnly_SuppressesPlaceholderAndShowsRemarksOnTypePage - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-ExtractBuildMemberPageIndex - title: DotNetEmitterGradualDisclosure shall expose an internal static - BuildMemberPageIndex function that reproduces WriteMembersSection's - exact member-grouping decisions (overload grouping, operator grouping, - case-collision grouping, single-member fallback) without writing any - Markdown, and WriteMembersSection shall itself call this same function - for its grouping decisions so there is a single source of truth, with - no change to any existing generated output. + title: DotNetEmitterGradualDisclosure shall assign every member to the + same gradual-disclosure page whether the page assignment is queried + before emission (for cross-reference linking) or during emission (for + writing). justification: | Full / cross-reference linking needs to know, for every member in the assembly, the exact gradual-disclosure page path it will be written to — before any page is written. Re-deriving the - grouping logic a second time, even if initially equivalent, would create - two sources of truth that could silently drift apart on a future edit to - only one of them. Extracting a pure, independently testable function and - having WriteMembersSection itself delegate to it removes that risk by - construction. This refactor is required to be fully behavior-preserving. + grouping logic (overload grouping, operator grouping, case-collision + grouping, single-member fallback) a second time, even if initially + equivalent, would create two sources of truth that could silently drift + apart on a future edit to only one of them. This is satisfied by an + internal static BuildMemberPageIndex function that reproduces + WriteMembersSection's exact member-grouping decisions without writing + any Markdown, verified against WriteMembersSection's actual output by a + dedicated integration test, with no change to any existing generated + output. tests: - BuildMemberPageIndex_AllEntries_MatchActualWrittenPages - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-WireCrefLinkContextThroughPageWriters - title: DotNetEmitterGradualDisclosure shall construct a CrefLinkContext - (bound to the model's CrefTargets/MemberPageIndex/Resolver and this - emitter's own visibility delegates) and pass it as the trailing - argument to every ctx.XmlDocs.Get* call it makes, except - GetExceptions/GetExceptionDetails, so that a resolvable ``/`` reference renders as a real relative Markdown - link instead of the plain code-span fallback. + title: DotNetEmitterGradualDisclosure shall render a resolvable ``/`` target as a real Markdown link in generated + member and type pages, for every eligible documentation element except + exception type/description text. justification: | Code-span-wrapped plain text for every cref is strictly less useful than a real clickable cross-reference whenever the target is actually present - in the generated documentation. Constructing the CrefLinkContext once per - type-processing pass (re-scoped per folder via the record `with` + in the generated documentation. This is satisfied by constructing a + CrefLinkContext (bound to the model's + CrefTargets/MemberPageIndex/Resolver and this emitter's own visibility + delegates) and passing it as the trailing argument to every + ctx.XmlDocs.Get* call made while writing a page, except + GetExceptions/GetExceptionDetails. Constructing the CrefLinkContext once + per type-processing pass (re-scoped per folder via the record `with` expression where a page writer executes at a deeper folder than its caller) avoids rebuilding it freshly at every call site while keeping page-writer methods free of a direct emitter-instance dependency, diff --git a/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml b/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml index d9487fd..ce981a4 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml @@ -130,20 +130,19 @@ sections: tests: - DotNetEmitterSingleFile_Emit_MethodWithParameter_TypeCellIsPlainText - id: ApiMarkDotNet-DotNetEmitterSingleFile-NeverLinkCrefReferences - title: DotNetEmitterSingleFile shall never construct or pass a - CrefLinkContext to any XmlDocReader call site, so that a ``/`` reference in single-file output continues to - render exactly as it did before cross-reference linking existed — - code-span-only, never a Markdown link — regardless of whether the - cref's target would otherwise be resolvable and emitted. + title: DotNetEmitterSingleFile shall render every ``/`` reference as code-span-only text, never as a Markdown link, + regardless of whether the cref's target would otherwise be resolvable + and emitted. justification: | Relative Markdown links are meaningless inside a single document, the same reason parameter type cells are already rendered as plain text rather than links (see EmitPlainTextParameterTypes above). Full ``/`` cross-reference linking is explicitly out of - scope for single-file mode; leaving every XmlDocReader call site in this - class completely unmodified, so the new optional CrefLinkContext - parameter always defaults to null, is simpler and safer than adding an - explicit opt-out branch. + scope for single-file mode. This is satisfied by leaving every + XmlDocReader call site in this class completely unmodified — never + constructing or passing a CrefLinkContext — so the new optional + CrefLinkContext parameter always defaults to null; this is simpler and + safer than adding an explicit opt-out branch. tests: - CrefLinking_SingleFileMode_SeeCrefToVisibleType_RemainsCodeSpanOnly 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 8739c7a..ba43c83 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml @@ -212,13 +212,9 @@ sections: tests: - DotNetGenerator_Parse_WithLibraryDescription_PropagatesToModelOptions - id: ApiMarkDotNet-DotNetGenerator-BuildCrefLinkingIndicesAfterEmitterConstruction - title: DotNetGenerator.Parse shall, after constructing the DotNetEmitter, - populate DotNetAstModel's MemberPageIndex and EmittedTypeIds by - recursively walking every visible top-level and nested type using the - emitter's own visibility rules, merging each type's - DotNetEmitterGradualDisclosure.BuildMemberPageIndex result into a - single assembly-wide index, without introducing a second construction - pass over DotNetAstModel/DotNetEmitter. + title: DotNetGenerator shall ensure every visible type and member in the + parsed assembly is present in the model's cref link index before + DotNetGenerator.Parse returns. justification: | Full / cross-reference linking requires knowing, for every member, the exact gradual-disclosure page path it will be @@ -230,9 +226,14 @@ sections: computed after the emitter exists, unlike CrefTargetResolver (built eagerly by DotNetAstModel's own constructor since it needs only the assembly). Populating it via a post-construction setup call on the - already-returned model, rather than restructuring Parse's single-pass - flow, keeps the existing Parse -> new DotNetEmitter(model) -> return - emitter contract intact. + already-returned model (DotNetGenerator.Parse, after constructing the + DotNetEmitter, recursively walks every visible top-level and nested + type using the emitter's own visibility rules, merging each type's + DotNetEmitterGradualDisclosure.BuildMemberPageIndex result into a + single assembly-wide index), rather than restructuring Parse's + single-pass flow, keeps the existing Parse -> new DotNetEmitter(model) + -> return emitter contract intact and avoids a second construction + pass over DotNetAstModel/DotNetEmitter. tests: - DotNetAstModel_MemberPageIndexAndEmittedTypeIds_AfterGradualDisclosureEmit_ArePopulated - DotNetGenerator_Generate_GradualDisclosure_EveryGeneratedLinkResolvesToExistingPage diff --git a/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml b/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml index ec5723a..27f6b28 100644 --- a/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml +++ b/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml @@ -247,27 +247,30 @@ sections: - XmlDocReader_GetExampleParts_WithMixedInlineElements_ProseAccumulatedAsOnePart - DotNetGenerator_Generate_InlineReferences_PreserveSymbolNamesInMarkdown - id: ApiMarkDotNet-XmlDocReader-LinkifyResolvedCrefReferences - title: XmlDocReader shall accept an optional, trailing, nullable - CrefLinkContext parameter on every public rendering method (except - GetExceptions/GetExceptionDetails) and, when supplied and the cref - target resolves to an intra-assembly type or member that will actually - be emitted, render the cref's existing code-span display text as a - real relative Markdown link to that target's page; otherwise it shall - continue rendering exactly as before (code-span text, no link) for - external types, filtered-out members, malformed crefs, and - constructors, and single-file output shall be entirely unaffected - because it never supplies a CrefLinkContext. + title: XmlDocReader shall render a ``/`` + reference as a real relative Markdown link to the target's page when + linking context is supplied and the target resolves to an + intra-assembly type or member that will actually be emitted; + otherwise it shall continue rendering exactly as before (code-span + text, no link) for external types, filtered-out members, malformed + crefs, and constructors, and single-file output shall be entirely + unaffected because it never supplies linking context. justification: | Code-span-wrapped plain text for every cref (the PR #54 fallback) is strictly less useful than a real clickable cross-reference whenever the - target is actually present in the generated documentation. Threading an - optional CrefLinkContext through the existing rendering chain, rather - than branching the whole chain on a new mode flag, keeps the ~100 - existing call sites (and the single-file emitter, which never opts in) - compiling and behaving identically by construction, while letting the - gradual-disclosure emitter opt in with no duplicated rendering logic. - Constructors are excluded because FormatCref already reports - ShouldWrapInCodeSpan: false for them, so no new special case is needed. + target is actually present in the generated documentation. This is + satisfied by accepting an optional, trailing, nullable CrefLinkContext + parameter on every public rendering method (except + GetExceptions/GetExceptionDetails, which are excluded because + exception type/description text is never cref-rendered in the first + place). Threading an optional CrefLinkContext through the existing + rendering chain, rather than branching the whole chain on a new mode + flag, keeps the ~100 existing call sites (and the single-file emitter, + which never opts in) compiling and behaving identically by + construction, while letting the gradual-disclosure emitter opt in with + no duplicated rendering logic. Constructors are excluded because + FormatCref already reports ShouldWrapInCodeSpan: false for them, so no + new special case is needed. tests: - XmlDocReader_GetSummary_NullLinkContext_RendersCodeSpanOnlyNoLink - XmlDocReader_GetSummary_ResolvedEmittedType_RendersLinkedCodeSpan diff --git a/docs/user_guide/dotnet.md b/docs/user_guide/dotnet.md index 9b3f4f0..67ed026 100644 --- a/docs/user_guide/dotnet.md +++ b/docs/user_guide/dotnet.md @@ -156,6 +156,24 @@ populated automatically from the project's resolved `@(ReferencePath)` items (see the *MSBuild Properties* section below), so most projects do not need to set it explicitly. +### Cross-Reference Linking + +A `` or `` reference to a type or member +declared in the assembly being documented renders as a real relative Markdown +link to that target's own page, in addition to the existing code-span +styling, whenever the target will actually be emitted under the active +visibility and obsolete-filter settings. A reference whose target is external +to the assembly, filtered out of the generated documentation, malformed, or a +constructor continues to render as plain code-span text with no link, exactly +as it did before this capability existed. + +This capability applies only to gradual-disclosure output (`--format gradual`, +the default), where each type and member has its own page to link to. In +single-file output (`--format single-file`), every ``/`` reference always renders as code-span-only text, since relative links +are meaningless inside a single flat document — the same reason parameter +type cells are also rendered as plain text in that mode. + ## Documentation Coverage Enforcement ApiMark can enforce that your public API surface (or a broader visibility tier) diff --git a/docs/verification/api-mark-dot-net.md b/docs/verification/api-mark-dot-net.md index 48f9019..2c3a93d 100644 --- a/docs/verification/api-mark-dot-net.md +++ b/docs/verification/api-mark-dot-net.md @@ -27,6 +27,7 @@ service, network dependency, or machine-specific configuration is required. - Documentation coverage enforcement correctly identifies undocumented types and members at the configured enforcement visibility tier, independent of the emission visibility tier. - Cross-assembly `` resolution correctly resolves `` targets defined in externally referenced assemblies (e.g. NuGet package dependencies) via `ExternalXmlDocResolver`, including graceful degradation when a referenced assembly's XML documentation is absent or corrupt. +- Cross-reference linking correctly renders a resolvable ``/`` target as a real relative Markdown link to that target's page in gradual-disclosure output, and leaves single-file output entirely unaffected. ## Test Scenarios @@ -67,3 +68,16 @@ documentation file when `DotNetGeneratorOptions.ReferencePaths` is configured. S `docs/verification/api-mark-dot-net/external-xml-doc-resolver.md` for the unit-level scenarios covering lookup, caching, the `ref/`/`lib/` fallback, and graceful degradation on missing/corrupt documentation. + +**Cross-reference linking resolves visible crefs to real Markdown links**: Verifies +that a ``/`` reference to a type or member that is declared +in the documented assembly and will actually be emitted renders as a real relative +Markdown link to that target's page in gradual-disclosure output, while an +external, filtered-out, malformed, or constructor cref continues to render as +code-span-only text, and single-file output never links a cref regardless of +resolvability. See `docs/verification/api-mark-dot-net/cref-target-resolver.md` +and `docs/verification/api-mark-dot-net/type-link-resolver.md` for the unit-level +resolution/link-building scenarios, and the `CrefLinking_*` scenarios in +`docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md` and +`docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md` for the +end-to-end emission scenarios. diff --git a/src/ApiMark.DotNet/XmlDocReader.cs b/src/ApiMark.DotNet/XmlDocReader.cs index 88953b5..a8a8f63 100644 --- a/src/ApiMark.DotNet/XmlDocReader.cs +++ b/src/ApiMark.DotNet/XmlDocReader.cs @@ -1418,10 +1418,12 @@ private static int ComputeFenceLength(string content) /// / /// — to a symbol declared in the assembly being documented that will actually be emitted /// (per /), - /// the formatted display text is wrapped in a real relative Markdown link (via + /// the code-span-wrapped text is itself wrapped in a real relative Markdown link (via /// /) - /// before being wrapped in the code span, in exactly the position the plain text would - /// otherwise have been wrapped. In every other case — is + /// — the code span becomes the link's label (e.g. [`Type.Member`](path.md)), never + /// the other way around, since Markdown code span content is rendered literally and a link + /// nested inside one would show as inert [...](...) text rather than a clickable + /// link. In every other case — is /// , the cref is external/malformed/unresolvable, or the resolved /// symbol is filtered out of the generated documentation — rendering is completely /// unchanged from the code-span-only fallback described above. A constructor cref (whose @@ -1464,8 +1466,11 @@ private static string GetInlineReferenceText(XElement element, CrefLinkContext? return text; } - var linkedText = TryLinkifyCref(cref, text, linkContext); - return FormatAsInlineCodeSpan(linkedText); + // The code span must be the link's label, not the other way around — Markdown code + // span content is literal, so a link nested inside a code span would render as inert + // text showing the raw "[...](...)" syntax rather than a clickable link. + var codeSpanText = FormatAsInlineCodeSpan(text); + return TryLinkifyCref(cref, codeSpanText, linkContext); } return string.Empty; @@ -1474,32 +1479,32 @@ private static string GetInlineReferenceText(XElement element, CrefLinkContext? /// /// Attempts to resolve against and, /// when resolution succeeds and the target will actually be emitted, returns - /// wrapped in a real relative Markdown link. Returns - /// unchanged in every other case (no context, external - /// type, filtered-out member, or malformed cref). + /// wrapped in a real relative Markdown link, with the code + /// span as the link's label. Returns unchanged in every + /// other case (no context, external type, filtered-out member, or malformed cref). /// /// The raw cref attribute value, including its kind prefix. - /// The already-formatted display text to wrap when linking. + /// The already backtick-wrapped display text to use as the link label when linking. /// Optional cross-reference linking context. - /// , or that text wrapped in a Markdown link. - private static string TryLinkifyCref(string cref, string displayText, CrefLinkContext? linkContext) + /// , or that text wrapped in a Markdown link. + private static string TryLinkifyCref(string cref, string codeSpanText, CrefLinkContext? linkContext) { if (linkContext == null) { - return displayText; + return codeSpanText; } if (linkContext.Targets.TryResolveType(cref, out var type) && linkContext.IsTypeEmitted(type)) { - return linkContext.Resolver.LinkifyResolvedType(type, displayText, linkContext.CurrentFolder); + return linkContext.Resolver.LinkifyResolvedType(type, codeSpanText, linkContext.CurrentFolder); } if (linkContext.Targets.TryResolveMember(cref, out var member) && linkContext.IsMemberEmitted(member)) { - return linkContext.Resolver.LinkifyResolvedMember(member, displayText, linkContext.CurrentFolder, linkContext.MemberPageIndex); + return linkContext.Resolver.LinkifyResolvedMember(member, codeSpanText, linkContext.CurrentFolder, linkContext.MemberPageIndex); } - return displayText; + return codeSpanText; } /// diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs index 9b2832d..ff05293 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs @@ -544,7 +544,7 @@ public void CrefLinking_SeeCrefToVisibleType_RendersAsMarkdownLink() var paragraphs = memberWriter.Operations.OfType().Select(p => p.Text).ToList(); Assert.Contains( paragraphs, - p => p.Contains("`[SampleClass](../SampleClass.md)`", StringComparison.Ordinal)); + p => p.Contains("[`SampleClass`](../SampleClass.md)", StringComparison.Ordinal)); } /// @@ -566,7 +566,7 @@ public void CrefLinking_SeeCrefToVisibleMember_RendersAsMarkdownLink() var paragraphs = memberWriter.Operations.OfType().Select(p => p.Text).ToList(); Assert.Contains( paragraphs, - p => p.Contains("`[SampleClass.Reset](../SampleClass/Reset.md)`", StringComparison.Ordinal)); + p => p.Contains("[`SampleClass.Reset`](../SampleClass/Reset.md)", StringComparison.Ordinal)); } /// @@ -653,6 +653,6 @@ public void CrefLinking_SeeAlsoCrefToVisibleType_RendersAsMarkdownLink() // renders as a real Markdown link on the CrefLinkingClass type page var typeWriter = factory.Writers["ApiMark.DotNet.Fixtures/CrefLinkingClass"]; var paragraphs = typeWriter.Operations.OfType().Select(p => p.Text).ToList(); - Assert.Contains(paragraphs, p => p.Contains("`[SampleClass](SampleClass.md)`", StringComparison.Ordinal)); + Assert.Contains(paragraphs, p => p.Contains("[`SampleClass`](SampleClass.md)", StringComparison.Ordinal)); } } diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index 646dc19..ada3634 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -551,7 +551,7 @@ public void DotNetGenerator_Generate_InlineReferences_PreserveSymbolNamesInMarkd // code-span-wrapped), per the cref-cross-reference-linking feature. var typeWriter = factory.Writers["ApiMark.DotNet.Fixtures/SampleStatusExtensions"]; var typeParagraphs = typeWriter.Operations.OfType().Select(p => p.Text).ToList(); - Assert.Contains("Extensions for the `[SampleStatus](SampleStatus.md)` enum.", typeParagraphs); + Assert.Contains("Extensions for the [`SampleStatus`](SampleStatus.md) enum.", typeParagraphs); var memberWriters = factory.Writers .Where(kvp => kvp.Key.StartsWith("ApiMark.DotNet.Fixtures/SampleStatusExtensions/IsPassed", StringComparison.Ordinal)) @@ -560,7 +560,7 @@ public void DotNetGenerator_Generate_InlineReferences_PreserveSymbolNamesInMarkd Assert.Contains( memberWriters, writer => writer.Operations.OfType() - .Any(p => p.Text == "Returns true when status is `[SampleStatus.Active](../SampleStatus/Active.md)` or `[SampleStatus.Pending](../SampleStatus/Pending.md)`.")); + .Any(p => p.Text == "Returns true when status is [`SampleStatus.Active`](../SampleStatus/Active.md) or [`SampleStatus.Pending`](../SampleStatus/Pending.md).")); } /// Validates that static types render a static class signature. diff --git a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs index 9a1d57d..27b967a 100644 --- a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs +++ b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs @@ -3435,7 +3435,7 @@ public void XmlDocReader_GetSummary_ResolvedEmittedType_RendersLinkedCodeSpan() var summary = reader.GetSummary("T:Foo.Bar", context); // Assert - Assert.Equal("See `[SampleClass](SampleClass.md)`.", summary); + Assert.Equal("See [`SampleClass`](SampleClass.md).", summary); } finally { @@ -3477,7 +3477,7 @@ public void XmlDocReader_GetSummary_ResolvedEmittedMember_RendersLinkedCodeSpan( var summary = reader.GetSummary("T:Foo.Bar", context); // Assert - Assert.Equal("See `[SampleClass.Reset](SampleClass/Reset.md)`.", summary); + Assert.Equal("See [`SampleClass.Reset`](SampleClass/Reset.md).", summary); } finally { @@ -3671,7 +3671,7 @@ public void XmlDocReader_GetRemarks_SeeAlsoResolvedEmittedType_RendersLinkedCode var remarks = reader.GetRemarks("T:Foo.Bar", context); // Assert - Assert.Contains("`[SampleClass](SampleClass.md)`", remarks); + Assert.Contains("[`SampleClass`](SampleClass.md)", remarks); } finally { From 868b39ba4e40ad8ec80e7e43d90f1fd980cd27b3 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 8 Oct 2026 00:24:35 -0400 Subject: [PATCH 10/13] Fix GitHub AI review findings and formal-review follow-ups for cref linking GitHub AI review fixes: - Extract shared GroupMembersByFileName/GetOrderedOverloads/ DecideGroupPageFileName helpers in DotNetEmitterGradualDisclosure so the cref-link index builder and the page writer can no longer silently drift out of sync on member grouping/overload-ordering/page-naming decisions. - Apply CrefLinkContext to namespace-level documentation: NamespaceDescription now stores the winning carrier's summary/remarks/example member IDs instead of pre-rendered text, deferring rendering to WriteNamespacePage once a folder-scoped CrefLinkContext is available, so / in a NamespaceDoc carrier's remarks/summary/example now link like type/member summaries do. DotNetEmitterSingleFile keeps passing no CrefLinkContext, preserving its no-links-in-single-file behavior. - Add the missing SysML2 model entry for CrefTargetResolver, mirroring the existing TypeLinkResolver pattern. Formal-review follow-ups: - Correct stale design/verification/reqstream documentation that still referenced a non-existent WriteMembersSection function, an inverted cref-link nesting description, and stale NamespaceDescription field names. - Document the (pre-existing, already-fixed) unconditional WriteNestedTypesSection call and its rationale in the gradual-disclosure design doc, and add verification/reqstream coverage for the memberless-container-type and generic-method-arity regression tests that already existed but weren't documented. - Add a single-file-mode regression test confirming a NamespaceDoc remarks cref also renders as plain code-span text with no link, matching the type/member behavior. - Remove a duplicate CrefLinkContext construction in WriteNamespacePage. Other fixes: - Exclude the gitignored artifacts/ build-output folder from markdownlint-cli2 scanning; it was never part of any PR diff but was still being linted locally. - Add "Memberless" and "desync" to the cspell word list for new test/comment identifiers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .cspell.yaml | 2 + .markdownlint-cli2.yaml | 1 + .../api-mark-dot-net/dot-net-ast-model.md | 32 ++- .../dot-net-emitter-gradual-disclosure.md | 68 +++-- .../dot-net-emitter-single-file.md | 17 +- .../api-mark-dot-net/dot-net-generator.md | 14 +- .../cref-target-resolver.yaml | 1 + .../dot-net-emitter-gradual-disclosure.yaml | 20 +- .../dot-net-emitter-single-file.yaml | 13 +- docs/sysml2/model/api-mark-dot-net.sysml | 1 + .../cref-target-resolver.sysml | 11 + docs/user_guide/dotnet.md | 8 +- .../api-mark-dot-net/cref-target-resolver.md | 7 + .../dot-net-emitter-gradual-disclosure.md | 25 +- .../dot-net-emitter-single-file.md | 10 + .../api-mark-dot-net/xml-doc-reader.md | 16 +- src/ApiMark.DotNet/DotNetAstModel.cs | 68 ++++- src/ApiMark.DotNet/DotNetEmitter.cs | 12 +- .../DotNetEmitterGradualDisclosure.cs | 244 ++++++++++-------- src/ApiMark.DotNet/DotNetEmitterSingleFile.cs | 23 +- src/ApiMark.DotNet/DotNetGenerator.cs | 21 +- .../CrefLinkingClass.cs | 19 ++ test/ApiMark.DotNet.Fixtures/NamespaceDoc.cs | 5 +- .../CrefTargetResolverTests.cs | 31 +++ .../DotNetEmitterSingleFileTests.cs | 26 ++ .../DotNetGeneratorTests.cs | 58 +++++ 26 files changed, 547 insertions(+), 206 deletions(-) create mode 100644 docs/sysml2/model/api-mark-dot-net/cref-target-resolver.sysml diff --git a/.cspell.yaml b/.cspell.yaml index dcccdb3..1f6e84f 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -28,6 +28,7 @@ words: - dedenting - declspec - deletedmembersclass + - desync - Dema - dllexport - docstrings @@ -62,6 +63,7 @@ words: - Linq - listheader - maxcount + - Memberless - misattributed - modreq - msbuild diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index c5515b5..e394bb2 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -10,6 +10,7 @@ ignores: - '**/third-party/**' - '**/3rd-party/**' - '**/generated/**' + - '**/artifacts/**' - '**/VhdlAst/Antlr/**' - '**/.agent-logs/**' - '**/bin/**' diff --git a/docs/design/api-mark-dot-net/dot-net-ast-model.md b/docs/design/api-mark-dot-net/dot-net-ast-model.md index 67c2e75..56f5237 100644 --- a/docs/design/api-mark-dot-net/dot-net-ast-model.md +++ b/docs/design/api-mark-dot-net/dot-net-ast-model.md @@ -21,8 +21,9 @@ The four context records defined in the same file — `TypePageWriteContext`, `MethodDocContext`, `NamespaceDocContext`, and `NamespaceDescription` — reduce parameter counts on the helper methods by bundling constant values that are threaded through multiple call levels: the first three bundle per-page-type -writing context (`NamespaceDescription` carries the summary, remarks, and -example parts extracted from a `NamespaceDoc` carrier). A fifth record, +writing context (`NamespaceDescription` carries the winning summary/remarks/ +example member IDs extracted from a `NamespaceDoc` carrier, for on-demand, +link-aware rendering). A fifth record, `DotNetAstModelArgs`, bundles the `DotNetAstModel` constructor's own parameters (see Key Methods below). @@ -52,7 +53,8 @@ boundary between parse and emit. paths. - *NamespaceDescriptions* (`IReadOnlyDictionary`): Optional per-namespace documentation sourced from `NamespaceDoc` carrier types, - each bundling the summary, remarks, and structured example parts. + each holding the winning carrier's summary/remarks/example member IDs for + on-demand, link-aware rendering. - *Resolver* (`TypeLinkResolver`): Type link resolver initialized with the root namespaces for gradual-disclosure output. - *Options* (`DotNetGeneratorOptions`): Generator configuration options @@ -112,13 +114,23 @@ writes in a single generation run. - *Resolver* (`TypeLinkResolver`): Type link resolver. **NamespaceDescription** (internal sealed record): Bundles the namespace-level -documentation sourced from a `NamespaceDoc` carrier class so that all three parts -surface on namespace output the same way they do for a type. - -- *Summary* (`string?`): Single-line namespace summary, or `null` when absent. -- *Remarks* (`string?`): Namespace remarks text, or `null` when absent. -- *ExampleParts* (`IReadOnlyList<(bool IsCode, string Content)>`): Structured - example parts, each flagged as code or prose; empty when no `` is present. +documentation sourced from a `NamespaceDoc` carrier class, as member IDs rather +than pre-rendered text, so rendering can be deferred until a `CrefLinkContext` +scoped to the correct output folder is available (namespace descriptions are +selected early in `DotNetGenerator.Parse`, before the cref-link index exists). + +- *SummaryMemberId* (`string?`): Member ID of the carrier whose `` won, + or `null` when absent. +- *RemarksMemberId* (`string?`): Member ID of the carrier whose `` won, + or `null` when absent. +- *ExampleMemberId* (`string?`): Member ID of the carrier whose `` won, + or `null` when absent. +- *GetSummary(XmlDocReader, CrefLinkContext?)* / *GetRemarks(XmlDocReader, + CrefLinkContext?)* / *GetExampleParts(XmlDocReader, CrefLinkContext?)*: + On-demand accessors that resolve the corresponding member ID's text via + `XmlDocReader.GetSummary`/`GetRemarks`/`GetExampleParts`, optionally + resolving ``/`` references to Markdown links when a + `CrefLinkContext` is supplied; return `null`/empty when no member ID was set. **DotNetAstModelArgs** (internal sealed record): Bundles the `DotNetAstModel` constructor's own parameters into a single value, so the constructor takes one diff --git a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md index e6c7cba..81c5642 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md @@ -77,6 +77,11 @@ single namespace. visible members by kind (Constructors, Properties, Fields, Events, Methods, Operators, Nested Types) and writes one table row per member (or one representative row per method overload group) with a link to the member's dedicated page; calls per-kind page writers for each member or member group. + The Nested Types section is written unconditionally via `WriteNestedTypesSection`, + even when the containing type has no own members/operators, since a type can be a + pure namespace-like container for nested types; this must match + `DotNetGenerator.CollectTypeCrefLinkIndex`, which recurses into nested types + unconditionally, to avoid dangling cref links to pages that were never written. **WriteMethodDocumentation** (private static): Writes the XML documentation content for a single method (or one overload) onto the caller-supplied writer. @@ -226,8 +231,8 @@ non-method member (property, field, or event) to the supplied Markdown writer. #### BuildMemberPageIndex and BuildCrefLinkContext -**BuildMemberPageIndex** (`internal static`): Pure function reproducing the -exact same member-grouping decisions `WriteMembersSection` makes (overload +**BuildMemberPageIndex** (`internal static`): Pure function computing, for +every emitted member of a type, the page key it will end up on (overload grouping, operator grouping, case-insensitive-filename-collision grouping, single-member fallback) without writing any Markdown, returning a map from each emitted member's XML-doc ID to its page key. @@ -239,29 +244,42 @@ each emitted member's XML-doc ID to its page key. - *Returns*: `IReadOnlyDictionary` — `DotNetEmitter.BuildMemberId(member) -> "{folder}/{fileNameNoExt}"` for every member where `isMemberEmitted(member)` is `true`. -- *Why this exists*: Before this refactor, the grouping decisions (which - members share an overload page, which operators share `operators.md`, which - case-colliding members share a combined page) existed only inline inside - `WriteMembersSection` and its four called methods - (`WriteMemberPage`/`WriteMethodOverloadPage`/`WriteTypeOperatorsPage`/ - `WriteCombinedMemberPage`), with no reusable, Markdown-free way to answer - "what page will member X end up on". `CrefTargetResolver`/`XmlDocReader` - cross-reference linking needs exactly that answer, for every member in the - assembly, before any page is written — recomputing the grouping logic a - second time (even "equivalently") would create two sources of truth that - could silently drift apart over time. `BuildMemberPageIndex` extracts the - grouping decisions into a single, independently testable, Markdown-free - function; `WriteMembersSection` (and the methods it calls, where they need - the same per-member grouping/page-key facts) now call this exact same - function - rather than duplicating its logic, so there is exactly one source of truth - for "what page does this member live on". This refactor is required to be, - and was verified to be, fully behavior-preserving: it changes no generated - Markdown output. -- *Callers*: `WriteMembersSection` (via its grouping-decision logic, now - delegating to this function); `DotNetGenerator.CollectTypeCrefLinkIndex` - (merges every type's result into the assembly-wide - `DotNetAstModel.MemberPageIndex`). +- *Why this exists*: `CrefTargetResolver`/`XmlDocReader` cross-reference + linking needs to answer "what page will member X end up on" for every + member in the assembly *before* any page is written. The actual page + writer (`ProcessTypeMembers` and the methods it calls — + `ProcessSingleMember`/`ProcessOverloadGroup`/`ProcessCollisionMember`) + interleaves this grouping decision with immediate table-row accumulation + and Markdown writing, in an order that does not separate cleanly into a + single function both call. Rather than duplicate the grouping decision + wholesale (which previously caused a real desync — see the + `DotNetGenerator.CollectTypeCrefLinkIndex` note on nested-type pages), the + three sub-decisions that actually determine a page's file name are each + factored into their own small, shared, Markdown-free helper that both + `BuildMemberPageIndex` and the page writer call identically: + - `GroupMembersByFileName` — groups members by case-insensitive sanitized + file name. Used by both `BuildMemberPageIndex` and `ProcessTypeMembers`. + - `GetOrderedOverloads` — deterministically orders a pure method overload + group so its first entry is always the chosen representative. Used by + `BuildMemberPageIndex` (via `DecideGroupPageFileName`) and + `ProcessOverloadGroup`. + - `DecideGroupPageFileName` — given a group and its case-insensitive key, + returns the single page file name every member in the group links to + (the lone member's own name, the representative overload's name, or the + shared collision key). Used directly by `BuildMemberPageIndex`; its + per-branch logic mirrors what `ProcessOverloadGroup`/ + `ProcessCollisionMember` independently compute for the pages they write. + + This leaves the table-row accumulation and Markdown-writing responsibilities + of `ProcessTypeMembers` and its helpers free to stay separate from indexing, + while guaranteeing the file-name decision itself — the one fact that must + match exactly for cref links to resolve to real pages — cannot drift between + the index and the writer. Overall correctness is additionally verified by + an end-to-end integration test (see `DotNetEmitterGradualDisclosureTests`) + asserting that every page key `BuildMemberPageIndex` returns matches an + actually-written Markdown file for the test fixtures. +- *Callers*: `DotNetGenerator.CollectTypeCrefLinkIndex` (merges every type's + result into the assembly-wide `DotNetAstModel.MemberPageIndex`). **BuildCrefLinkContext** (private): Constructs a `CrefLinkContext` scoped to a given folder, bound to the ambient model's cref-linking indices and the diff --git a/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md b/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md index 1e09cd4..84e85af 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter-single-file.md @@ -48,13 +48,23 @@ into that writer at heading levels `HeadingDepth` (assembly title), - A `TypeLinkResolver` with `generateLinks: false` is used so that parameter type cells contain plain text rather than relative file links that are meaningless inside a single document. For the same reason, every - `ctx.XmlDocs.Get*` call in this class uses the parameterless overload, so - the optional `CrefLinkContext?` parameter always defaults to `null`: a + `ctx.XmlDocs.Get*` call in this class — including the on-demand + `NamespaceDescription.GetSummary`/`GetRemarks`/`GetExampleParts` accessor + calls in `WriteNamespaceDescription` — always passes `null` for the optional + `CrefLinkContext?` parameter: a ``/`` reference always renders as code-span-only text in single-file output, never as a Markdown link, since relative anchors/links are just as meaningless inside a single flat document as the relative file links the `generateLinks: false` resolver already avoids. +**WriteNamespaceDescription** (private static): Renders a namespace's +`NamespaceDoc`-sourced documentation (summary, remarks, structured example +parts) onto the shared single-file writer, mirroring type-level rendering. +Calls `NamespaceDescription.GetSummary`/`GetRemarks`/`GetExampleParts` with +only the `xmlDocs` argument, so the optional `CrefLinkContext?` parameter +defaults to `null` and cref references never link, consistent with every +other `XmlDocReader` access in this class. + **WriteSingleFileTypeSections** (private): Writes all content for a single type — type heading, signature code block, summary, remarks, example, compact member bullet list, and then dispatches to `WriteSingleFileMemberSection` for each @@ -112,7 +122,8 @@ this class. type names without Markdown link generation; used when rendering parameter type cells in single-file output. - **XmlDocReader** — used to retrieve documentation text for each member; - every call site uses the parameterless overloads (no `CrefLinkContext`), so + every call site passes `null` for the `CrefLinkContext?` parameter (directly, + or indirectly via `NamespaceDescription`'s on-demand accessors), so ``/`` references never link in single-file output. - **IMarkdownWriterFactory** — received from `DotNetEmitter.Emit`. 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 6a64ff7..2f04a0a 100644 --- a/docs/design/api-mark-dot-net/dot-net-generator.md +++ b/docs/design/api-mark-dot-net/dot-net-generator.md @@ -184,11 +184,15 @@ memory and returns a `DotNetEmitter` ready to emit. - *NamespaceDoc processing*: After collecting all visible types, `Parse` calls `DotNetEmitter.IsNamespaceDocCarrier` on each type. Carrier types (those named `NamespaceDoc` with `internal static` modifiers) are excluded from the type - listings passed to the emitter. Their XML documentation is extracted via the - `BuildNamespaceDescription` helper — which reads the summary, remarks, and - structured example parts (`XmlDocReader.GetSummary`, `GetRemarks`, and - `GetExampleParts`) using the type's XML-doc ID — and bundled into a - `NamespaceDescription`. The result is stored in the `NamespaceDescriptions` + listings passed to the emitter. The `BuildNamespaceDescription` helper selects, + per namespace, the winning carrier's member ID for each of summary, remarks, + and example — without rendering any text — and bundles them into a + `NamespaceDescription`. Rendering is deferred to emission time (via + `NamespaceDescription.GetSummary`/`GetRemarks`/`GetExampleParts`) because + `Parse` runs before the cref-link index (`CrefTargets`/`MemberPageIndex`) + exists, so no `CrefLinkContext` scoped to the eventual output folder is yet + available for resolving ``/`` references. The result is + stored in the `NamespaceDescriptions` dictionary of `DotNetAstModel`, keyed by namespace name, for use when writing namespace pages. - *Visibility note*: At the top-level type enumeration stage, `PublicAndProtected` diff --git a/docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml b/docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml index db84f59..aceb1d9 100644 --- a/docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml +++ b/docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml @@ -24,6 +24,7 @@ sections: - CrefTargetResolver_TryResolveType_NestedType_ReturnsTrueAndType - CrefTargetResolver_TryResolveMember_Method_ReturnsTrueAndMember - CrefTargetResolver_TryResolveMember_Field_ReturnsTrueAndMember + - CrefTargetResolver_TryResolveMember_GenericMethod_IncludesArityAndResolvesCorrectOverload - id: ApiMarkDotNet-CrefTargetResolver-ResolveWithoutVisibilityFiltering title: CrefTargetResolver shall resolve cref identifiers purely by symbol existence in the assembly, applying no visibility, obsolete, or diff --git a/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml index a19c356..ea5565d 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml @@ -116,12 +116,17 @@ sections: C# types commonly nest supporting types within their declaring type. Emitting a dedicated page per nested type under the containing type's folder mirrors the C# type hierarchy in the file system and makes nested types independently - addressable. + addressable. This must hold even when the containing type is a pure + namespace-like container with no own members/operators, since + DotNetGenerator.CollectTypeCrefLinkIndex recurses into nested types + unconditionally and a skipped nested-types section would leave dangling cref + links. tests: - DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesNestedTypePage - DotNetGenerator_Generate_NestedClass_ListedOnOuterClassPage - DotNetGenerator_Generate_NestedClass_CreatesNestedClassPage - DotNetGenerator_Generate_NestedClass_PageContainsSummary + - DotNetGenerator_Generate_GradualDisclosure_MemberlessContainerType_WritesNestedTypePages - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitOperatorsSummaryDocumentation title: DotNetEmitterGradualDisclosure shall include XML summary documentation for each operator on the operators page. @@ -159,11 +164,13 @@ sections: grouping, single-member fallback) a second time, even if initially equivalent, would create two sources of truth that could silently drift apart on a future edit to only one of them. This is satisfied by an - internal static BuildMemberPageIndex function that reproduces - WriteMembersSection's exact member-grouping decisions without writing - any Markdown, verified against WriteMembersSection's actual output by a - dedicated integration test, with no change to any existing generated - output. + internal static BuildMemberPageIndex function that shares the + GroupMembersByFileName/GetOrderedOverloads/DecideGroupPageFileName + helpers with the actual page writer (ProcessTypeMembers/ + ProcessOverloadGroup/ProcessCollisionMember), reproducing its exact + member-grouping decisions without writing any Markdown, verified + against the writer's actual output by a dedicated integration test, + with no change to any existing generated output. tests: - BuildMemberPageIndex_AllEntries_MatchActualWrittenPages - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-WireCrefLinkContextThroughPageWriters @@ -193,3 +200,4 @@ sections: - CrefLinking_SeeCrefToExternalType_RendersFallbackWithNoLink - CrefLinking_SeeCrefMalformed_RendersFallbackWithNoLink - CrefLinking_SeeAlsoCrefToVisibleType_RendersAsMarkdownLink + - DotNetGenerator_NamespacePage_NamespaceDocRemarksSeeCref_RendersAsLink diff --git a/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml b/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml index ce981a4..9869f8e 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-emitter-single-file.yaml @@ -139,10 +139,13 @@ sections: same reason parameter type cells are already rendered as plain text rather than links (see EmitPlainTextParameterTypes above). Full ``/`` cross-reference linking is explicitly out of - scope for single-file mode. This is satisfied by leaving every - XmlDocReader call site in this class completely unmodified — never - constructing or passing a CrefLinkContext — so the new optional - CrefLinkContext parameter always defaults to null; this is simpler and - safer than adding an explicit opt-out branch. + scope for single-file mode. This is satisfied by the invariant that no + XmlDocReader call site in this class ever constructs or passes a + non-null CrefLinkContext — directly, or indirectly via + NamespaceDescription's GetSummary/GetRemarks/GetExampleParts + accessors — so the optional CrefLinkContext parameter always defaults + to or is passed as null; this is simpler and safer than adding an + explicit opt-out branch. tests: - CrefLinking_SingleFileMode_SeeCrefToVisibleType_RemainsCodeSpanOnly + - CrefLinking_SingleFileMode_NamespaceDocRemarksSeeCref_RemainsCodeSpanOnly diff --git a/docs/sysml2/model/api-mark-dot-net.sysml b/docs/sysml2/model/api-mark-dot-net.sysml index a5b3adf..cc47c6d 100644 --- a/docs/sysml2/model/api-mark-dot-net.sysml +++ b/docs/sysml2/model/api-mark-dot-net.sysml @@ -19,6 +19,7 @@ package ApiMarkDotNet { part dotNetEmitterGradualDisclosure : DotNetEmitterGradualDisclosure; part dotNetEmitterSingleFile : DotNetEmitterSingleFile; part typeLinkResolver : TypeLinkResolver; + part crefTargetResolver : CrefTargetResolver; part xmlDocReader : XmlDocReader; part externalXmlDocResolver : ExternalXmlDocResolver; part typeNameSimplifier : TypeNameSimplifier; diff --git a/docs/sysml2/model/api-mark-dot-net/cref-target-resolver.sysml b/docs/sysml2/model/api-mark-dot-net/cref-target-resolver.sysml new file mode 100644 index 0000000..4dee3b8 --- /dev/null +++ b/docs/sysml2/model/api-mark-dot-net/cref-target-resolver.sysml @@ -0,0 +1,11 @@ +package ApiMarkDotNet { + part def CrefTargetResolver { + doc /* CrefTargetResolver resolves a raw XML-doc cref attribute string to the Mono.Cecil type or member it names within a single, specific assembly. */ + + comment sourceRef /* Source: src/ApiMark.DotNet/CrefTargetResolver.cs */ + comment testRef /* Test: test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs */ + comment designRef /* Design: docs/design/api-mark-dot-net/cref-target-resolver.md */ + comment verificationRef /* Verification: docs/verification/api-mark-dot-net/cref-target-resolver.md */ + comment reqRef /* Requirements: docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml */ + } +} diff --git a/docs/user_guide/dotnet.md b/docs/user_guide/dotnet.md index 67ed026..10de335 100644 --- a/docs/user_guide/dotnet.md +++ b/docs/user_guide/dotnet.md @@ -163,9 +163,11 @@ declared in the assembly being documented renders as a real relative Markdown link to that target's own page, in addition to the existing code-span styling, whenever the target will actually be emitted under the active visibility and obsolete-filter settings. A reference whose target is external -to the assembly, filtered out of the generated documentation, malformed, or a -constructor continues to render as plain code-span text with no link, exactly -as it did before this capability existed. +to the assembly, filtered out of the generated documentation, or malformed +continues to render as plain code-span text with no link, exactly as it did +before this capability existed. A reference to a constructor renders as its +declaring type's bare name with no code span and no link, also unchanged from +before this capability existed. This capability applies only to gradual-disclosure output (`--format gradual`, the default), where each type and member has its own page to link to. In diff --git a/docs/verification/api-mark-dot-net/cref-target-resolver.md b/docs/verification/api-mark-dot-net/cref-target-resolver.md index 553eb44..047051e 100644 --- a/docs/verification/api-mark-dot-net/cref-target-resolver.md +++ b/docs/verification/api-mark-dot-net/cref-target-resolver.md @@ -85,3 +85,10 @@ identifier for a field declared in the indexed assembly resolves to that field, confirming that all four member kinds (`Methods`/`Properties`/ `Fields`/`Events`) are indexed, not just methods. This scenario is tested by `CrefTargetResolver_TryResolveMember_Field_ReturnsTrueAndMember`. + +**Generic method identifier includes arity and resolves the correct +overload**: Verifies that a generic method's XML-doc member identifier +carries the `` `N`` arity suffix distinguishing it from a non-generic +overload sharing the same name, and that each identifier resolves to its own +distinct method (not the other overload). This scenario is tested by +`CrefTargetResolver_TryResolveMember_GenericMethod_IncludesArityAndResolvesCorrectOverload`. diff --git a/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md b/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md index 20694eb..1050e90 100644 --- a/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md @@ -116,6 +116,15 @@ a type containing a nested type produces a dedicated page for that nested type placed under the containing type's folder path. This scenario is tested by `DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesNestedTypePage`. +**Nested Types section is written even when the containing type has no own +members**: Verifies that a type acting as a pure namespace-like container — +with no own members/operators, only a nested type — still has its Nested +Types section written and its nested type's page created, so that cref links +into the nested type (which `DotNetGenerator.CollectTypeCrefLinkIndex` +indexes unconditionally) never dangle. This scenario is tested using the +`TwoLevelNestedClass`/`Middle`/`Inner` fixture by +`DotNetGenerator_Generate_GradualDisclosure_MemberlessContainerType_WritesNestedTypePages`. + **Child namespace page is created**: Verifies that a child namespace (such as `ApiMark.DotNet.Fixtures.Inner`) also produces a dedicated Markdown summary page, confirming that child namespace enumeration works correctly. This scenario is tested by @@ -197,13 +206,23 @@ end-to-end linking path as `` in gradual-disclosure output. This scenario is tested by `CrefLinking_SeeAlsoCrefToVisibleType_RendersAsMarkdownLink`. +**CrefLinking: see cref in a namespace's NamespaceDoc remarks renders as a +Markdown link**: Verifies that `` inside the `` of a +`NamespaceDoc` carrier class is resolved and linkified on the namespace page, +using a `CrefLinkContext` scoped to the namespace page's own folder — the +same linking path as type- and member-level summaries, applied to +namespace-level prose. This scenario is tested by +`DotNetGenerator_NamespacePage_NamespaceDocRemarksSeeCref_RendersAsLink`. + **Every MemberPageIndex entry matches an actually-written page**: Verifies, across the full fixture assembly, that every entry in `DotNetAstModel.MemberPageIndex` (populated by `BuildMemberPageIndex` via `DotNetGenerator.CollectTypeCrefLinkIndex`) corresponds to a page key that a writer actually wrote during `Emit` — directly guarding against `BuildMemberPageIndex`'s grouping logic silently drifting out of sync with -`WriteMembersSection`'s real output, since both now call the same extracted -function but could in principle diverge again through a future edit to only -one call site. This scenario is tested by +the real output of `ProcessTypeMembers`/`ProcessOverloadGroup`/ +`ProcessCollisionMember`, since both the index builder and the page writer +now call the same extracted `GroupMembersByFileName`/`GetOrderedOverloads`/ +`DecideGroupPageFileName` helpers but could in principle diverge again +through a future edit to only one call site. This scenario is tested by `BuildMemberPageIndex_AllEntries_MatchActualWrittenPages`. diff --git a/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md b/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md index 2b34f85..c6a7fee 100644 --- a/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md +++ b/docs/verification/api-mark-dot-net/dot-net-emitter-single-file.md @@ -180,3 +180,13 @@ renders as plain code-span text with no link in single-file output, proving any `XmlDocReader` call site, so the new optional parameter always defaults to `null` here. This scenario is tested by `CrefLinking_SingleFileMode_SeeCrefToVisibleType_RemainsCodeSpanOnly`. + +**Single-file mode never links a namespace-level cref either**: Verifies that +a `` inside a `NamespaceDoc` carrier's `` also renders as +plain code-span text with no link in single-file output — the +`NamespaceDescription.GetSummary`/`GetRemarks`/`GetExampleParts` accessor +calls made by `WriteNamespaceDescription` pass only the `xmlDocs` argument, +so the same `null`-`CrefLinkContext` invariant holds for namespace-level +documentation as it does for type/member documentation. This scenario is +tested by +`CrefLinking_SingleFileMode_NamespaceDocRemarksSeeCref_RemainsCodeSpanOnly`. diff --git a/docs/verification/api-mark-dot-net/xml-doc-reader.md b/docs/verification/api-mark-dot-net/xml-doc-reader.md index f0713c3..575874e 100644 --- a/docs/verification/api-mark-dot-net/xml-doc-reader.md +++ b/docs/verification/api-mark-dot-net/xml-doc-reader.md @@ -94,12 +94,12 @@ assembly is needed. - A multi-line `` element nested inside a `` item's `` renders as an inline span, not a fenced block. - A multi-line `` element reached through `` mixed-prose accumulation (nested inside a ``, not a direct `` child) renders as an inline span, not a fenced block. - Every public `XmlDocReader` method called without a `linkContext` argument (or with `linkContext: null`) renders a resolvable cref exactly as it did before cross-reference linking existed — code-span-only, never linked. -- A `` to an intra-assembly type that will be emitted, rendered with a non-null `linkContext` resolving that type and reporting it as emitted, produces a Markdown link nested inside the code span. -- A `` to an intra-assembly member that will be emitted, rendered with a non-null `linkContext` resolving that member and reporting it as emitted, produces a Markdown link (via the member-page index) nested inside the code span. +- A `` to an intra-assembly type that will be emitted, rendered with a non-null `linkContext` resolving that type and reporting it as emitted, produces a code span nested inside a Markdown link. +- A `` to an intra-assembly member that will be emitted, rendered with a non-null `linkContext` resolving that member and reporting it as emitted, produces a code span (via the member-page index) nested inside a Markdown link. - A `` to an external (non-indexed) type falls back to code-span-only rendering even with a non-null `linkContext`. - A malformed/unresolvable `cref` string falls back to code-span-only rendering even with a non-null `linkContext`. - A `` to a constructor falls back to its existing unwrapped-text rendering even with a non-null `linkContext` — constructors are never linked. -- A `` to a resolved, emitted intra-assembly type (nested inside ``) produces a Markdown link nested inside the code span, confirming `` shares the same `TryLinkifyCref` rendering path as ``. +- A `` to a resolved, emitted intra-assembly type (nested inside ``) produces a code span nested inside a Markdown link, confirming `` shares the same `TryLinkifyCref` rendering path as ``. ### Test Scenarios @@ -627,16 +627,16 @@ no existing behavior by default. This scenario is tested by **A resolved, emitted intra-assembly type renders as a linked code span**: Verifies that `GetSummary`, given a `linkContext` whose `Targets` resolves the cref to a `TypeDefinition` and whose `IsTypeEmitted` delegate reports `true` -for it, renders `` `[Type](path.md)` `` — a Markdown link nested inside the -code span — rather than plain code-span text. This scenario is tested by +for it, renders `` [`Type`](path.md) `` — a code span nested inside a +Markdown link — rather than plain code-span text. This scenario is tested by `XmlDocReader_GetSummary_ResolvedEmittedType_RendersLinkedCodeSpan`. **A resolved, emitted intra-assembly member renders as a linked code span**: Verifies the member counterpart of the above: `GetSummary`, given a `linkContext` whose `Targets` resolves the cref to an `IMemberDefinition` and whose `IsMemberEmitted` delegate reports `true` for it, and whose -`MemberPageIndex` contains an entry for that member, renders a Markdown link -nested inside the code span. This scenario is tested by +`MemberPageIndex` contains an entry for that member, renders a code span +nested inside a Markdown link. This scenario is tested by `XmlDocReader_GetSummary_ResolvedEmittedMember_RendersLinkedCodeSpan`. **An external type cref falls back to code-span-only even with a non-null @@ -665,6 +665,6 @@ confirming constructors are deliberately excluded from linking because span**: Verifies that `` (nested inside `` text, the only place it is ever rendered — see "Top-level `` is never rendered" in the technical notes) shares the exact same `TryLinkifyCref` rendering path as -``, producing a Markdown link nested inside the code span when its +``, producing a code span nested inside a Markdown link when its target resolves and is reported as emitted. This scenario is tested by `XmlDocReader_GetRemarks_SeeAlsoResolvedEmittedType_RendersLinkedCodeSpan`. diff --git a/src/ApiMark.DotNet/DotNetAstModel.cs b/src/ApiMark.DotNet/DotNetAstModel.cs index 52aa56a..a7f52e7 100644 --- a/src/ApiMark.DotNet/DotNetAstModel.cs +++ b/src/ApiMark.DotNet/DotNetAstModel.cs @@ -53,19 +53,67 @@ internal sealed record MethodDocContext( /// /// Bundles the namespace-level documentation sourced from a NamespaceDoc carrier -/// class, carrying the summary, remarks, and structured example parts so that all -/// three surface on namespace output in the same way they do for types. +/// class, identifying which carrier's summary, remarks, and example parts win (per +/// the "first non-empty wins" rule) without pre-rendering their text, so callers can +/// re-fetch the text on demand through an optional and +/// get cross-reference linking in namespace prose the same way type pages do. /// -/// Single-line namespace summary, or null when absent. -/// Namespace remarks text, or null when absent. -/// -/// Structured example parts, each flagged as code or prose; empty when no -/// <example> is present on the carrier. +/// +/// XML-doc identifier of the carrier whose summary should be used, or null +/// when no carrier has a non-empty summary. +/// +/// +/// XML-doc identifier of the carrier whose remarks should be used, or null +/// when no carrier has non-empty remarks. +/// +/// +/// XML-doc identifier of the carrier whose example parts should be used, or +/// null when no carrier has a non-empty <example>. /// internal sealed record NamespaceDescription( - string? Summary, - string? Remarks, - IReadOnlyList<(bool IsCode, string Content)> ExampleParts); + string? SummaryMemberId, + string? RemarksMemberId, + string? ExampleMemberId) +{ + /// + /// Fetches the namespace summary text, optionally resolving <see cref> + /// references to Markdown links via . + /// + /// Documentation index to read the summary from. + /// + /// Optional cross-reference linking context; null renders plain code-span + /// text with no links, matching pre-cross-reference-linking behavior. + /// + /// The summary text, or null when no carrier supplied one. + internal string? GetSummary(XmlDocReader xmlDocs, CrefLinkContext? linkContext = null) => + SummaryMemberId is { } id ? xmlDocs.GetSummary(id, linkContext) : null; + + /// + /// Fetches the namespace remarks text, optionally resolving <see cref> + /// references to Markdown links via . + /// + /// Documentation index to read the remarks from. + /// + /// Optional cross-reference linking context; null renders plain code-span + /// text with no links, matching pre-cross-reference-linking behavior. + /// + /// The remarks text, or null when no carrier supplied any. + internal string? GetRemarks(XmlDocReader xmlDocs, CrefLinkContext? linkContext = null) => + RemarksMemberId is { } id ? xmlDocs.GetRemarks(id, linkContext) : null; + + /// + /// Fetches the namespace's structured example parts, optionally resolving + /// <see cref> references to Markdown links via . + /// + /// Documentation index to read the example parts from. + /// + /// Optional cross-reference linking context; null renders plain code-span + /// text with no links, matching pre-cross-reference-linking behavior. + /// + /// The example parts, or empty when no carrier supplied an example. + internal IReadOnlyList<(bool IsCode, string Content)> GetExampleParts(XmlDocReader xmlDocs, CrefLinkContext? linkContext = null) => + ExampleMemberId is { } id ? xmlDocs.GetExampleParts(id, linkContext) : []; +} /// /// Bundles the per-assembly namespace documentation context that is constant diff --git a/src/ApiMark.DotNet/DotNetEmitter.cs b/src/ApiMark.DotNet/DotNetEmitter.cs index b865bb5..183824a 100644 --- a/src/ApiMark.DotNet/DotNetEmitter.cs +++ b/src/ApiMark.DotNet/DotNetEmitter.cs @@ -568,10 +568,16 @@ internal static string BuildMethodId(MethodDefinition method) // XML doc format uses #ctor for constructors; IL metadata uses .ctor var methodName = method.Name == ConstructorMethodName ? "#ctor" : method.Name; + // XML doc IDs encode a generic method's own type-parameter count as a double-backtick + // arity suffix on the method name (e.g. M:Type.Method``1(...)) — distinct from the + // single-backtick arity on a generic *type* name. Without this, a generic method's ID + // collides with (or is indistinguishable from) a non-generic overload of the same name. + var genericArity = method.HasGenericParameters ? $"``{method.GenericParameters.Count}" : string.Empty; + // XML doc format includes parenthesized parameter list only when parameters exist if (!method.HasParameters) { - return $"M:{typeName}.{methodName}"; + return $"M:{typeName}.{methodName}{genericArity}"; } // Normalize nested-type separators: Cecil uses '/' in FullName (e.g. Outer/Inner) @@ -587,10 +593,10 @@ internal static string BuildMethodId(MethodDefinition method) { // Normalize nested-type separators: Cecil uses '/' in FullName (e.g. OuterClass/Inner) // but XML doc IDs always use '.' (e.g. OuterClass.Inner) - return $"M:{typeName}.{methodName}({paramList})~{ToXmlDocTypeName(method.ReturnType.FullName)}"; + return $"M:{typeName}.{methodName}{genericArity}({paramList})~{ToXmlDocTypeName(method.ReturnType.FullName)}"; } - return $"M:{typeName}.{methodName}({paramList})"; + return $"M:{typeName}.{methodName}{genericArity}({paramList})"; } // ========================================================================= diff --git a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs index 2e73f3a..4523a65 100644 --- a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs +++ b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs @@ -135,6 +135,7 @@ private void EmitGradualDisclosure(IMarkdownWriterFactory factory) // All-namespaces table — lists every namespace so AI agents get a complete map in one // read; counts reflect only the types declared directly in each namespace (not children) + var rootLinkContext = BuildCrefLinkContext(""); var nsHeaders = new[] { "Namespace", "Types", DotNetEmitter.DescriptionColumnHeader }; var nsRows = _model.AllNamespaces.Select(nsName => { @@ -142,7 +143,7 @@ private void EmitGradualDisclosure(IMarkdownWriterFactory factory) var link = $"{folderPath}.md"; var typeCount = _model.ByNamespace.TryGetValue(nsName, out var nsTypes) ? nsTypes.Count : 0; var description = _model.NamespaceDescriptions.TryGetValue(nsName, out var desc) - ? NamespaceTableDescription(desc) + ? NamespaceTableDescription(desc, _model.XmlDocs, rootLinkContext) : DotNetEmitter.NoDescriptionPlaceholder; return new[] { $"[{nsName}]({link})", typeCount.ToString(), description }; }); @@ -183,27 +184,34 @@ private void EmitGradualDisclosure(IMarkdownWriterFactory factory) /// /// Computes the description cell value for a namespace row in a single-row Markdown - /// table: the when present, otherwise the - /// collapsed to a single line, otherwise the - /// . + /// table: the summary when present, otherwise the remarks collapsed to a single line, + /// otherwise the . /// /// /// Remarks may span multiple paragraphs, so they are collapsed to a single line to keep /// the one-row table cell well-formed. Summary text is already single-line by convention. /// /// The namespace description whose summary or remarks to render. + /// Documentation index used to fetch the summary/remarks text. + /// + /// Cross-reference linking context scoped to the table's containing page, so a + /// <see cref> in the namespace summary/remarks links the same way it + /// would in a type's own description. + /// /// The single-line description cell value. - private static string NamespaceTableDescription(NamespaceDescription desc) + private static string NamespaceTableDescription(NamespaceDescription desc, XmlDocReader xmlDocs, CrefLinkContext linkContext) { // Prefer the summary, falling back to single-line remarks, then the placeholder - if (!string.IsNullOrEmpty(desc.Summary)) + var summary = desc.GetSummary(xmlDocs, linkContext); + if (!string.IsNullOrEmpty(summary)) { - return desc.Summary; + return summary; } - if (!string.IsNullOrEmpty(desc.Remarks)) + var remarks = desc.GetRemarks(xmlDocs, linkContext); + if (!string.IsNullOrEmpty(remarks)) { - return CollapseToSingleLine(desc.Remarks); + return CollapseToSingleLine(remarks); } return DotNetEmitter.NoDescriptionPlaceholder; @@ -263,22 +271,28 @@ private void WriteNamespacePage( using var nsWriter = factory.CreateMarkdown(subFolder, shortName); nsWriter.WriteHeading(1, namespaceName); + // Namespace prose and the child-namespace table both live in subFolder, so a single + // link context scoped there is correct for both. + var namespacePageLinkContext = BuildCrefLinkContext(subFolder); + // Emit the namespace documentation when one was supplied via the NamespaceDoc // convention — summary, then remarks, then structured example parts, mirroring the // type-level rendering used elsewhere if (ctx.NamespaceDescriptions.TryGetValue(namespaceName, out var nsDescription)) { - if (!string.IsNullOrEmpty(nsDescription.Summary)) + var summary = nsDescription.GetSummary(ctx.XmlDocs, namespacePageLinkContext); + if (!string.IsNullOrEmpty(summary)) { - nsWriter.WriteParagraph(nsDescription.Summary); + nsWriter.WriteParagraph(summary); } - if (!string.IsNullOrEmpty(nsDescription.Remarks)) + var remarks = nsDescription.GetRemarks(ctx.XmlDocs, namespacePageLinkContext); + if (!string.IsNullOrEmpty(remarks)) { - nsWriter.WriteParagraph(nsDescription.Remarks); + nsWriter.WriteParagraph(remarks); } - WriteExampleParts(nsWriter, nsDescription.ExampleParts); + WriteExampleParts(nsWriter, nsDescription.GetExampleParts(ctx.XmlDocs, namespacePageLinkContext)); } // List immediate child namespaces (gradual disclosure — one level at a time) @@ -295,7 +309,7 @@ private void WriteNamespacePage( SplitPath(childFolderPath, out _, out var childShortName); var link = $"{shortName}/{childShortName}.md"; var childDesc = ctx.NamespaceDescriptions.TryGetValue(child, out var desc) - ? NamespaceTableDescription(desc) + ? NamespaceTableDescription(desc, ctx.XmlDocs, namespacePageLinkContext) : DotNetEmitter.NoDescriptionPlaceholder; return new[] { $"[{child}]({link})", childDesc }; }); @@ -309,11 +323,10 @@ private void WriteNamespacePage( } var typeHeaders = new[] { "Type", DotNetEmitter.DescriptionColumnHeader }; - var namespaceLinkContext = BuildCrefLinkContext(subFolder); var typeRows = nsTypes.Select(t => { var typeMemberId = BuildTypeId(t); - var summary = ctx.XmlDocs.GetSummary(typeMemberId, namespaceLinkContext) ?? DotNetEmitter.NoDescriptionPlaceholder; + var summary = ctx.XmlDocs.GetSummary(typeMemberId, namespacePageLinkContext) ?? DotNetEmitter.NoDescriptionPlaceholder; var typeDisplayName = StripArity(t.Name); var link = $"{shortName}/{FlattenArity(t.Name)}.md"; return new[] { $"[{typeDisplayName}]({link})", summary }; @@ -353,32 +366,33 @@ private void WriteTypePage(TypePageWriteContext ctx) } var (members, operatorMethods) = CollectTypePageMembers(ctx.Type); - if (members.Count == 0 && operatorMethods.Count == 0) + if (members.Count > 0 || operatorMethods.Count > 0) { - return; - } + var buckets = new MemberRowBuckets(); + ProcessTypeMembers(ctx, members, buckets); - var buckets = new MemberRowBuckets(); - ProcessTypeMembers(ctx, members, buckets); + // Emit grouped sub-tables in the canonical order: Constructors, Properties, Methods, Fields, Events. + // Each section is only emitted when at least one member of that kind is present. + WriteMemberRowSections(typeWriter, buckets); - // Emit grouped sub-tables in the canonical order: Constructors, Properties, Methods, Fields, Events. - // Each section is only emitted when at least one member of that kind is present. - WriteMemberRowSections(typeWriter, buckets); + // Emit Operators section when the type has operator overloads — all operators share + // a single page to prevent file-name collisions between op_Addition, op_Subtraction, etc. + if (operatorMethods.Count > 0) + { + WriteTypeOperatorsSection(typeWriter, ctx, operatorMethods); + } - // Emit Operators section when the type has operator overloads — all operators share - // a single page to prevent file-name collisions between op_Addition, op_Subtraction, etc. - if (operatorMethods.Count > 0) - { - WriteTypeOperatorsSection(typeWriter, ctx, operatorMethods); + // Emit the External Types section when any non-standard external types were referenced + WriteExternalTypesSection(typeWriter, buckets.ExternalTypes); } // Emit Nested Types section when the type has visible nested types — each nested type // receives a dedicated page under the containing type's folder so the documentation - // hierarchy mirrors the C# type hierarchy + // hierarchy mirrors the C# type hierarchy. This runs even when the containing type has + // no own members/operators, since a type can be a pure namespace-like container for + // nested types; the cref-link index (DotNetGenerator.CollectTypeCrefLinkIndex) recurses + // into nested types unconditionally and must match this to avoid dangling cref links. WriteNestedTypesSection(typeWriter, ctx); - - // Emit the External Types section when any non-standard external types were referenced - WriteExternalTypesSection(typeWriter, buckets.ExternalTypes); } /// @@ -463,21 +477,9 @@ private static void WriteTypeHeaderSections(IMarkdownWriter typeWriter, TypePage /// Row accumulators and external-type tracker for the type page. private static void ProcessTypeMembers(TypePageWriteContext ctx, List members, MemberRowBuckets buckets) { - // Build a case-insensitive map of all members by their sanitized file name to detect - // collisions between members whose names differ only in case (e.g. field "name" and - // property "Name"). Members sharing a lowercase key are combined onto a single page. - var caseInsensitiveGroups = new Dictionary>(StringComparer.Ordinal); - foreach (var member in members) - { - var lowerKey = GetSanitizedMemberFileName(member, ctx.Type).ToLowerInvariant(); - if (!caseInsensitiveGroups.TryGetValue(lowerKey, out var list)) - { - list = []; - caseInsensitiveGroups[lowerKey] = list; - } - - list.Add(member); - } + // Shared with BuildMemberPageIndex (the cref-link index) so both always agree on which + // members share a page — see GroupMembersByFileName's remarks. + var caseInsensitiveGroups = GroupMembersByFileName(members, ctx.Type); // Track which lowercase keys have had their member page written so collision groups // are documented exactly once while their table rows are still emitted individually @@ -889,70 +891,105 @@ internal static IReadOnlyDictionary BuildMemberPageIndex( .Where(m => !(m is MethodDefinition md && IsOperator(md))) .ToList(); - // Mirror ProcessTypeMembers: group by case-insensitive sanitized file name, then decide - // single/overload-group/collision handling for each distinct group exactly once. - var caseInsensitiveGroups = new Dictionary>(StringComparer.Ordinal); - foreach (var member in members) + // Shared with ProcessTypeMembers via GroupMembersByFileName, and with ProcessOverloadGroup + // via DecideGroupPageFileName/GetOrderedOverloads, so the index can never desync from the + // page names the writer actually produces. + foreach (var (lowerKey, group) in GroupMembersByFileName(members, type)) { - var lowerKey = GetSanitizedMemberFileName(member, type).ToLowerInvariant(); - if (!caseInsensitiveGroups.TryGetValue(lowerKey, out var list)) + var pageFileName = DecideGroupPageFileName(group, lowerKey, type); + var pageKey = $"{typeFolder}/{pageFileName}"; + foreach (var groupMember in group) { - list = []; - caseInsensitiveGroups[lowerKey] = list; + index[BuildMemberId(groupMember)] = pageKey; } - - list.Add(member); } - var processedKeys = new HashSet(StringComparer.Ordinal); + return index; + } + + // ========================================================================= + // Member processing helpers + // ========================================================================= + + /// + /// Groups by case-insensitive sanitized file name. + /// + /// + /// Shared by (the cref-link index) and + /// (the actual page writer) so both always agree on + /// which members share a page. + /// + /// The members to group (non-operator members of a single type). + /// The declaring type, required by . + /// A map from lowercase sanitized file name to the members sharing that name. + private static Dictionary> GroupMembersByFileName( + IReadOnlyList members, + TypeDefinition type) + { + var groups = new Dictionary>(StringComparer.Ordinal); foreach (var member in members) { var lowerKey = GetSanitizedMemberFileName(member, type).ToLowerInvariant(); - if (!processedKeys.Add(lowerKey)) + if (!groups.TryGetValue(lowerKey, out var list)) { - continue; + list = []; + groups[lowerKey] = list; } - var group = caseInsensitiveGroups[lowerKey]; - if (group.Count == 1) - { - var sanitizedName = GetSanitizedMemberFileName(group[0], type); - index[BuildMemberId(group[0])] = $"{typeFolder}/{sanitizedName}"; - } - else if (IsPureMethodOverloadGroup(group, type)) - { - // Mirror ProcessOverloadGroup's deterministic representative selection — the - // representative's sanitized file name is the shared overload-group page name - // (matching WriteMethodOverloadPage's use of orderedOverloads[0]). - var orderedOverloads = group - .Cast() - .OrderBy(m => m.GenericParameters.Count) - .ThenBy(m => m.Parameters.Count) - .ThenBy(m => string.Join(",", m.Parameters.Select(p => p.ParameterType.FullName)), StringComparer.Ordinal) - .ToList(); - var overloadFileName = GetSanitizedMemberFileName(orderedOverloads[0], type); - var pageKey = $"{typeFolder}/{overloadFileName}"; - foreach (var overload in orderedOverloads) - { - index[BuildMemberId(overload)] = pageKey; - } - } - else - { - var pageKey = $"{typeFolder}/{lowerKey}"; - foreach (var collisionMember in group) - { - index[BuildMemberId(collisionMember)] = pageKey; - } - } + list.Add(member); } - return index; + return groups; } - // ========================================================================= - // Member processing helpers - // ========================================================================= + /// + /// Deterministically orders a pure method overload group so the first entry is always + /// the chosen representative (used for the shared overload page's file name and + /// heading, and for the summary table row). + /// + /// + /// Shared by , , + /// and so the representative selection can never + /// diverge between the cref-link index and the actual overload page written to disk. + /// + /// All members of a pure method overload group. + /// The group's methods, ordered with the representative first. + private static List GetOrderedOverloads(IReadOnlyList group) => + group + .Cast() + .OrderBy(m => m.GenericParameters.Count) + .ThenBy(m => m.Parameters.Count) + .ThenBy(m => string.Join(",", m.Parameters.Select(p => p.ParameterType.FullName)), StringComparer.Ordinal) + .ToList(); + + /// + /// Decides the single shared page file name (without extension) that every member of + /// links to. + /// + /// + /// Shared by (the cref-link index) and + /// / (the actual + /// page writer), so a future change to this decision automatically applies to both and + /// cannot cause a cref link to point at a page different from the one actually written. + /// + /// All members sharing . + /// The shared lowercase sanitized file name key for the group. + /// The declaring type, required by . + /// The page file name (without extension) for the group. + private static string DecideGroupPageFileName(IReadOnlyList group, string lowerKey, TypeDefinition type) + { + if (group.Count == 1) + { + return GetSanitizedMemberFileName(group[0], type); + } + + if (IsPureMethodOverloadGroup(group.ToList(), type)) + { + return GetSanitizedMemberFileName(GetOrderedOverloads(group)[0], type); + } + + return lowerKey; + } /// /// Returns when all members in are @@ -1085,14 +1122,9 @@ private static void ProcessOverloadGroup( List methodRows, SortedSet externalTypes) { - var methods = group.Cast().ToList(); - - // Ensure deterministic ordering for representative selection and page rendering - var orderedOverloads = methods - .OrderBy(m => m.GenericParameters.Count) - .ThenBy(m => m.Parameters.Count) - .ThenBy(m => string.Join(",", m.Parameters.Select(p => p.ParameterType.FullName)), StringComparer.Ordinal) - .ToList(); + // Shared with BuildMemberPageIndex (the cref-link index) and DecideGroupPageFileName so + // the representative selection — and therefore the page file name — can never diverge. + var orderedOverloads = GetOrderedOverloads(group); var representative = orderedOverloads[0]; var representativeMemberId = BuildMemberId(representative); diff --git a/src/ApiMark.DotNet/DotNetEmitterSingleFile.cs b/src/ApiMark.DotNet/DotNetEmitterSingleFile.cs index 8e87962..824eb5e 100644 --- a/src/ApiMark.DotNet/DotNetEmitterSingleFile.cs +++ b/src/ApiMark.DotNet/DotNetEmitterSingleFile.cs @@ -124,7 +124,7 @@ private void EmitSingleFile(IMarkdownWriterFactory factory, EmitConfig config) // the type-level rendering in WriteSingleFileTypeSections if (_model.NamespaceDescriptions.TryGetValue(namespaceName, out var nsDescription)) { - WriteNamespaceDescription(writer, nsDescription); + WriteNamespaceDescription(writer, nsDescription, _model.XmlDocs); } if (!_model.ByNamespace.TryGetValue(namespaceName, out var nsTypes) || nsTypes.Count == 0) @@ -145,21 +145,30 @@ private void EmitSingleFile(IMarkdownWriterFactory factory, EmitConfig config) /// Emits the summary, remarks, and structured example parts sourced from a NamespaceDoc /// carrier class, mirroring the type-level rendering in . /// + /// + /// No is supplied — single-file output never resolves + /// <see cref> to links (see the noLinkResolver remark in + /// ), so this renders exactly as it did before cross-reference + /// linking existed. + /// /// The shared single-file Markdown writer. /// The namespace description to render. - private static void WriteNamespaceDescription(IMarkdownWriter writer, NamespaceDescription nsDescription) + /// Documentation index used to fetch the summary/remarks/example text. + private static void WriteNamespaceDescription(IMarkdownWriter writer, NamespaceDescription nsDescription, XmlDocReader xmlDocs) { - if (!string.IsNullOrEmpty(nsDescription.Summary)) + var summary = nsDescription.GetSummary(xmlDocs); + if (!string.IsNullOrEmpty(summary)) { - writer.WriteParagraph(nsDescription.Summary); + writer.WriteParagraph(summary); } - if (!string.IsNullOrEmpty(nsDescription.Remarks)) + var remarks = nsDescription.GetRemarks(xmlDocs); + if (!string.IsNullOrEmpty(remarks)) { - writer.WriteParagraph(nsDescription.Remarks); + writer.WriteParagraph(remarks); } - WriteExampleParts(writer, nsDescription.ExampleParts); + WriteExampleParts(writer, nsDescription.GetExampleParts(xmlDocs)); } /// diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index 0e5c733..d1b0ca2 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -437,20 +437,19 @@ private static NamespaceDescription BuildNamespaceDescription( // Materialize once — the selection makes multiple passes over the carriers var memberIds = carriers.Select(DotNetEmitter.BuildTypeId).ToList(); - var summary = memberIds - .Select(xmlDocs.GetSummary) - .FirstOrDefault(text => !string.IsNullOrEmpty(text)); + // Record the winning carrier's ID rather than its rendered text, so the text can be + // re-fetched at emission time with a CrefLinkContext scoped to the actual output + // folder — see NamespaceDescription's remarks on cross-reference linking. + var summaryMemberId = memberIds + .FirstOrDefault(id => !string.IsNullOrEmpty(xmlDocs.GetSummary(id))); - var remarks = memberIds - .Select(xmlDocs.GetRemarks) - .FirstOrDefault(text => !string.IsNullOrEmpty(text)); + var remarksMemberId = memberIds + .FirstOrDefault(id => !string.IsNullOrEmpty(xmlDocs.GetRemarks(id))); - var exampleParts = memberIds - .Select(xmlDocs.GetExampleParts) - .FirstOrDefault(parts => parts.Count > 0) - ?? Array.Empty<(bool IsCode, string Content)>(); + var exampleMemberId = memberIds + .FirstOrDefault(id => xmlDocs.GetExampleParts(id).Count > 0); - return new NamespaceDescription(summary, remarks, exampleParts); + return new NamespaceDescription(summaryMemberId, remarksMemberId, exampleMemberId); } /// diff --git a/test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs b/test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs index c19f324..428a979 100644 --- a/test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs +++ b/test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs @@ -51,4 +51,23 @@ public void ReferencesExternalType() public void ReferencesMalformedCref() { } + + /// + /// References a generic method overload by its own XML-doc ``N arity suffix: + /// . + /// + public void ReferencesGenericMethod() + { + } + + /// A generic method with its own type parameter, distinct from a non-generic overload of the same name. + /// The type of . + /// The value to return unchanged. + /// , unchanged. + public T Identity(T value) => value; + + /// A non-generic overload sharing its name with . + /// The value to return unchanged. + /// , unchanged. + public int Identity(int value) => value; } diff --git a/test/ApiMark.DotNet.Fixtures/NamespaceDoc.cs b/test/ApiMark.DotNet.Fixtures/NamespaceDoc.cs index 5cc4968..6c44f3c 100644 --- a/test/ApiMark.DotNet.Fixtures/NamespaceDoc.cs +++ b/test/ApiMark.DotNet.Fixtures/NamespaceDoc.cs @@ -1,7 +1,10 @@ namespace ApiMark.DotNet.Fixtures; /// Contains types for testing the ApiMark .NET documentation generator. -/// Namespace-level remarks for verification of NamespaceDoc rendering. +/// +/// Namespace-level remarks for verification of NamespaceDoc rendering, including a +/// cross-reference to . +/// /// /// /// var x = 1; diff --git a/test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs b/test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs index 6f928a6..8df43d0 100644 --- a/test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs +++ b/test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs @@ -173,4 +173,35 @@ public void CrefTargetResolver_TryResolveMember_Field_ReturnsTrueAndMember() Assert.True(resolved); Assert.Same(expected, member); } + + /// + /// Validates that a generic method's XML-doc member identifier includes the ``N + /// arity suffix and resolves to the generic overload specifically, not the non-generic + /// overload sharing the same name. + /// + [Fact] + public void CrefTargetResolver_TryResolveMember_GenericMethod_IncludesArityAndResolvesCorrectOverload() + { + // Arrange + var type = _assembly.MainModule.Types.First(t => t.Name == "CrefLinkingClass"); + var genericMethod = type.Methods.First(m => m.Name == "Identity" && m.HasGenericParameters); + var nonGenericMethod = type.Methods.First(m => m.Name == "Identity" && !m.HasGenericParameters); + var genericCrefId = DotNetEmitter.BuildMemberId(genericMethod); + var nonGenericCrefId = DotNetEmitter.BuildMemberId(nonGenericMethod); + + // Assert: the generic method's ID carries the ``1 arity suffix and the two overloads + // produce distinct identifiers + Assert.Contains("``1", genericCrefId); + Assert.NotEqual(genericCrefId, nonGenericCrefId); + + // Act + var resolvedGeneric = _resolver.TryResolveMember(genericCrefId, out var resolvedGenericMember); + var resolvedNonGeneric = _resolver.TryResolveMember(nonGenericCrefId, out var resolvedNonGenericMember); + + // Assert: each identifier resolves to its own distinct overload + Assert.True(resolvedGeneric); + Assert.Same(genericMethod, resolvedGenericMember); + Assert.True(resolvedNonGeneric); + Assert.Same(nonGenericMethod, resolvedNonGenericMember); + } } diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs index 5646e37..4d6c75d 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterSingleFileTests.cs @@ -645,4 +645,30 @@ public void CrefLinking_SingleFileMode_SeeCrefToVisibleType_RemainsCodeSpanOnly( Assert.DoesNotContain(paragraphs, p => p.Contains("References another in-assembly, publicly visible type", StringComparison.Ordinal) && p.Contains("](", StringComparison.Ordinal)); } + + /// + /// Validates that a <see cref> inside a NamespaceDoc carrier's + /// <remarks> renders as plain code-span text with no Markdown link in + /// single-file mode, mirroring the type/member cref behavior: single-file mode never + /// supplies a CrefLinkContext, even when fetching namespace-level documentation + /// via NamespaceDescription's on-demand accessors. + /// + [Fact] + public void CrefLinking_SingleFileMode_NamespaceDocRemarksSeeCref_RemainsCodeSpanOnly() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterSingleFile(emitter, emitter.Model).Emit(factory, new EmitConfig { Format = OutputFormat.SingleFile }, new InMemoryContext()); + + // Assert: the in the NamespaceDoc must render as plain + // code-span text with no Markdown link syntax + var writer = factory.GetWriter("", "api"); + var paragraphs = writer.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains(paragraphs, p => p.Contains("`SampleClass`", StringComparison.Ordinal)); + Assert.DoesNotContain(paragraphs, p => p.Contains("`SampleClass`", StringComparison.Ordinal) && + p.Contains("](", StringComparison.Ordinal)); + } } diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index ada3634..729178b 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1217,6 +1217,30 @@ public void DotNetGenerator_NamespacePage_NamespaceDocRemarks_AppearsOnNamespace p => p.Contains("Namespace-level remarks for verification", StringComparison.Ordinal)); } + /// + /// Validates that a <see cref> inside a NamespaceDoc carrier's + /// <remarks> renders as a cross-reference link on the namespace page, the + /// same way it would inside a type's own remarks. + /// + [Fact] + public void DotNetGenerator_NamespacePage_NamespaceDocRemarksSeeCref_RendersAsLink() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var generator = new DotNetGenerator(BuildOptions()); + + // Act + generator.Parse(new InMemoryContext()).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: the in the NamespaceDoc must render as a + // Markdown link to SampleClass's own page, not plain code-span text + var nsWriter = factory.Writers["ApiMark.DotNet.Fixtures"]; + var paragraphs = nsWriter.Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains( + paragraphs, + p => p.Contains("[`SampleClass`](ApiMark.DotNet.Fixtures/SampleClass.md)", StringComparison.Ordinal)); + } + /// Validates that the NamespaceDoc XML example is emitted as a code block on the namespace page. [Fact] public void DotNetGenerator_NamespacePage_NamespaceDocExample_EmitsCodeBlock() @@ -2618,6 +2642,40 @@ public void DotNetGenerator_Generate_GradualDisclosure_EveryGeneratedLinkResolve Assert.True(brokenLinks.Count == 0, $"Broken links found:\n{string.Join('\n', brokenLinks)}"); } + /// + /// Regression guard for the cref-link-index/page-emission mismatch flagged in PR review: + /// a type with no own members or operators (a pure container for a nested type, such as + /// TwoLevelNestedClass, whose only content is the nested Middle class) must + /// still get its nested-type page(s) written. Before the fix, WriteTypePage returned + /// before reaching WriteNestedTypesSection whenever the containing type itself had + /// no members/operators, so Middle.md and Middle/Inner.md were silently never + /// created even though DotNetGenerator.CollectTypeCrefLinkIndex unconditionally + /// recursed into them and marked them as valid cref-link targets — a cref resolving to + /// either page would have linked to a file that did not exist. + /// + [Fact] + public void DotNetGenerator_Generate_GradualDisclosure_MemberlessContainerType_WritesNestedTypePages() + { + // Arrange + var factory = new InMemoryMarkdownWriterFactory(); + var emitter = (DotNetEmitter)new DotNetGenerator(BuildOptions()).Parse(new InMemoryContext()); + + // Act + new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); + + // Assert: TwoLevelNestedClass itself has no own members, but its nested Middle (also + // memberless) and Middle's nested Inner must still each get a written page + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/TwoLevelNestedClass"), + "Expected TwoLevelNestedClass.md to be created"); + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/TwoLevelNestedClass/Middle"), + "Expected TwoLevelNestedClass/Middle.md to be created even though TwoLevelNestedClass has no own members"); + Assert.True( + factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/TwoLevelNestedClass/Middle/Inner"), + "Expected TwoLevelNestedClass/Middle/Inner.md to be created even though Middle has no own members"); + } + /// /// Resolves a Markdown-relative link path against the folder of the page containing it, /// collapsing . and .. segments, and strips the trailing .md extension From 6db9a137d7cb2aba1f5dc041f24bca508efe7273 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 8 Oct 2026 09:14:53 -0400 Subject: [PATCH 11/13] Fix formal-review findings on DotNetGenerator and CrefTargetResolver review-sets - Relocate DotNetGenerator_BuildMethodIdFromReference_ExplicitGenericInterfaceOverride_MatchesInterfaceMethodId from CrefTargetResolverTests.cs to DotNetGeneratorTests.cs (it exercises DotNetGenerator, not CrefTargetResolver) - Add ExplicitIndexerClass fixture and widen MapAccessorReferenceToPropertyId to internal (with rationale remarks, matching existing convention) to cover the indexer parameter-list handling for explicit accessor overrides - Add remarks rationale to BuildMethodIdFromReference's internal visibility - Correct cref-target-resolver.md verification doc (remove misattributed acceptance criteria/test scenario) and dot-net-generator.md verification doc (add acceptance criteria/test scenarios for the new/relocated tests) - Fix stale "unmodified" wording in cref-target-resolver.md design doc now that BuildMemberId/BuildTypeId formatting is reused as-is rather than duplicated - Link reqstream requirements to the new/moved tests All findings from the DotNetGenerator and CrefTargetResolver formal reviews are resolved; both review-sets are now Approved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api-mark-dot-net/cref-target-resolver.md | 9 +- .../api-mark-dot-net/dot-net-emitter.md | 41 ++++++--- .../api-mark-dot-net/dot-net-generator.md | 10 ++- .../cref-target-resolver.yaml | 2 + .../api-mark-dot-net/dot-net-generator.yaml | 3 + .../api-mark-dot-net/cref-target-resolver.md | 24 ++++- .../api-mark-dot-net/dot-net-generator.md | 27 ++++++ src/ApiMark.DotNet/DotNetEmitter.cs | 89 ++++++++++++++++++- .../DotNetEmitterGradualDisclosure.cs | 50 ++++++----- src/ApiMark.DotNet/DotNetGenerator.cs | 53 +++++++++-- .../CrefLinkingClass.cs | 14 +++ .../ExplicitIndexerClass.cs | 31 +++++++ .../GenericOverrideClass.cs | 26 ++++++ .../CrefTargetResolverTests.cs | 54 +++++++++++ .../DotNetGeneratorTests.cs | 70 +++++++++++++++ 15 files changed, 446 insertions(+), 57 deletions(-) create mode 100644 test/ApiMark.DotNet.Fixtures/ExplicitIndexerClass.cs create mode 100644 test/ApiMark.DotNet.Fixtures/GenericOverrideClass.cs diff --git a/docs/design/api-mark-dot-net/cref-target-resolver.md b/docs/design/api-mark-dot-net/cref-target-resolver.md index 6d17fcf..acc230f 100644 --- a/docs/design/api-mark-dot-net/cref-target-resolver.md +++ b/docs/design/api-mark-dot-net/cref-target-resolver.md @@ -96,9 +96,12 @@ for crefs that cannot be linked. - **Mono.Cecil** — CrefTargetResolver walks `AssemblyDefinition`, `ModuleDefinition.GetTypes()`, `TypeDefinition`, and `IMemberDefinition`. -- **DotNetEmitter.BuildTypeId** / **DotNetEmitter.BuildMemberId** — reused, - unmodified, to compute index keys; no XML-doc-ID formatting logic is - duplicated. +- **DotNetEmitter.BuildTypeId** / **DotNetEmitter.BuildMemberId** — reused + as-is to compute index keys; CrefTargetResolver never duplicates any of + their XML-doc-ID formatting logic, so any future change to how those + builders encode an ID (e.g. the indexer parameter-list and generic + positional-notation fixes) is automatically reflected here without any + change to this unit. ### Callers diff --git a/docs/design/api-mark-dot-net/dot-net-emitter.md b/docs/design/api-mark-dot-net/dot-net-emitter.md index 180b368..457c931 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter.md @@ -162,7 +162,7 @@ These helpers are grouped by concern: constructor is never the compiler-synthesized one, regardless of how many (necessarily zero) sequence points it reports. - *ID and file-name builders* — `GetMemberDisplayName`, `BuildTypeId`, `BuildMemberId`, - `BuildMethodId`, `GetSanitizedMemberFileName`, `BuildMethodDisplayName`, + `BuildPropertyId`, `BuildMethodId`, `GetSanitizedMemberFileName`, `BuildMethodDisplayName`, `BuildMethodFileName`, `GetMethodGroupDisplayName`, `GetMethodGroupName`: produce the XML-doc member IDs, display names, and sanitized file-name segments used by both emitters. @@ -254,18 +254,33 @@ portion of a property signature. the accessor's declared accessibility is strictly less permissive than the property's declared accessibility. -**DotNetEmitter.ToXmlDocTypeName** (internal static): Converts a Mono.Cecil -`TypeReference` full name to an XML documentation ID string suitable for member-key -lookup in `XmlDocReader`. - -- *Parameters*: `string cecilFullName` — the Mono.Cecil `TypeReference.FullName` string. -- *Returns*: `string` — the normalized XML-doc type name string. -- *Algorithm*: Strips generic arity markers (backtick + digit(s)) from type names and - replaces square-bracket generic parameter notation (found in some Cecil full-name - representations) with XML-doc `{` / `}` type-parameter format; normalizes - nested-type separators from `/` (Cecil) to `.` (XML-doc format). -- *Used by*: `BuildMethodId` when constructing the XML-doc member ID for method lookups - in `XmlDocReader`. +**DotNetEmitter.ToXmlDocTypeName** (internal static, two overloads): Converts a Mono.Cecil +type reference to an XML documentation ID string suitable for member-key lookup in +`XmlDocReader`. + +- *`string cecilFullName` overload*: Purely textual. Strips generic arity markers + (backtick + digit(s)) from type names and replaces angle-bracket generic + instantiation notation (found in Cecil full-name representations) with XML-doc + `{` / `}` type-parameter format; normalizes nested-type separators from `/` (Cecil) + to `.` (XML-doc format); converts a trailing byref `&` to `@`. Retained for callers + (and tests) that only have a `TypeReference.FullName` string available, but cannot + correctly resolve a generic parameter (its `FullName` is just its source name, e.g. + `"T"`, which carries no positional information). +- *`TypeReference typeRef` overload*: Operates on the live Cecil object graph. + Recognizes `ByReferenceType` (recurses into the element type, appends `@`), + `GenericParameter` (emits XML-doc positional notation — `` `N `` for a type's own + generic parameter at position `N`, `` ``N `` for a generic method's own parameter), + `GenericInstanceType` (recurses into each generic argument so a generic + parameter nested inside one, e.g. the `T` in `List`, is also resolved + positionally), and `ArrayType` (recurses into the element type so a generic + parameter used as an array element, e.g. `T[]`, is also resolved positionally; + appends `[]` for a single-dimension array or `[0:,0:,...]`, one `0:` per + dimension, for a multi-dimensional array); falls back to the string overload for + the element type name itself and for any other type reference kind. Preferred + whenever the Cecil `TypeReference` is available (i.e. everywhere except the + dedicated unit test exercising the string overload directly). +- *Used by*: `BuildMethodId`, `BuildPropertyId`, and `DotNetGenerator.BuildMethodIdFromReference` + when constructing XML-doc member IDs for method/property lookups in `XmlDocReader`. ### Error Handling 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 2f04a0a..d04dd82 100644 --- a/docs/design/api-mark-dot-net/dot-net-generator.md +++ b/docs/design/api-mark-dot-net/dot-net-generator.md @@ -339,9 +339,13 @@ lookups. `CollectPropertyInheritanceTargets`, and `CollectEventInheritanceTargets`, which also populate `assemblyHints` for each candidate target via `GetAssemblyNameFromScope`. - Method targets are resolved using `FindMatchingMethodDefinition` (matches by parameter - count and type name) and `BuildMethodIdFromReference` (reconstructs the XML-doc ID). - - Property accessor→property mapping uses `MapAccessorReferenceToPropertyId`; event - accessor→event mapping uses `MapAccessorReferenceToEventId`. + count and type name) and `BuildMethodIdFromReference` (reconstructs the XML-doc ID, + including the method's own `` ``N `` generic-arity suffix, mirroring + `DotNetEmitter.BuildMethodId`, so a generic method's explicit-override target ID + matches its own XML-doc ID exactly). + - Property accessor→property mapping uses `MapAccessorReferenceToPropertyId` (includes + an indexer's own index parameter type list, excluding the setter's trailing `value` + parameter); event accessor→event mapping uses `MapAccessorReferenceToEventId`. - The ordering rule places the direct base-class override first, followed by each explicit or implicit interface target in declaration order. - *Known limitation*: Complex generic signatures may not always map perfectly to XML-doc diff --git a/docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml b/docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml index aceb1d9..3bdf812 100644 --- a/docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml +++ b/docs/reqstream/api-mark-dot-net/cref-target-resolver.yaml @@ -25,6 +25,8 @@ sections: - CrefTargetResolver_TryResolveMember_Method_ReturnsTrueAndMember - CrefTargetResolver_TryResolveMember_Field_ReturnsTrueAndMember - CrefTargetResolver_TryResolveMember_GenericMethod_IncludesArityAndResolvesCorrectOverload + - CrefTargetResolver_TryResolveMember_Indexer_IncludesParameterListAndResolves + - CrefTargetResolver_TryResolveMember_GenericMethodWithArrayParameter_ResolvesPositionalNotation - id: ApiMarkDotNet-CrefTargetResolver-ResolveWithoutVisibilityFiltering title: CrefTargetResolver shall resolve cref identifiers purely by symbol existence in the assembly, applying no visibility, obsolete, or 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 ba43c83..2adc9b5 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-generator.yaml @@ -150,6 +150,9 @@ sections: tests: - DotNetGenerator_Generate_SampleImplementationNameMemberPage_UsesInheritedSummary - DotNetGenerator_Generate_SampleImplementationExecuteMemberPage_UsesInheritedSummaryAndParamDescription + - DotNetGenerator_BuildMethodIdFromReference_ExplicitGenericInterfaceOverride_MatchesInterfaceMethodId + - DotNetGenerator_MapAccessorReferenceToPropertyId_ExplicitIndexerGetterOverride_IncludesIndexParameter + - DotNetGenerator_MapAccessorReferenceToPropertyId_ExplicitIndexerSetterOverride_ExcludesValueParameter - id: ApiMarkDotNet-DotNetGenerator-ResolveInheritdocAcrossReferencedAssemblies title: DotNetGenerator shall resolve `` elements that target base types, interfaces, or members defined in externally referenced diff --git a/docs/verification/api-mark-dot-net/cref-target-resolver.md b/docs/verification/api-mark-dot-net/cref-target-resolver.md index 047051e..0b399eb 100644 --- a/docs/verification/api-mark-dot-net/cref-target-resolver.md +++ b/docs/verification/api-mark-dot-net/cref-target-resolver.md @@ -31,6 +31,8 @@ or writable output location is needed. - An unknown member identifier returns `false` from `TryResolveMember`. - A malformed cref string returns `false` from `TryResolveType`. - A field's XML-doc identifier resolves via `TryResolveMember`. +- An indexer's XML-doc identifier includes its index parameter type list and resolves via `TryResolveMember`. +- A generic method's own type parameter used as an array element type (e.g. `T[]`) resolves to XML-doc positional notation, not the raw source name. ### Test Scenarios @@ -89,6 +91,24 @@ field, confirming that all four member kinds (`Methods`/`Properties`/ **Generic method identifier includes arity and resolves the correct overload**: Verifies that a generic method's XML-doc member identifier carries the `` `N`` arity suffix distinguishing it from a non-generic -overload sharing the same name, and that each identifier resolves to its own -distinct method (not the other overload). This scenario is tested by +overload sharing the same name, that the method's own generic parameter +within its parameter list is encoded using XML-doc positional notation +(`` ``0``) rather than its raw Cecil source name, and that each identifier +resolves to its own distinct method (not the other overload). This scenario +is tested by `CrefTargetResolver_TryResolveMember_GenericMethod_IncludesArityAndResolvesCorrectOverload`. + +**Indexer identifier includes parameter list and resolves**: Verifies that an +indexer's XML-doc member identifier includes its index parameter type list +(e.g. `P:Type.Item(System.Int32)`), matching the real compiler-emitted XML +doc ID, and resolves back to the indexer's `PropertyDefinition` via +`TryResolveMember`. This scenario is tested by +`CrefTargetResolver_TryResolveMember_Indexer_IncludesParameterListAndResolves`. + +**Generic method with array parameter resolves positional notation**: +Verifies that a generic method's own type parameter used as an array element +type (e.g. `T[]`) is encoded as `` ``0[]`` — recursing into the array element +type rather than falling back to the raw Cecil source name `T[]` — matching +real compiler-emitted XML doc IDs, and resolves back to the method via +`TryResolveMember`. This scenario is tested by +`CrefTargetResolver_TryResolveMember_GenericMethodWithArrayParameter_ResolvesPositionalNotation`. diff --git a/docs/verification/api-mark-dot-net/dot-net-generator.md b/docs/verification/api-mark-dot-net/dot-net-generator.md index 0cdda49..7ded27b 100644 --- a/docs/verification/api-mark-dot-net/dot-net-generator.md +++ b/docs/verification/api-mark-dot-net/dot-net-generator.md @@ -73,6 +73,10 @@ network dependency, or privileged configuration is needed. page for. - Every relative Markdown link produced by gradual-disclosure emission (including cref-based links) resolves to a page that was actually written. +- An explicit interface implementation of an indexer resolves its index parameter type list + via `MapAccessorReferenceToPropertyId`, excluding the setter's trailing `value` parameter. +- `BuildMethodIdFromReference` produces the same `` ``N`` generic-arity suffix as + `DotNetEmitter.BuildMemberId` for an explicit interface implementation of a generic method. ### Test Scenarios @@ -352,3 +356,26 @@ path that a writer actually wrote, confirming `BuildMemberPageIndex`'s grouping decisions (overload grouping, operator grouping, case-collision grouping) exactly match `WriteMembersSection`'s real output with no drift between the two. This scenario is tested by `DotNetGenerator_Generate_GradualDisclosure_EveryGeneratedLinkResolvesToExistingPage`. + +**Explicit indexer getter override maps to the interface property ID with its index +parameter**: Verifies that `MapAccessorReferenceToPropertyId` maps an explicit indexer +getter's override reference to the interface property's XML doc ID, including the index +parameter type list (e.g. `P:Namespace.IIndexerSource.Item(System.Int32)`), so an +explicit indexer implementation's inheritance target resolves to the same identifier the +interface property's own XML documentation uses. This scenario is tested by +`DotNetGenerator_MapAccessorReferenceToPropertyId_ExplicitIndexerGetterOverride_IncludesIndexParameter`. + +**Explicit indexer setter override excludes the trailing value parameter**: Verifies that +`MapAccessorReferenceToPropertyId` maps an explicit indexer setter's override reference to +the same interface property ID as the getter, excluding the setter's own trailing `value` +parameter from the index parameter type list. This scenario is tested by +`DotNetGenerator_MapAccessorReferenceToPropertyId_ExplicitIndexerSetterOverride_ExcludesValueParameter`. + +**Explicit generic interface override ID matches interface method ID**: +Verifies that `DotNetGenerator.BuildMethodIdFromReference` (used to resolve +an explicit-interface-override's inheritance target) produces an identifier +identical to `DotNetEmitter.BuildMemberId` applied to the interface method's +own `MethodDefinition` — including the `` ``N`` generic-arity suffix — so an +explicit override of a generic interface method correctly inherits its +documentation. This scenario is tested by +`DotNetGenerator_BuildMethodIdFromReference_ExplicitGenericInterfaceOverride_MatchesInterfaceMethodId`. diff --git a/src/ApiMark.DotNet/DotNetEmitter.cs b/src/ApiMark.DotNet/DotNetEmitter.cs index 183824a..6911ffc 100644 --- a/src/ApiMark.DotNet/DotNetEmitter.cs +++ b/src/ApiMark.DotNet/DotNetEmitter.cs @@ -500,6 +500,11 @@ internal static string BuildTypeId(TypeDefinition type) => /// The XML doc parameter type encoding. internal static string ToXmlDocTypeName(string cecilFullName) { + // Preserved as a pure-string overload for callers (and tests) that only have a + // FullName available. Prefer ToXmlDocTypeName(TypeReference) when the Cecil object + // is available: it correctly resolves generic parameters to their positional XML + // doc notation (`0, ``0), which cannot be recovered from FullName alone (it is just + // the source parameter name, e.g. "T"). var isByRef = cecilFullName.EndsWith('&'); var name = isByRef ? cecilFullName[..^1] : cecilFullName; @@ -546,18 +551,91 @@ internal static string ToXmlDocTypeName(string cecilFullName) return sb.ToString(); } + /// + /// Converts a Mono.Cecil to the XML doc member ID encoding. + /// + /// + /// Unlike the string overload, this overload + /// operates on the live Cecil object graph so it can correctly resolve generic + /// parameters (e.g. a method's own T parameter, or T nested inside a + /// generic instantiation such as List<T>) to their XML doc positional + /// notation — `N for a type's own generic parameter at position N, and + /// ``N for a generic method's own parameter at position N. A generic + /// parameter's is just its source name (e.g. "T"), + /// which carries no positional information, so this cannot be done from a string alone. + /// + /// The Cecil type reference to convert. + /// The XML doc parameter type encoding. + internal static string ToXmlDocTypeName(TypeReference typeRef) + { + switch (typeRef) + { + // Byref (ref/out/in parameters): recurse on the element type, then append the + // trailing XML doc byref marker (only ever the outermost modifier) + case ByReferenceType byReferenceType: + return $"{ToXmlDocTypeName(byReferenceType.ElementType)}@"; + + // Generic parameter: XML doc IDs identify these positionally rather than by name, + // since the source name is not necessarily unique/stable across assemblies + case GenericParameter genericParameter: + var prefix = genericParameter.DeclaringMethod != null ? "``" : "`"; + return $"{prefix}{genericParameter.Position}"; + + // Generic instantiation (e.g. List, IReadOnlyDictionary): recurse + // into each type argument so a generic parameter nested inside one is also resolved + // positionally, then fall back to the string-based conversion for the element type + // name itself (handles arity-backtick stripping and nested-type separators) + case GenericInstanceType genericInstanceType: + var args = string.Join(",", genericInstanceType.GenericArguments.Select(ToXmlDocTypeName)); + return $"{ToXmlDocTypeName(genericInstanceType.ElementType.FullName)}{{{args}}}"; + + // Array: recurse into the element type so a generic parameter used as an array + // element (e.g. T[] in a method of a generic type, or an array nested inside a + // generic instantiation such as List) is also resolved positionally. XML doc + // IDs encode a single-dimension array as a bare "[]" suffix, and a multi-dimensional + // array as "[0:,0:,...]" — one "0:" per dimension. + case ArrayType arrayType: + var dimensions = arrayType.Rank == 1 + ? string.Empty + : string.Join(",", Enumerable.Repeat("0:", arrayType.Rank)); + return $"{ToXmlDocTypeName(arrayType.ElementType)}[{dimensions}]"; + + default: + return ToXmlDocTypeName(typeRef.FullName); + } + } + /// Builds the XML doc member identifier for an arbitrary member. /// The member definition. /// The XML doc member identifier string. internal static string BuildMemberId(IMemberDefinition member) => member switch { MethodDefinition m => BuildMethodId(m), - PropertyDefinition p => $"P:{p.DeclaringType.FullName.Replace('/', '.')}.{p.Name}", + PropertyDefinition p => BuildPropertyId(p), FieldDefinition f => $"F:{f.DeclaringType.FullName.Replace('/', '.')}.{f.Name}", EventDefinition e => $"E:{e.DeclaringType.FullName.Replace('/', '.')}.{e.Name}", _ => string.Empty, }; + /// + /// Builds the XML doc member identifier for a property, including an indexer's + /// parameter type list when present (e.g. P:Namespace.Type.Item(System.Int32)). + /// + /// The property definition. + /// The XML doc member identifier string. + private static string BuildPropertyId(PropertyDefinition property) + { + var typeName = property.DeclaringType.FullName.Replace('/', '.'); + + if (!property.HasParameters) + { + return $"P:{typeName}.{property.Name}"; + } + + var paramList = string.Join(",", property.Parameters.Select(p => ToXmlDocTypeName(p.ParameterType))); + return $"P:{typeName}.{property.Name}({paramList})"; + } + /// Builds the XML doc member identifier for a method, including parameter type list when present. /// The method definition. /// The XML doc member identifier string. @@ -583,8 +661,11 @@ internal static string BuildMethodId(MethodDefinition method) // Normalize nested-type separators: Cecil uses '/' in FullName (e.g. Outer/Inner) // but XML doc IDs always use '.' (e.g. Outer.Inner). Generic instantiations use // angle brackets in Cecil (e.g. IEnumerable`1) but curly braces in XML doc IDs - // (e.g. IEnumerable{T}) — ToXmlDocTypeName handles both transformations. - var paramList = string.Join(",", method.Parameters.Select(p => ToXmlDocTypeName(p.ParameterType.FullName))); + // (e.g. IEnumerable{T}) — ToXmlDocTypeName handles both transformations. The + // TypeReference overload is used (rather than the FullName string) so that a + // parameter referencing the method's own generic parameter (e.g. T in a param of + // T Identity(T value)) resolves to its XML doc positional notation (``0). + var paramList = string.Join(",", method.Parameters.Select(p => ToXmlDocTypeName(p.ParameterType))); // Conversion operators carry a ~ReturnType suffix in the XML doc member ID // (e.g. M:Type.op_Implicit(SourceType)~TargetType) that distinguishes overloads @@ -593,7 +674,7 @@ internal static string BuildMethodId(MethodDefinition method) { // Normalize nested-type separators: Cecil uses '/' in FullName (e.g. OuterClass/Inner) // but XML doc IDs always use '.' (e.g. OuterClass.Inner) - return $"M:{typeName}.{methodName}{genericArity}({paramList})~{ToXmlDocTypeName(method.ReturnType.FullName)}"; + return $"M:{typeName}.{methodName}{genericArity}({paramList})~{ToXmlDocTypeName(method.ReturnType)}"; } return $"M:{typeName}.{methodName}{genericArity}({paramList})"; diff --git a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs index 4523a65..4cb5ce8 100644 --- a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs +++ b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs @@ -359,39 +359,43 @@ private void WriteTypePage(TypePageWriteContext ctx) // 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(ctx.Type)) + // in public API docs, analogous to how enum backing fields are suppressed. A C# + // delegate declaration cannot itself contain nested types, but nothing prevents an + // assembly from a non-C# compiler from attaching nested types to a delegate-derived + // type at the IL level, and DotNetGenerator.CollectTypeCrefLinkIndex indexes any + // visible nested type unconditionally, so the Nested Types section below must still + // run even here to avoid a dangling cref link. + if (!IsDelegate(ctx.Type)) { - return; - } + var (members, operatorMethods) = CollectTypePageMembers(ctx.Type); + if (members.Count > 0 || operatorMethods.Count > 0) + { + var buckets = new MemberRowBuckets(); + ProcessTypeMembers(ctx, members, buckets); - var (members, operatorMethods) = CollectTypePageMembers(ctx.Type); - if (members.Count > 0 || operatorMethods.Count > 0) - { - var buckets = new MemberRowBuckets(); - ProcessTypeMembers(ctx, members, buckets); + // Emit grouped sub-tables in the canonical order: Constructors, Properties, Methods, Fields, Events. + // Each section is only emitted when at least one member of that kind is present. + WriteMemberRowSections(typeWriter, buckets); - // Emit grouped sub-tables in the canonical order: Constructors, Properties, Methods, Fields, Events. - // Each section is only emitted when at least one member of that kind is present. - WriteMemberRowSections(typeWriter, buckets); + // Emit Operators section when the type has operator overloads — all operators share + // a single page to prevent file-name collisions between op_Addition, op_Subtraction, etc. + if (operatorMethods.Count > 0) + { + WriteTypeOperatorsSection(typeWriter, ctx, operatorMethods); + } - // Emit Operators section when the type has operator overloads — all operators share - // a single page to prevent file-name collisions between op_Addition, op_Subtraction, etc. - if (operatorMethods.Count > 0) - { - WriteTypeOperatorsSection(typeWriter, ctx, operatorMethods); + // Emit the External Types section when any non-standard external types were referenced + WriteExternalTypesSection(typeWriter, buckets.ExternalTypes); } - - // Emit the External Types section when any non-standard external types were referenced - WriteExternalTypesSection(typeWriter, buckets.ExternalTypes); } // Emit Nested Types section when the type has visible nested types — each nested type // receives a dedicated page under the containing type's folder so the documentation // hierarchy mirrors the C# type hierarchy. This runs even when the containing type has - // no own members/operators, since a type can be a pure namespace-like container for - // nested types; the cref-link index (DotNetGenerator.CollectTypeCrefLinkIndex) recurses - // into nested types unconditionally and must match this to avoid dangling cref links. + // no own members/operators (including delegates), since a type can be a pure + // namespace-like container for nested types; the cref-link index + // (DotNetGenerator.CollectTypeCrefLinkIndex) recurses into nested types unconditionally + // and must match this to avoid dangling cref links. WriteNestedTypesSection(typeWriter, ctx); } diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index d1b0ca2..47d5380 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -1040,38 +1040,61 @@ private static void AddInterfaceEventImplementation(EventDefinition ev, TypeDefi /// /// Builds an XML doc method identifier string from a . /// + /// + /// Widened from to so that + /// unit tests (same assembly, via InternalsVisibleTo) can directly verify the + /// generated ID matches 's own output for the + /// same method. This does not change the public API surface of + /// . + /// /// The method reference to convert. /// The XML doc member identifier (e.g. M:Namespace.Type.Method(ParamType)). - private static string BuildMethodIdFromReference(MethodReference methodRef) + internal static string BuildMethodIdFromReference(MethodReference methodRef) { var typeName = methodRef.DeclaringType.FullName.Replace('/', '.'); // XML doc format uses #ctor for constructors; IL metadata uses .ctor var methodName = string.Equals(methodRef.Name, ".ctor", StringComparison.Ordinal) ? "#ctor" : methodRef.Name; + // Mirrors DotNetEmitter.BuildMethodId's generic arity suffix: an explicit-override + // target ID must match the overridden generic method's own ID exactly, or the + // inheritdoc-chain lookup will silently fail to find it. + var genericArity = methodRef.HasGenericParameters ? $"``{methodRef.GenericParameters.Count}" : string.Empty; + if (!methodRef.HasParameters) { - return $"M:{typeName}.{methodName}"; + return $"M:{typeName}.{methodName}{genericArity}"; } - var paramList = string.Join(",", methodRef.Parameters.Select(p => DotNetEmitter.ToXmlDocTypeName(p.ParameterType.FullName))); + var paramList = string.Join(",", methodRef.Parameters.Select(p => DotNetEmitter.ToXmlDocTypeName(p.ParameterType))); if (methodRef.Name is "op_Implicit" or "op_Explicit") { - return $"M:{typeName}.{methodName}({paramList})~{DotNetEmitter.ToXmlDocTypeName(methodRef.ReturnType.FullName)}"; + return $"M:{typeName}.{methodName}{genericArity}({paramList})~{DotNetEmitter.ToXmlDocTypeName(methodRef.ReturnType)}"; } - return $"M:{typeName}.{methodName}({paramList})"; + return $"M:{typeName}.{methodName}{genericArity}({paramList})"; } /// - /// Maps an accessor method reference (getter or setter) to its owning property's XML doc ID. - /// Returns null when the accessor name does not start with . + /// Maps an accessor method reference (getter or setter) to its owning property's XML doc ID, + /// including an indexer's own parameter type list when present. Returns null when + /// the accessor name does not start with . /// + /// + /// A setter accessor's parameter list is the indexer's own index parameters followed by + /// the assigned value parameter; the trailing value parameter is excluded + /// since it is not part of the property's own XML doc ID. + /// Widened from to so that unit tests + /// (same assembly, via InternalsVisibleTo) can directly verify the indexer + /// parameter-list handling for explicit accessor overrides, without depending on the full + /// generator pipeline. This does not change the public API surface of + /// . + /// /// The accessor method reference. /// The accessor prefix to strip (get_ or set_). /// The property XML doc ID, or null. - private static string? MapAccessorReferenceToPropertyId(MethodReference accessorRef, string prefix) + internal static string? MapAccessorReferenceToPropertyId(MethodReference accessorRef, string prefix) { if (!accessorRef.Name.StartsWith(prefix, StringComparison.Ordinal)) { @@ -1080,7 +1103,19 @@ private static string BuildMethodIdFromReference(MethodReference methodRef) var propertyName = accessorRef.Name[prefix.Length..]; var typeName = accessorRef.DeclaringType.FullName.Replace('/', '.'); - return $"P:{typeName}.{propertyName}"; + + var isSetter = string.Equals(prefix, "set_", StringComparison.Ordinal); + var indexParameters = isSetter + ? accessorRef.Parameters.Take(accessorRef.Parameters.Count - 1) + : accessorRef.Parameters; + + if (!indexParameters.Any()) + { + return $"P:{typeName}.{propertyName}"; + } + + var paramList = string.Join(",", indexParameters.Select(p => DotNetEmitter.ToXmlDocTypeName(p.ParameterType))); + return $"P:{typeName}.{propertyName}({paramList})"; } /// diff --git a/test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs b/test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs index 428a979..d015c68 100644 --- a/test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs +++ b/test/ApiMark.DotNet.Fixtures/CrefLinkingClass.cs @@ -70,4 +70,18 @@ public void ReferencesGenericMethod() /// The value to return unchanged. /// , unchanged. public int Identity(int value) => value; + + /// An indexer, whose XML doc ID must carry its index parameter type list. + /// The index to read. + /// Always 0. + public int this[int index] => 0; + + /// + /// A generic method taking its own type parameter as an array element type, whose XML + /// doc ID must resolve T positionally even when wrapped in an array type. + /// + /// The array element type. + /// The array to return unchanged. + /// , unchanged. + public T[] IdentityArray(T[] values) => values; } diff --git a/test/ApiMark.DotNet.Fixtures/ExplicitIndexerClass.cs b/test/ApiMark.DotNet.Fixtures/ExplicitIndexerClass.cs new file mode 100644 index 0000000..c8dd2f5 --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/ExplicitIndexerClass.cs @@ -0,0 +1,31 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +namespace ApiMark.DotNet.Fixtures; + +/// An interface declaring an indexer, implemented explicitly by . +public interface IIndexerSource +{ + /// An indexer whose explicit-interface-implementation override ID must carry the index parameter. + /// The index to retrieve or set. + /// The value at . + int this[int index] { get; set; } +} + +/// +/// A fixture class exercising explicit interface implementation of an indexer, so the +/// override's XML-doc inheritance target ID (built from the accessors' MethodReference +/// overrides) can be verified to carry the index parameter type list, excluding the setter's +/// trailing value parameter. +/// +public class ExplicitIndexerClass : IIndexerSource +{ + private int _value; + + /// + int IIndexerSource.this[int index] + { + get => _value; + set => _value = value; + } +} diff --git a/test/ApiMark.DotNet.Fixtures/GenericOverrideClass.cs b/test/ApiMark.DotNet.Fixtures/GenericOverrideClass.cs new file mode 100644 index 0000000..9288b37 --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/GenericOverrideClass.cs @@ -0,0 +1,26 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +namespace ApiMark.DotNet.Fixtures; + +/// An interface declaring a generic method, implemented explicitly by . +public interface IGenericOverrideSource +{ + /// A generic method whose explicit-interface-implementation override ID must match this one. + /// The type of . + /// The value to return unchanged. + /// , unchanged. + T Wrap(T value); +} + +/// +/// A fixture class exercising explicit interface implementation of a generic method, so the +/// override's XML-doc inheritance target ID (built from a MethodReference) can be +/// verified to carry the same ``N generic-arity suffix as the interface method's own +/// ID (built from a MethodDefinition). +/// +public class GenericOverrideClass : IGenericOverrideSource +{ + /// + T IGenericOverrideSource.Wrap(T value) => value; +} diff --git a/test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs b/test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs index 8df43d0..2a8d54d 100644 --- a/test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs +++ b/test/ApiMark.DotNet.Tests/CrefTargetResolverTests.cs @@ -194,6 +194,11 @@ public void CrefTargetResolver_TryResolveMember_GenericMethod_IncludesArityAndRe Assert.Contains("``1", genericCrefId); Assert.NotEqual(genericCrefId, nonGenericCrefId); + // Assert: the method's own generic parameter in its parameter list is encoded using + // XML doc positional notation (``0), matching what a real compiler-generated XML doc + // file would contain — not the raw Cecil source name ("T") + Assert.Equal("M:ApiMark.DotNet.Fixtures.CrefLinkingClass.Identity``1(``0)", genericCrefId); + // Act var resolvedGeneric = _resolver.TryResolveMember(genericCrefId, out var resolvedGenericMember); var resolvedNonGeneric = _resolver.TryResolveMember(nonGenericCrefId, out var resolvedNonGenericMember); @@ -204,4 +209,53 @@ public void CrefTargetResolver_TryResolveMember_GenericMethod_IncludesArityAndRe Assert.True(resolvedNonGeneric); Assert.Same(nonGenericMethod, resolvedNonGenericMember); } + + /// + /// Validates that an indexer's XML-doc member identifier includes its index parameter + /// type list (e.g. P:Type.Item(System.Int32)), matching the real compiler-emitted + /// XML doc ID, and resolves back to the indexer's . + /// + [Fact] + public void CrefTargetResolver_TryResolveMember_Indexer_IncludesParameterListAndResolves() + { + // Arrange + var type = _assembly.MainModule.Types.First(t => t.Name == "CrefLinkingClass"); + var indexer = type.Properties.First(p => p.Name == "Item"); + var crefId = DotNetEmitter.BuildMemberId(indexer); + + // Assert: the ID carries the index parameter type list + Assert.Equal("P:ApiMark.DotNet.Fixtures.CrefLinkingClass.Item(System.Int32)", crefId); + + // Act + var resolved = _resolver.TryResolveMember(crefId, out var resolvedMember); + + // Assert + Assert.True(resolved); + Assert.Same(indexer, resolvedMember); + } + + /// + /// Validates that a generic method's own type parameter used as an array element type + /// (e.g. T[]) is resolved to XML doc positional notation (``0), not the + /// raw Cecil source name, matching real compiler-emitted XML doc IDs. + /// + [Fact] + public void CrefTargetResolver_TryResolveMember_GenericMethodWithArrayParameter_ResolvesPositionalNotation() + { + // Arrange + var type = _assembly.MainModule.Types.First(t => t.Name == "CrefLinkingClass"); + var method = type.Methods.First(m => m.Name == "IdentityArray"); + var crefId = DotNetEmitter.BuildMemberId(method); + + // Assert: both the array-element parameter and the array-element return type resolve + // positionally, not to the literal source name "T" + Assert.Equal("M:ApiMark.DotNet.Fixtures.CrefLinkingClass.IdentityArray``1(``0[])", crefId); + + // Act + var resolved = _resolver.TryResolveMember(crefId, out var resolvedMember); + + // Assert + Assert.True(resolved); + Assert.Same(method, resolvedMember); + } } diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index 729178b..4700472 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1,6 +1,7 @@ using ApiMark.Core; using ApiMark.Core.TestHelpers; using ApiMark.DotNet; +using Mono.Cecil; using Xunit; namespace ApiMark.DotNet.Tests; @@ -2713,5 +2714,74 @@ private static string ResolveRelativeLink(string sourceFolder, string relativePa return string.Join('/', stack); } + + /// + /// Validates that maps an + /// explicit indexer getter override reference to the interface property's XML doc ID, + /// including the index parameter type list. + /// + [Fact] + public void DotNetGenerator_MapAccessorReferenceToPropertyId_ExplicitIndexerGetterOverride_IncludesIndexParameter() + { + // Arrange + using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var implType = assembly.MainModule.Types.First(t => t.Name == "ExplicitIndexerClass"); + var property = implType.Properties.Single(); + var overrideRef = property.GetMethod!.Overrides.Single(); + + // Act + var propertyId = DotNetGenerator.MapAccessorReferenceToPropertyId(overrideRef, "get_"); + + // Assert + Assert.Equal("P:ApiMark.DotNet.Fixtures.IIndexerSource.Item(System.Int32)", propertyId); + } + + /// + /// Validates that maps an + /// explicit indexer setter override reference to the interface property's XML doc ID, + /// including the index parameter type list but excluding the trailing value parameter. + /// + [Fact] + public void DotNetGenerator_MapAccessorReferenceToPropertyId_ExplicitIndexerSetterOverride_ExcludesValueParameter() + { + // Arrange + using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var implType = assembly.MainModule.Types.First(t => t.Name == "ExplicitIndexerClass"); + var property = implType.Properties.Single(); + var overrideRef = property.SetMethod!.Overrides.Single(); + + // Act + var propertyId = DotNetGenerator.MapAccessorReferenceToPropertyId(overrideRef, "set_"); + + // Assert: the trailing "value" parameter is excluded; only the index parameter remains + Assert.Equal("P:ApiMark.DotNet.Fixtures.IIndexerSource.Item(System.Int32)", propertyId); + } + + /// + /// Validates that (used to resolve + /// explicit-interface-override targets) produces the same ``N generic-arity suffix + /// as (used to build the interface method's own + /// ID), so an explicit override of a generic interface method correctly links to it. + /// + [Fact] + public void DotNetGenerator_BuildMethodIdFromReference_ExplicitGenericInterfaceOverride_MatchesInterfaceMethodId() + { + // Arrange + using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll()); + var implType = assembly.MainModule.Types.First(t => t.Name == "GenericOverrideClass"); + var ifaceType = assembly.MainModule.Types.First(t => t.Name == "IGenericOverrideSource"); + var overrideMethod = implType.Methods.First(m => m.Overrides.Count > 0); + var overrideRef = overrideMethod.Overrides.Single(); + var ifaceMethod = ifaceType.Methods.Single(m => m.Name == "Wrap"); + + // Act + var targetIdFromOverrideReference = DotNetGenerator.BuildMethodIdFromReference(overrideRef); + var ifaceMethodId = DotNetEmitter.BuildMemberId(ifaceMethod); + + // Assert: both builders must produce the identical ID, including the ``1 arity suffix, + // for the explicit override to resolve against the interface method's documentation + Assert.Equal(ifaceMethodId, targetIdFromOverrideReference); + Assert.Contains("``1", targetIdFromOverrideReference); + } } From bfc541a39b69baa30a5859d93e9a7d65db8e45b3 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 8 Oct 2026 09:41:11 -0400 Subject: [PATCH 12/13] Resolve cref links for explicit-label see/seealso references - GetInlineReferenceText now resolves and links the cref attribute even when the / element has explicit display text, wrapping that explicit label in the target link instead of leaving it unlinked. Falls back to the unlinked label exactly as before when there is no link context or the cref does not resolve. - Add 3 unit tests covering the resolved/linked, null-context, and unresolved-cref fallback cases for the explicit-label path. - Update xml-doc-reader.md design/verification docs and reqstream test links to reflect the new behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../design/api-mark-dot-net/xml-doc-reader.md | 44 +++++--- .../api-mark-dot-net/xml-doc-reader.yaml | 3 + .../api-mark-dot-net/xml-doc-reader.md | 24 ++++ src/ApiMark.DotNet/XmlDocReader.cs | 12 +- .../ApiMark.DotNet.Tests/XmlDocReaderTests.cs | 104 ++++++++++++++++++ 5 files changed, 170 insertions(+), 17 deletions(-) diff --git a/docs/design/api-mark-dot-net/xml-doc-reader.md b/docs/design/api-mark-dot-net/xml-doc-reader.md index b268200..3ade87b 100644 --- a/docs/design/api-mark-dot-net/xml-doc-reader.md +++ b/docs/design/api-mark-dot-net/xml-doc-reader.md @@ -458,7 +458,12 @@ The following are deliberately left unwrapped: - A `langword` attribute value (e.g. `null`, `true`, `false`) — not a reference to a declared symbol at all. - Explicit inner element text (the element supplies its own display text, - which this method always prefers verbatim over any `cref` formatting). + which this method always prefers verbatim over any `cref`-formatted text). + The explicit label is still passed to `TryLinkifyCref` alongside the + element's own `cref` attribute — see "Cross-reference linking" below — so a + resolvable, emitted cref with a custom label (e.g. `the validator`) still becomes a real link, just without a + code-span wrapper around the label. - A constructor cref (`M:...#ctor`) — `FormatMemberReference` collapses it to the bare type name and reports `ShouldWrapInCodeSpan: false`. @@ -572,20 +577,29 @@ link's label; otherwise returns the code-span text unchanged. linkContext.CurrentFolder, linkContext.MemberPageIndex)`. Falls back to `codeSpanText` unchanged in every other case — external type, filtered-out member, or malformed/unresolvable cref — identical to today's rendering. -- *Call site*: `GetInlineReferenceText`'s ``/`` cref branch - calls `TryLinkifyCref` only when `FormatCref` already reported - `ShouldWrapInCodeSpan: true` (i.e. the exact same subset of crefs that were - already eligible for code-span wrapping before this feature existed — - constructor crefs are excluded by construction, since they report - `ShouldWrapInCodeSpan: false` and short-circuit before `TryLinkifyCref` is - ever called). The code span is built first via `FormatAsInlineCodeSpan`, - and that code-span string is then passed into `TryLinkifyCref` as the - link's label, so a successful link renders as `` [`Type.Member`](path.md) `` - — the code span nested inside the link's label — while an unresolved cref - renders exactly as it does today, `` `Type.Member` ``. The nesting is - deliberately link-outside/code-span-inside rather than the reverse: Markdown - code-span content is rendered literally, so a link placed inside a code span - would show as inert `[...](...)` text rather than a clickable link. +- *Call site*: `GetInlineReferenceText` has two call sites for + `TryLinkifyCref`, both only reached when a `cref` attribute is present: + - The explicit-label branch (element has its own display text) calls + `TryLinkifyCref` with that explicit text, unwrapped, as the label — so a + successful link renders as `[the validator](path.md)`, preserving the + author's chosen wording rather than substituting the formatted `cref` + text. + - The no-explicit-text branch calls `TryLinkifyCref` only when `FormatCref` + already reported `ShouldWrapInCodeSpan: true` (i.e. the exact same subset + of crefs that were already eligible for code-span wrapping before this + feature existed — constructor crefs are excluded by construction, since + they report `ShouldWrapInCodeSpan: false` and short-circuit before + `TryLinkifyCref` is ever called). The code span is built first via + `FormatAsInlineCodeSpan`, and that code-span string is then passed into + `TryLinkifyCref` as the link's label, so a successful link renders as + `` [`Type.Member`](path.md) `` — the code span nested inside the link's + label. + - In both cases, an unresolved cref falls back to the label unchanged + (plain explicit text, or `` `Type.Member` ``), exactly as it did before + this feature existed. The nesting is deliberately link-outside/code-span- + inside rather than the reverse: Markdown code-span content is rendered + literally, so a link placed inside a code span would show as inert + `[...](...)` text rather than a clickable link. ### Error Handling diff --git a/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml b/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml index 27f6b28..37bf3a1 100644 --- a/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml +++ b/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml @@ -279,6 +279,9 @@ sections: - XmlDocReader_GetSummary_MalformedCref_FallsBackUnchanged - XmlDocReader_GetSummary_ConstructorCref_FallsBackUnchanged - XmlDocReader_GetRemarks_SeeAlsoResolvedEmittedType_RendersLinkedCodeSpan + - XmlDocReader_GetSummary_ExplicitLabelResolvedEmittedType_RendersLinkedLabel + - XmlDocReader_GetSummary_ExplicitLabelNullLinkContext_RendersLabelOnlyNoLink + - XmlDocReader_GetSummary_ExplicitLabelUnresolvedCref_RendersLabelOnlyNoLink - CrefLinking_SeeCrefToVisibleType_RendersAsMarkdownLink - CrefLinking_SeeCrefToVisibleMember_RendersAsMarkdownLink - CrefLinking_SeeCrefToFilteredMember_RendersFallbackWithNoLink diff --git a/docs/verification/api-mark-dot-net/xml-doc-reader.md b/docs/verification/api-mark-dot-net/xml-doc-reader.md index 575874e..1e17604 100644 --- a/docs/verification/api-mark-dot-net/xml-doc-reader.md +++ b/docs/verification/api-mark-dot-net/xml-doc-reader.md @@ -100,6 +100,8 @@ assembly is needed. - A malformed/unresolvable `cref` string falls back to code-span-only rendering even with a non-null `linkContext`. - A `` to a constructor falls back to its existing unwrapped-text rendering even with a non-null `linkContext` — constructors are never linked. - A `` to a resolved, emitted intra-assembly type (nested inside ``) produces a code span nested inside a Markdown link, confirming `` shares the same `TryLinkifyCref` rendering path as ``. +- A `` carrying an explicit display label (e.g. `custom label`) that resolves to an emitted intra-assembly type is rendered with that label wrapped in a Markdown link, instead of being left unlinked merely because it has a custom label. +- A `` with an explicit display label and no `linkContext`, or whose `cref` does not resolve, falls back to the plain label text with no link, preserving the existing fallback exactly. ### Test Scenarios @@ -668,3 +670,25 @@ the technical notes) shares the exact same `TryLinkifyCref` rendering path as ``, producing a code span nested inside a Markdown link when its target resolves and is reported as emitted. This scenario is tested by `XmlDocReader_GetRemarks_SeeAlsoResolvedEmittedType_RendersLinkedCodeSpan`. + +**A `` with an explicit label renders that label as a linked, resolved +reference**: Verifies that `custom label` — previously +returning the explicit label unconditionally before the `cref` was ever +consulted — now still resolves the `cref` and, when it resolves to an +emitted intra-assembly type, wraps the explicit label itself in a Markdown +link (e.g. `[the sample class](SampleClass.md)`), rather than leaving a +resolvable reference unlinked merely because it carries a custom label. This +scenario is tested by +`XmlDocReader_GetSummary_ExplicitLabelResolvedEmittedType_RendersLinkedLabel`. + +**A `` with an explicit label and no linkContext renders the label +unlinked**: Verifies that, without a `linkContext`, an explicit label renders +exactly as it did before this feature existed — the plain label text, with no +link. This scenario is tested by +`XmlDocReader_GetSummary_ExplicitLabelNullLinkContext_RendersLabelOnlyNoLink`. + +**A `` with an explicit label and an unresolved cref renders the +label unlinked**: Verifies that an explicit label whose `cref` does not +resolve (external/malformed) falls back to the plain label text, with no +link, preserving the existing fallback exactly. This scenario is tested by +`XmlDocReader_GetSummary_ExplicitLabelUnresolvedCref_RendersLabelOnlyNoLink`. diff --git a/src/ApiMark.DotNet/XmlDocReader.cs b/src/ApiMark.DotNet/XmlDocReader.cs index a8a8f63..4d6efae 100644 --- a/src/ApiMark.DotNet/XmlDocReader.cs +++ b/src/ApiMark.DotNet/XmlDocReader.cs @@ -1445,11 +1445,19 @@ private static string GetInlineReferenceText(XElement element, CrefLinkContext? return langword; } - // If the element provides explicit display text, prefer it over formatting the cref. + // If the element provides explicit display text, prefer it over formatting the cref, + // but still attempt to resolve and link the cref using that explicit text as the link's + // label — otherwise a valid in-assembly reference with a custom label (e.g. + // the validator) would never become a clickable + // link. TryLinkifyCref returns the label unchanged when there is no link context or the + // cref does not resolve, preserving the existing fallback exactly. var explicitText = NormalizeDocumentationText(element.Value); if (explicitText.Length > 0) { - return explicitText; + var explicitCref = element.Attribute("cref")?.Value; + return string.IsNullOrWhiteSpace(explicitCref) + ? explicitText + : TryLinkifyCref(explicitCref, explicitText, linkContext); } var cref = element.Attribute("cref")?.Value; diff --git a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs index 27b967a..86f79e6 100644 --- a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs +++ b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs @@ -3684,5 +3684,109 @@ public void XmlDocReader_GetRemarks_SeeAlsoResolvedEmittedType_RendersLinkedCode } } + /// + /// Validates that a <see cref> element carrying an explicit display label + /// (e.g. <see cref="...">the validator</see>) still resolves and links + /// the cref when it refers to a resolvable, emitted intra-assembly type, wrapping + /// the explicit label itself in the link rather than falling back to an unlinked label. + /// + [Fact] + public void XmlDocReader_GetSummary_ExplicitLabelResolvedEmittedType_RendersLinkedLabel() + { + // Arrange + var (assembly, context) = BuildFixtureLinkContext(); + try + { + var path = WriteXmlDoc(""" + + See the sample class. + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var summary = reader.GetSummary("T:Foo.Bar", context); + + // Assert + Assert.Equal("See [the sample class](SampleClass.md).", summary); + } + finally + { + File.Delete(path); + } + } + finally + { + assembly.Dispose(); + } + } + + /// + /// Validates that a <see cref> element with an explicit display label falls + /// back to the unlinked, explicit-label-only rendering (existing behavior) when there is + /// no link context, matching the no-context baseline for a cref-only reference. + /// + [Fact] + public void XmlDocReader_GetSummary_ExplicitLabelNullLinkContext_RendersLabelOnlyNoLink() + { + // Arrange + var path = WriteXmlDoc(""" + + See the sample class. + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var summary = reader.GetSummary("T:Foo.Bar", null); + + // Assert + Assert.Equal("See the sample class.", summary); + } + finally + { + File.Delete(path); + } + } + + /// + /// Validates that a <see cref> element with an explicit display label falls + /// back to the unlinked, explicit-label-only rendering when the cref does not + /// resolve (external/malformed), preserving the existing fallback exactly. + /// + [Fact] + public void XmlDocReader_GetSummary_ExplicitLabelUnresolvedCref_RendersLabelOnlyNoLink() + { + // Arrange + var (assembly, context) = BuildFixtureLinkContext(); + try + { + var path = WriteXmlDoc(""" + + See the exception type. + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var summary = reader.GetSummary("T:Foo.Bar", context); + + // Assert + Assert.Equal("See the exception type.", summary); + } + finally + { + File.Delete(path); + } + } + finally + { + assembly.Dispose(); + } + } + #endregion } From c9740bbdd1802ec6592b4a0c3a1a13b733b60954 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 8 Oct 2026 10:15:04 -0400 Subject: [PATCH 13/13] Never link a labeled constructor cref in GetInlineReferenceText - The explicit-label branch now checks FormatCref's ShouldWrapInCodeSpan classification before calling TryLinkifyCref, so a constructor cref (M:...#ctor) is never linked even when the / element has an explicit display label - matching the no-label branch's existing precedent that constructor crefs are never linked. - Add a regression test (XmlDocReader_GetSummary_ExplicitLabelConstructorCref_RendersLabelOnlyNoLink) covering a labeled constructor cref whose declaring type is otherwise resolvable and emitted. - Update xml-doc-reader.md design/verification docs and reqstream test links accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../design/api-mark-dot-net/xml-doc-reader.md | 22 ++++++++--- .../api-mark-dot-net/xml-doc-reader.yaml | 1 + .../api-mark-dot-net/xml-doc-reader.md | 9 +++++ src/ApiMark.DotNet/XmlDocReader.cs | 15 +++++-- .../ApiMark.DotNet.Tests/XmlDocReaderTests.cs | 39 +++++++++++++++++++ 5 files changed, 77 insertions(+), 9 deletions(-) diff --git a/docs/design/api-mark-dot-net/xml-doc-reader.md b/docs/design/api-mark-dot-net/xml-doc-reader.md index 3ade87b..3468797 100644 --- a/docs/design/api-mark-dot-net/xml-doc-reader.md +++ b/docs/design/api-mark-dot-net/xml-doc-reader.md @@ -463,9 +463,15 @@ The following are deliberately left unwrapped: element's own `cref` attribute — see "Cross-reference linking" below — so a resolvable, emitted cref with a custom label (e.g. `the validator`) still becomes a real link, just without a - code-span wrapper around the label. + code-span wrapper around the label. A constructor cref is the one + exception: it is never linked even with an explicit label, matching the + next bullet. - A constructor cref (`M:...#ctor`) — `FormatMemberReference` collapses it to - the bare type name and reports `ShouldWrapInCodeSpan: false`. + the bare type name and reports `ShouldWrapInCodeSpan: false`. The + explicit-label branch consults this same `ShouldWrapInCodeSpan` result + before calling `TryLinkifyCref`, so a labeled reference to a constructor + (e.g. `the constructor`) renders its label + unlinked too. #### Generic cref escaping inside a code span @@ -580,10 +586,14 @@ link's label; otherwise returns the code-span text unchanged. - *Call site*: `GetInlineReferenceText` has two call sites for `TryLinkifyCref`, both only reached when a `cref` attribute is present: - The explicit-label branch (element has its own display text) calls - `TryLinkifyCref` with that explicit text, unwrapped, as the label — so a - successful link renders as `[the validator](path.md)`, preserving the - author's chosen wording rather than substituting the formatted `cref` - text. + `FormatCref` first purely to read its `ShouldWrapInCodeSpan` + classification (discarding the formatted text, since the explicit label + is always used), and only calls `TryLinkifyCref` — with that explicit + text, unwrapped, as the label — when it is `true`. A successful link + renders as `[the validator](path.md)`, preserving the author's chosen + wording rather than substituting the formatted `cref` text. A labeled + constructor cref (`ShouldWrapInCodeSpan: false`) is never linked, + matching the no-explicit-text branch's precedent below. - The no-explicit-text branch calls `TryLinkifyCref` only when `FormatCref` already reported `ShouldWrapInCodeSpan: true` (i.e. the exact same subset of crefs that were already eligible for code-span wrapping before this diff --git a/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml b/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml index 37bf3a1..21dd643 100644 --- a/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml +++ b/docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml @@ -282,6 +282,7 @@ sections: - XmlDocReader_GetSummary_ExplicitLabelResolvedEmittedType_RendersLinkedLabel - XmlDocReader_GetSummary_ExplicitLabelNullLinkContext_RendersLabelOnlyNoLink - XmlDocReader_GetSummary_ExplicitLabelUnresolvedCref_RendersLabelOnlyNoLink + - XmlDocReader_GetSummary_ExplicitLabelConstructorCref_RendersLabelOnlyNoLink - CrefLinking_SeeCrefToVisibleType_RendersAsMarkdownLink - CrefLinking_SeeCrefToVisibleMember_RendersAsMarkdownLink - CrefLinking_SeeCrefToFilteredMember_RendersFallbackWithNoLink diff --git a/docs/verification/api-mark-dot-net/xml-doc-reader.md b/docs/verification/api-mark-dot-net/xml-doc-reader.md index 1e17604..0c29c3d 100644 --- a/docs/verification/api-mark-dot-net/xml-doc-reader.md +++ b/docs/verification/api-mark-dot-net/xml-doc-reader.md @@ -102,6 +102,7 @@ assembly is needed. - A `` to a resolved, emitted intra-assembly type (nested inside ``) produces a code span nested inside a Markdown link, confirming `` shares the same `TryLinkifyCref` rendering path as ``. - A `` carrying an explicit display label (e.g. `custom label`) that resolves to an emitted intra-assembly type is rendered with that label wrapped in a Markdown link, instead of being left unlinked merely because it has a custom label. - A `` with an explicit display label and no `linkContext`, or whose `cref` does not resolve, falls back to the plain label text with no link, preserving the existing fallback exactly. +- A `` carrying an explicit display label that targets a constructor is never linked, even when the constructor's declaring type is a resolvable, emitted intra-assembly type — constructors are never linked regardless of whether the reference has an explicit label. ### Test Scenarios @@ -692,3 +693,11 @@ label unlinked**: Verifies that an explicit label whose `cref` does not resolve (external/malformed) falls back to the plain label text, with no link, preserving the existing fallback exactly. This scenario is tested by `XmlDocReader_GetSummary_ExplicitLabelUnresolvedCref_RendersLabelOnlyNoLink`. + +**A `` with an explicit label targeting a constructor renders the +label unlinked**: Verifies that an explicit label is never linked when its +`cref` targets a constructor, even though the constructor's declaring type is +a resolvable, emitted intra-assembly type — matching the no-label branch's +existing precedent that a constructor cref's `ShouldWrapInCodeSpan` result +(always `false`) is never linked. This scenario is tested by +`XmlDocReader_GetSummary_ExplicitLabelConstructorCref_RendersLabelOnlyNoLink`. diff --git a/src/ApiMark.DotNet/XmlDocReader.cs b/src/ApiMark.DotNet/XmlDocReader.cs index 4d6efae..24081de 100644 --- a/src/ApiMark.DotNet/XmlDocReader.cs +++ b/src/ApiMark.DotNet/XmlDocReader.cs @@ -1455,9 +1455,18 @@ private static string GetInlineReferenceText(XElement element, CrefLinkContext? if (explicitText.Length > 0) { var explicitCref = element.Attribute("cref")?.Value; - return string.IsNullOrWhiteSpace(explicitCref) - ? explicitText - : TryLinkifyCref(explicitCref, explicitText, linkContext); + if (string.IsNullOrWhiteSpace(explicitCref)) + { + return explicitText; + } + + // A constructor cref is never linked, matching the no-label branch's precedent below + // (ShouldWrapInCodeSpan is false for constructors) — only FormatCref's classification + // is needed here, not its formatted text, since the explicit label is always used. + var (_, shouldLink) = FormatCref(explicitCref); + return shouldLink + ? TryLinkifyCref(explicitCref, explicitText, linkContext) + : explicitText; } var cref = element.Attribute("cref")?.Value; diff --git a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs index 86f79e6..6c874d3 100644 --- a/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs +++ b/test/ApiMark.DotNet.Tests/XmlDocReaderTests.cs @@ -3788,5 +3788,44 @@ public void XmlDocReader_GetSummary_ExplicitLabelUnresolvedCref_RendersLabelOnly } } + /// + /// Validates that a <see cref> element with an explicit display label is + /// never linked when the cref targets a constructor, even though the constructor's + /// declaring type is a resolvable, emitted intra-assembly type — matching the no-label + /// branch's existing precedent that a constructor cref's ShouldWrapInCodeSpan + /// result (always ) is never linked. + /// + [Fact] + public void XmlDocReader_GetSummary_ExplicitLabelConstructorCref_RendersLabelOnlyNoLink() + { + // Arrange + var (assembly, context) = BuildFixtureLinkContext(); + try + { + var path = WriteXmlDoc(""" + + See the constructor. + + """); + try + { + // Act + var reader = new XmlDocReader(path); + var summary = reader.GetSummary("T:Foo.Bar", context); + + // Assert + Assert.Equal("See the constructor.", summary); + } + finally + { + File.Delete(path); + } + } + finally + { + assembly.Dispose(); + } + } + #endregion }