Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 25 additions & 23 deletions docs/design/api-mark-dot-net/xml-doc-reader.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ according to the following element-to-text mappings:
table or list well-formed (a documented limitation).

**FormatCref** (private static): Converts a raw `cref` attribute value to a concise
display string, returning a `(string Text, bool IsMemberReference)` tuple.
display string, returning a `(string Text, bool ShouldWrapInCodeSpan)` tuple.

- *Algorithm*: Strips the type-kind prefix (`T:`, `M:`, `P:`, `F:`, `E:`); strips
the namespace path from the remaining qualified name to leave just the type and
Expand All @@ -389,10 +389,12 @@ display string, returning a `(string Text, bool IsMemberReference)` tuple.
`List<T>`,`Dictionary\`2` → `Dictionary<T1, T2>`); replaces`#ctor` with the
declaring type name for constructor crefs; appends `()` to method crefs that
include a parameter list.
- *Return contract*: `IsMemberReference` is `true` only when the kind-prefix
dispatch delegates to `FormatMemberReference` and that call itself returns
`true` (i.e. the cref is `M:`/`P:`/`F:`/`E:` and is not a constructor); it is
`false` for `T:` crefs and any other/unrecognized prefix. This single
- *Return contract*: `ShouldWrapInCodeSpan` is `false` only for a constructor
cref (the kind-prefix dispatch delegates to `FormatMemberReference`, which
returns `false` for its `#ctor` branch) or any other/unrecognized prefix.
It is `true` for `T:` crefs (the result is forwarded as-is from
`FormatTypeName`) and for `M:`/`P:`/`F:`/`E:` crefs that are not
constructors. This single
Comment thread
Malcolmnixon marked this conversation as resolved.
Resolved
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
Expand All @@ -401,7 +403,7 @@ display string, returning a `(string Text, bool IsMemberReference)` tuple.
**FormatMemberReference** (private): Formats a `<see cref>` reference whose
kind indicates a type member (`M:`, `P:`, `F:`, or `E:`) as `Type.Member` text,
used by `FormatCref` once the kind-prefix dispatch determines the cref targets
a member rather than a type. Returns a `(string Text, bool IsMemberReference)`
a member rather than a type. Returns a `(string Text, bool ShouldWrapInCodeSpan)`
tuple.

- *Parameters*: `char kind` — the single-character cref kind prefix (`M`, `P`,
Expand All @@ -412,7 +414,7 @@ tuple.
formats each individually, and always renders `{formattedTypeName}.{formattedMemberName}`
for every one of M/P/F/E — the declaring type name is never omitted. Method
crefs additionally append the formatted parameter list.
- *Return contract*: `IsMemberReference` is `false` only for the `kind == 'M'`
- *Return contract*: `ShouldWrapInCodeSpan` is `false` only for the `kind == 'M'`
constructor (`#ctor`) branch, which returns the bare type name instead of a
`Type.Member` form; every other M/P/F/E branch (including the no-dot
fallback, which returns the raw target unchanged) returns `true`.
Expand All @@ -432,26 +434,25 @@ tuple.
cross-reference link would require an architectural change beyond this
method's scope.

#### Member-cref inline code span wrapping
#### Member and type-only cref inline code span wrapping

`GetInlineReferenceText` wraps a `<see>`/`<seealso>` reference's display text
in an inline Markdown code span (via `FormatAsInlineCodeSpan`/
`AppendMarkdownCodeSpan`) when that text was derived from a `cref` attribute
and `FormatCref` reports `IsMemberReference: true` for it — i.e. the cref
targets a property, field, event, or non-constructor method. This makes a
member reference read visually distinct from surrounding prose, matching how
`<c>` content is already rendered, and was driven by a real-world consumer
report where `Type.Member` text rendered indistinguishably from plain prose.
and `FormatCref` reports `ShouldWrapInCodeSpan: true` for it — i.e. the cref
targets a type, or a property, field, event, or non-constructor method. This
makes a type or member reference read visually distinct from surrounding
prose, matching how `<c>` content is already rendered, and was driven by
real-world consumer reports where `Type.Member` text, and then a bare type
name, rendered indistinguishably from plain prose.
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).
- A type-only (`T:`) cref — `FormatCref` reports `IsMemberReference: false`.
- A constructor cref (`M:...#ctor`) — `FormatMemberReference` collapses it to
the bare type name and reports `IsMemberReference: false`, matching how a
type-only cref is treated.
the bare type name and reports `ShouldWrapInCodeSpan: false`.

#### Generic cref escaping inside a code span

Expand All @@ -469,17 +470,18 @@ appear as a stray visible character in the output (rendering as
`List\<T\>.Add()` instead of the intended `List<T>.Add()`).

- `GetInlineReferenceText` passes `forCodeSpan: true` into `FormatCref`,
since a member-cref's result is always subsequently wrapped in a code span
(see "Member-cref inline code span wrapping" above) when
`IsMemberReference` is `true`.
since a type or member cref's result is always subsequently wrapped in a
code span (see "Member and type-only cref inline code span wrapping" above)
when `ShouldWrapInCodeSpan` is `true`.
- Each layer of the call chain makes its own locally-correct decision about
whether `forCodeSpan` actually applies to the branch it is in, rather than
blindly forwarding the caller's request:
- `FormatCref`'s type-only (`T:`) branch always hardcodes `forCodeSpan:
false`, because a type-only cref's result is never wrapped in a code span
regardless of what the caller requested.
- `FormatCref`'s type-only (`T:`) branch forwards the caller's
`forCodeSpan` value unchanged into `FormatTypeName`, because a type-only
cref's result is now always wrapped in a code span by
`GetInlineReferenceText`.
- `FormatMemberReference`'s constructor (`#ctor`) branch always hardcodes
`forCodeSpan: false` for the same reason — a constructor cref collapses
`forCodeSpan: false`, because a constructor cref collapses
to the bare type name, which is never wrapped.
- Every other `FormatMemberReference`/`FormatTypeName` branch forwards the
caller's `forCodeSpan` value unchanged.
Expand Down
17 changes: 11 additions & 6 deletions docs/reqstream/api-mark-dot-net/xml-doc-reader.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,9 @@ sections:
- XmlDocReader_GetRemarks_BrAndCodeFromGeneratedRegexExample_RendersAsDistinctBlocks
- id: ApiMarkDotNet-XmlDocReader-WrapMemberCrefDisplayInCodeSpan
title: XmlDocReader shall render the display text of a `<see>`/`<seealso>`
reference to a type member (property, field, event, or method) in an
inline Markdown code span, while leaving `langword`, explicit inner
text, type-only (`T:`) crefs, and constructor crefs unwrapped.
reference to a type or type member (property, field, event, or method)
in an inline Markdown code span, while leaving `langword`, explicit
inner text, and constructor crefs unwrapped.
justification: |
A real-world consumer (DemaConsulting.Bicameral.Core) reported that a
`<see cref="Type.Member"/>` reference rendered as plain prose
Expand All @@ -225,17 +225,22 @@ sections:
use raw, unescaped angle brackets (`List<T>`, not `List\<T\>`): a code
span's content is literal, so the backslash escaping needed to keep
prose safe from being parsed as an HTML tag would otherwise show up as
stray visible characters. A type-only (`T:`) cref is never wrapped in a
code span, so it keeps the escaped form.
stray visible characters. The same consumer additionally reported that a
type-only (`T:`) cref — e.g. `<see cref="ArgumentValidator"/>` — still
rendered as plain prose, inconsistent with a member cref right next to
it; a type-only cref is now wrapped the same as a member cref, so a
reference always reads as code regardless of whether it targets a type
or one of its members.
tests:
- XmlDocReader_GetSummary_SeeCrefToProperty_RendersTypeDotMember
- XmlDocReader_GetSummary_SeeCrefToField_RendersTypeDotMember
- XmlDocReader_GetSummary_SeeCrefToEvent_RendersTypeDotMember
- XmlDocReader_GetSummary_SeeCrefToMethod_StillRendersTypeDotMember
- XmlDocReader_GetSummary_WithSeeGenericTypeCref_FormatsWithTypeParameters
- XmlDocReader_GetSummary_SeeCrefToTypeOnly_RendersAsInlineCodeSpan
- XmlDocReader_GetSummary_SeeCrefToConstructor_RendersUnwrappedTypeName
- XmlDocReader_GetSummary_SeeCrefToMemberOnGenericType_RendersUnescapedAngleBracketsInCodeSpan
- XmlDocReader_GetSummary_SeeCrefToGenericTypeOnly_StillEscapesAngleBracketsInProse
- XmlDocReader_GetSummary_SeeCrefToGenericTypeOnly_RendersUnescapedAngleBracketsInCodeSpan
- XmlDocReader_GetSummary_SeeCrefWithEmptyMemberTarget_DoesNotThrowAndRendersEmpty
- XmlDocReader_GetSummary_WithInlineReferences_PreservesReferencedNames
- XmlDocReader_GetExampleParts_WithSeeCref_RendersInlineReferenceInProsePart
Expand Down
27 changes: 16 additions & 11 deletions docs/verification/api-mark-dot-net/xml-doc-reader.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,11 +479,17 @@ are additionally now wrapped in an inline code span along with the other
member kinds. This scenario is tested by
`XmlDocReader_GetSummary_SeeCrefToMethod_StillRendersTypeDotMember`.

**GetSummary renders a type-only (`T:`) cref unwrapped (regression guard)**: Verifies that a
`<see cref="T:..." />` reference to a type — rather than a type member — renders its display
text as plain prose with no surrounding inline code span, confirmed via an exact string match
(not a substring match) so a wrapping regression would be caught. This scenario is tested by
`XmlDocReader_GetSummary_WithSeeGenericTypeCref_FormatsWithTypeParameters`.
**GetSummary wraps a type-only (`T:`) cref in a code span**: Verifies that a
`<see cref="T:..." />` reference to a type with an arity marker — rather than a type member —
renders with raw, unescaped angle brackets inside a backtick code span, matching the
generic-type-parameter placeholder handling used for the member-cref case. This scenario is
tested by `XmlDocReader_GetSummary_WithSeeGenericTypeCref_FormatsWithTypeParameters`.
Comment thread
Malcolmnixon marked this conversation as resolved.
Resolved

**GetSummary wraps a plain type-only cref in a code span (mandatory regression guard)**:
Verifies that `<see cref="T:Foo.ArgumentValidator"/>`, with no explicit display text, is wrapped
in an inline code span the same as a member cref — fixing a reported inconsistency where a
type reference rendered as plain prose right next to a member reference rendered as code. This
scenario is tested by `XmlDocReader_GetSummary_SeeCrefToTypeOnly_RendersAsInlineCodeSpan`.

**GetSummary renders a constructor (`#ctor`) cref unwrapped as the bare type name**: Verifies
that `<see cref="M:Type.#ctor" />` — a method-kind cref whose member name is the special
Expand All @@ -508,12 +514,11 @@ result is wrapped in a code span and a code span's content is literal. This
scenario is tested by
`XmlDocReader_GetSummary_SeeCrefToMemberOnGenericType_RendersUnescapedAngleBracketsInCodeSpan`.

**GetSummary still escapes angle brackets for a type-only cref to a generic
type (regression guard)**: Verifies that `` `<see cref="T:...List`1" />` `` —
a type-only cref, never wrapped in a code span — still renders the
backslash-escaped prose form `List\<T\>`, confirming the `forCodeSpan`
opt-out is scoped correctly to member crefs only. This scenario is tested by
`XmlDocReader_GetSummary_SeeCrefToGenericTypeOnly_StillEscapesAngleBracketsInProse`.
**GetSummary renders a type-only cref to a generic type with unescaped angle brackets inside its
code span**: Verifies that `` `<see cref="T:...List`1" />` `` — a type-only cref — is wrapped the
same as a member cref, rendering `` `List<T>` `` with raw, unescaped angle brackets rather than
the backslash-escaped prose form `List\<T\>`. This scenario is tested by
`XmlDocReader_GetSummary_SeeCrefToGenericTypeOnly_RendersUnescapedAngleBracketsInCodeSpan`.

**GetRemarks renders a `<br/>` element as a paragraph break**: Verifies that
`<br/>` inserts a blank-line paragraph break between the surrounding text
Expand Down
45 changes: 22 additions & 23 deletions src/ApiMark.DotNet/XmlDocReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1317,12 +1317,12 @@ private static int ComputeFenceLength(string content)
/// </summary>
/// <remarks>
/// When the display text is ultimately derived from a <c>cref</c> attribute that refers to
/// a type member (property, field, event, or method — see
/// a type or type member (property, field, event, or method — see
/// <see cref="FormatCref"/>/<see cref="FormatMemberReference"/>), it is wrapped in an
/// inline Markdown code span via <see cref="FormatAsInlineCodeSpan"/> so it reads visually
/// distinct from surrounding prose, matching how <c>&lt;c&gt;</c> content is rendered. A
/// <c>langword</c> value, explicit inner text, a type-only (<c>T:</c>) cref, and a
/// constructor cref are all left unwrapped.
/// <c>langword</c> value, explicit inner text, and a constructor cref are all left
/// unwrapped.
/// </remarks>
/// <param name="element">The inline reference element to render.</param>
/// <returns>A non-null display string; may be empty when no renderable content is found.</returns>
Expand All @@ -1344,13 +1344,13 @@ private static string GetInlineReferenceText(XElement element)
var cref = element.Attribute("cref")?.Value;
if (!string.IsNullOrWhiteSpace(cref))
{
// Request the code-span (unescaped) rendering since a member reference is about to
// be wrapped in backticks, where escaped angle brackets would show literal
// backslashes — see FormatCref's forCodeSpan parameter. Non-member results (type-only
// and constructor crefs) always ignore this request and return the escaped,
// prose-safe form internally, since they are never wrapped.
var (text, isMemberReference) = FormatCref(cref, forCodeSpan: true);
return isMemberReference && text.Length > 0 ? FormatAsInlineCodeSpan(text) : text;
// Request the code-span (unescaped) rendering since the result is about to be
// wrapped in backticks, where escaped angle brackets would show literal backslashes
// — see FormatCref's forCodeSpan parameter. Only the constructor-cref result ignores
// 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;
}

return string.Empty;
Expand Down Expand Up @@ -1378,19 +1378,19 @@ private static string FormatAsInlineCodeSpan(string text)
/// When <see langword="true"/>, requests the unescaped, code-span-safe rendering of any
/// generic angle-bracket notation (e.g. <c>List&lt;T&gt;</c> rather than the prose-escaped
/// <c>List\&lt;T\&gt;</c>) for results that will be wrapped in an inline Markdown code span
/// by the caller. This request only takes effect for the member-reference branch (the one
/// whose <c>IsMemberReference</c> result is <see langword="true"/>) — the type-only and
/// constructor branches are never wrapped, so they always return the escaped, prose-safe
/// form regardless of this flag.
/// by the caller. This request only takes effect for branches whose <c>ShouldWrapInCodeSpan</c>
/// result is <see langword="true"/> — the constructor branch is never wrapped, so it always
/// returns the escaped, prose-safe form regardless of this flag.
/// </param>
/// <returns>
/// A concise display name (e.g. <c>ArgumentNullException</c> or <c>Bar.Go()</c>) together with
/// <see langword="true"/> when the cref refers to a type member (property, field, event, or
/// non-constructor method) rather than a type or constructor — the single source of truth
/// <see cref="GetInlineReferenceText"/> uses to decide whether to wrap the text in an inline
/// code span.
/// <see langword="true"/> unless the cref refers to a constructor — the single source of
/// truth <see cref="GetInlineReferenceText"/> uses to decide whether to wrap the text in an
/// inline code span. A type-only cref (e.g. <c>ArgumentValidator</c>) is wrapped the same as a
/// member reference, so an inline reference always reads as code regardless of whether it
/// targets a type or one of its members.
/// </returns>
private static (string Text, bool IsMemberReference) FormatCref(string cref, bool forCodeSpan = false)
private static (string Text, bool ShouldWrapInCodeSpan) FormatCref(string cref, bool forCodeSpan = false)
{
var separatorIndex = cref.IndexOf(':');
var kind = separatorIndex > 0 ? cref[0] : '\0';
Expand All @@ -1402,8 +1402,7 @@ private static (string Text, bool IsMemberReference) FormatCref(string cref, boo

return kind switch
{
// Never wrapped in a code span — always use the escaped, prose-safe form.
'T' => (FormatTypeName(memberTarget, forCodeSpan: false), false),
'T' => (FormatTypeName(memberTarget, forCodeSpan), true),
Comment thread
Malcolmnixon marked this conversation as resolved.
Resolved
'M' or 'P' or 'F' or 'E' => FormatMemberReference(kind, memberTarget, parameters, forCodeSpan),
_ => (target, false),
};
Expand All @@ -1428,7 +1427,7 @@ private static (string Text, bool IsMemberReference) FormatCref(string cref, boo
/// <paramref name="target"/> names a constructor (<c>#ctor</c>), in which case the type name
/// alone is returned with <see langword="false"/>.
/// </returns>
private static (string Text, bool IsMemberReference) FormatMemberReference(
private static (string Text, bool ShouldWrapInCodeSpan) FormatMemberReference(
char kind,
string target,
string parameters,
Expand All @@ -1445,7 +1444,7 @@ private static (string Text, bool IsMemberReference) FormatMemberReference(

if (kind == 'M' && memberName == "#ctor")
{
// Never wrapped in a code span (IsMemberReference is false here) — always escaped.
// Never wrapped in a code span (ShouldWrapInCodeSpan is false here) — always escaped.
return (FormatTypeName(typeName, forCodeSpan: false), false);
}

Expand Down
2 changes: 1 addition & 1 deletion test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ public void DotNetGenerator_Generate_InlineReferences_PreserveSymbolNamesInMarkd
// Assert
var typeWriter = factory.Writers["ApiMark.DotNet.Fixtures/SampleStatusExtensions"];
var typeParagraphs = typeWriter.Operations.OfType<ParagraphOperation>().Select(p => p.Text).ToList();
Assert.Contains("Extensions for the SampleStatus enum.", typeParagraphs);
Assert.Contains("Extensions for the `SampleStatus` enum.", typeParagraphs);

var memberWriters = factory.Writers
.Where(kvp => kvp.Key.StartsWith("ApiMark.DotNet.Fixtures/SampleStatusExtensions/IsPassed", StringComparison.Ordinal))
Expand Down
Loading
Loading