Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
38 changes: 33 additions & 5 deletions src/ApiMark.DotNet/DotNetEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ internal static string BuildDelegateSignature(TypeDefinition type, string contex
}

var parameters = string.Join(", ", invoke.Parameters.Select(p =>
$"{TypeNameSimplifier.Simplify(p.ParameterType, contextNamespace)} {p.Name}"));
$"{GetRefKindKeyword(p)}{TypeNameSimplifier.Simplify(p.ParameterType, contextNamespace)} {p.Name}"));

return $"public delegate {returnType} {name}({parameters})";
}
Expand Down Expand Up @@ -692,7 +692,7 @@ internal static string BuildMethodSignature(MethodDefinition method, string cont
p.ParameterType,
contextNamespace,
HasNullableAnnotation(p.CustomAttributes));
return $"{receiverPrefix}{paramType} {p.Name}";
return $"{receiverPrefix}{GetRefKindKeyword(p)}{paramType} {p.Name}";
Comment thread
Malcolmnixon marked this conversation as resolved.
Resolved
}));

var accessibility = GetAccessibilityKeyword(method);
Expand All @@ -702,6 +702,34 @@ internal static string BuildMethodSignature(MethodDefinition method, string cont
: $"{accessibility}{staticModifier} {returnType} {name}({parameters})";
}

/// <summary>
/// Returns the C# reference-kind keyword (<c>out </c>, <c>in </c>, or <c>ref </c>) for a
/// byref parameter, or an empty string for an ordinary by-value parameter.
/// </summary>
/// <remarks>
/// Mono.Cecil represents any <c>ref</c>/<c>out</c>/<c>in</c> parameter with a
/// <see cref="Mono.Cecil.ByReferenceType"/> parameter type; the specific keyword is
/// recovered from the <see cref="ParameterDefinition.IsOut"/> / <see cref="ParameterDefinition.IsIn"/>
/// flags set by the C# compiler (<c>out</c> sets <c>Out</c>, <c>in</c> sets <c>In</c>,
/// and plain <c>ref</c> sets neither).
/// </remarks>
/// <param name="parameter">The parameter definition to inspect.</param>
/// <returns>The keyword including a trailing space, or <see cref="string.Empty"/>.</returns>
internal static string GetRefKindKeyword(ParameterDefinition parameter)
{
if (parameter.ParameterType is not ByReferenceType)
{
return string.Empty;
}

if (parameter.IsOut)
{
return "out ";
}

return parameter.IsIn ? "in " : "ref ";
}

/// <summary>Builds a human-readable C# property declaration signature.</summary>
/// <param name="prop">The property definition.</param>
/// <param name="contextNamespace">Used to simplify the property type name.</param>
Expand Down Expand Up @@ -902,15 +930,15 @@ private static MethodDefinition MostPermissiveAccessor(MethodDefinition? a, Meth

/// <summary>
/// Builds the full display name for a method overload, including the simplified parameter
/// type list in parentheses (e.g. <c>Process(int, string)</c>).
/// type list in parentheses (e.g. <c>Process(int, string)</c> or <c>TryGet(string, out int)</c>).
/// </summary>
/// <param name="method">The method definition to build a display name for.</param>
/// <returns>A human-readable method name including parenthesized parameter types.</returns>
internal static string BuildMethodDisplayName(MethodDefinition method)
{
var baseName = GetMethodGroupName(method);
var parameters = string.Join(", ", method.Parameters.Select(p =>
TypeNameSimplifier.Simplify(p.ParameterType, method.DeclaringType.Namespace)));
$"{GetRefKindKeyword(p)}{TypeNameSimplifier.Simplify(p.ParameterType, method.DeclaringType.Namespace)}"));
return $"{baseName}({parameters})";
}

Expand Down Expand Up @@ -1048,7 +1076,7 @@ internal static string BuildOperatorSignature(MethodDefinition method, string co
p.ParameterType,
contextNamespace,
HasNullableAnnotation(p.CustomAttributes));
return $"{paramType} {p.Name}";
return $"{GetRefKindKeyword(p)}{paramType} {p.Name}";
}));

return method.Name switch
Expand Down
2 changes: 1 addition & 1 deletion src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1066,7 +1066,7 @@ private static void WriteMethodDocumentation(
var paramRows = method.Parameters.Select(p =>
{
var desc = paramDocs.FirstOrDefault(pd => pd.Name == p.Name).Description ?? NoDescriptionPlaceholder;
var typeName = ctx.Resolver.Linkify(p.ParameterType, ctx.CurrentFolder, ctx.NamespaceName, ctx.ExternalTypes);
var typeName = GetRefKindKeyword(p) + ctx.Resolver.Linkify(p.ParameterType, ctx.CurrentFolder, ctx.NamespaceName, ctx.ExternalTypes);
return new[] { p.Name, typeName, desc };
});
memberWriter.WriteTable(paramHeaders, paramRows);
Expand Down
2 changes: 1 addition & 1 deletion src/ApiMark.DotNet/DotNetEmitterSingleFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ private static void WriteSingleFileMemberSection(
var paramRows = method.Parameters.Select(p =>
{
var desc = paramDocs.FirstOrDefault(pd => pd.Name == p.Name).Description ?? NoDescriptionPlaceholder;
var typeName = context.Resolver.Linkify(p.ParameterType, context.NamespaceFolderPath, context.NamespaceName,
var typeName = GetRefKindKeyword(p) + context.Resolver.Linkify(p.ParameterType, context.NamespaceFolderPath, context.NamespaceName,
// Shared throw-away accumulator — generateLinks is false so it is never populated or read
context.SharedExternalTypes);
return new[] { p.Name, typeName, desc };
Expand Down
9 changes: 9 additions & 0 deletions src/ApiMark.DotNet/TypeLinkResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ public string Linkify(
return string.Empty;
}

// Byref parameter types (ref/out/in) are marked by Cecil with a trailing "&" on the type
// name. The ref/out/in keyword itself is the caller's responsibility (see
// DotNetEmitter.GetRefKindKeyword) — unwrap to the underlying element type here so both
// the display text and the computed link target use the real (un-suffixed) type name.
if (typeRef is ByReferenceType byRefType)
{
return Linkify(byRefType.ElementType, currentFolder, contextNamespace, externalTypes, isNullableAnnotated);
}

// Generic type parameters (e.g. T, TKey) are not real types — render as plain text,
// appending "?" when the annotation indicates the parameter itself is nullable
if (typeRef is GenericParameter genericParam)
Expand Down
17 changes: 12 additions & 5 deletions src/ApiMark.DotNet/TypeNameSimplifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,12 @@ public static class TypeNameSimplifier
/// <remarks>
/// Exists to make generated Markdown signatures readable to C# developers — raw Mono.Cecil type
/// names include CLR full names and generic arity suffixes that are unfamiliar in documentation.
/// Seven simplification rules are applied in a fixed priority order: (1) C# primitive aliases,
/// (2) array bracket notation, (3) Nullable&lt;T&gt; → T?, (4) well-known namespace stripping,
/// (5) context namespace prefix stripping, (6) recursive generic argument simplification, and
/// (7) nullable reference annotation suffix. Stateless and thread-safe; no shared mutable state
/// is modified during the call.
/// Eight simplification rules are applied in a fixed priority order: (0) byref parameter type
/// unwrapping (the <c>ref</c>/<c>out</c>/<c>in</c> keyword itself is the caller's responsibility —
/// see <see cref="DotNetEmitter.GetRefKindKeyword"/>), (1) C# primitive aliases, (2) array bracket
/// notation, (3) Nullable&lt;T&gt; → T?, (4) well-known namespace stripping, (5) context namespace
/// prefix stripping, (6) recursive generic argument simplification, and (7) nullable reference
/// annotation suffix. Stateless and thread-safe; no shared mutable state is modified during the call.
/// </remarks>
/// <param name="typeRef">The Mono.Cecil type reference to simplify.</param>
/// <param name="contextNamespace">The namespace of the type that owns this reference, used for prefix stripping.</param>
Expand Down Expand Up @@ -87,6 +88,12 @@ private static string SimplifyCore(TypeReference typeRef, string contextNamespac
{
return typeRef switch
{
// Rule 0: byref parameter types (ref/out/in) — Cecil marks these with a trailing "&"
// on the type name; the caller is responsible for rendering the ref/out/in keyword,
// this rule only unwraps to the underlying element type for display purposes.
ByReferenceType byRef
=> Simplify(byRef.ElementType, contextNamespace),
Comment thread
Malcolmnixon marked this conversation as resolved.
Resolved

// Rule 2: array types recurse on the element type; rank-aware suffix (e.g., [] for 1-D, [,] for 2-D)
ArrayType arr
=> Simplify(arr.ElementType, contextNamespace) + "[" + new string(',', arr.Rank - 1) + "]",
Expand Down
41 changes: 41 additions & 0 deletions test/ApiMark.DotNet.Fixtures/ByRefParameterClass.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace ApiMark.DotNet.Fixtures;

/// <summary>Fixture reference type used as the target of <c>ref</c>/<c>out</c>/<c>in</c> parameters in <see cref="ByRefParameterClass"/>.</summary>
public class ByRefTargetClass
{
/// <summary>Gets or sets a sample value.</summary>
public int Value { get; set; }
}

/// <summary>Fixture class for testing <c>ref</c>/<c>out</c>/<c>in</c> parameter rendering in signatures and links.</summary>
/// <remarks>
/// Used to verify that byref parameters render the correct C# keyword (<c>out</c>/<c>in</c>/<c>ref</c>)
/// and that the underlying type name — not Cecil's byref-suffixed name — is used for both the
/// displayed type and any generated documentation link.
/// </remarks>
public class ByRefParameterClass
{
/// <summary>Attempts to resolve a value by name.</summary>
/// <param name="name">The name to resolve.</param>
/// <param name="value">The resolved value.</param>
/// <returns><c>true</c> if resolved; otherwise, <c>false</c>.</returns>
public bool TryResolve(string name, out ByRefTargetClass value)
{
value = new ByRefTargetClass();
return name.Length > 0;
}

/// <summary>Increments the given value in place.</summary>
/// <param name="value">The value to increment.</param>
public void Increment(ref ByRefTargetClass value)
{
_ = value;
}

/// <summary>Inspects the given value without modifying it.</summary>
/// <param name="value">The value to inspect.</param>
public void Inspect(in ByRefTargetClass value)
{
_ = value;
}
}
100 changes: 100 additions & 0 deletions test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,106 @@ public void DotNetEmitter_BuildPropertySignature_InstanceProperty_DoesNotContain
Assert.DoesNotContain("static ", signature, StringComparison.Ordinal);
}

/// <summary>
/// Validates that <see cref="DotNetEmitter.BuildMethodSignature"/> renders an <c>out</c>
/// parameter with the <c>out</c> keyword and the un-suffixed element type name, rather than
/// Cecil's raw byref-marked type name (e.g. <c>ByRefTargetClass&amp;</c>).
/// </summary>
[Fact]
public void DotNetEmitter_BuildMethodSignature_OutParameter_RendersOutKeywordAndPlainTypeName()
{
// Arrange
using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll());
var type = assembly.MainModule.Types.First(t => t.Name == "ByRefParameterClass");
var method = type.Methods.Single(m => m.Name == "TryResolve");

// Act
var signature = DotNetEmitter.BuildMethodSignature(method, "ApiMark.DotNet.Fixtures");

// Assert: the out parameter must render as "out ByRefTargetClass value" — never "ByRefTargetClass& value"
Assert.Contains("out ByRefTargetClass value", signature, StringComparison.Ordinal);
Assert.DoesNotContain("&", signature, StringComparison.Ordinal);
}

/// <summary>
/// Validates that <see cref="DotNetEmitter.BuildMethodSignature"/> renders a <c>ref</c>
/// parameter with the <c>ref</c> keyword and the un-suffixed element type name.
/// </summary>
[Fact]
public void DotNetEmitter_BuildMethodSignature_RefParameter_RendersRefKeywordAndPlainTypeName()
{
// Arrange
using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll());
var type = assembly.MainModule.Types.First(t => t.Name == "ByRefParameterClass");
var method = type.Methods.Single(m => m.Name == "Increment");

// Act
var signature = DotNetEmitter.BuildMethodSignature(method, "ApiMark.DotNet.Fixtures");

// Assert
Assert.Contains("ref ByRefTargetClass value", signature, StringComparison.Ordinal);
Assert.DoesNotContain("&", signature, StringComparison.Ordinal);
}

/// <summary>
/// Validates that <see cref="DotNetEmitter.BuildMethodSignature"/> renders an <c>in</c>
/// parameter with the <c>in</c> keyword and the un-suffixed element type name.
/// </summary>
[Fact]
public void DotNetEmitter_BuildMethodSignature_InParameter_RendersInKeywordAndPlainTypeName()
{
// Arrange
using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll());
var type = assembly.MainModule.Types.First(t => t.Name == "ByRefParameterClass");
var method = type.Methods.Single(m => m.Name == "Inspect");

// Act
var signature = DotNetEmitter.BuildMethodSignature(method, "ApiMark.DotNet.Fixtures");

// Assert
Assert.Contains("in ByRefTargetClass value", signature, StringComparison.Ordinal);
Assert.DoesNotContain("&", signature, StringComparison.Ordinal);
}

/// <summary>
/// Validates that <see cref="DotNetEmitter.BuildMethodDisplayName"/> includes the <c>out</c>
/// keyword for a byref parameter in the overload heading text.
/// </summary>
[Fact]
public void DotNetEmitter_BuildMethodDisplayName_OutParameter_IncludesOutKeyword()
{
// Arrange
using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll());
var type = assembly.MainModule.Types.First(t => t.Name == "ByRefParameterClass");
var method = type.Methods.Single(m => m.Name == "TryResolve");

// Act
var displayName = DotNetEmitter.BuildMethodDisplayName(method);

// Assert
Assert.Equal("TryResolve(string, out ByRefTargetClass)", displayName);
}

/// <summary>
/// Validates that <see cref="DotNetEmitter.GetRefKindKeyword"/> returns an empty string for
/// an ordinary by-value parameter.
/// </summary>
[Fact]
public void DotNetEmitter_GetRefKindKeyword_ByValueParameter_ReturnsEmptyString()
{
// Arrange
using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll());
var type = assembly.MainModule.Types.First(t => t.Name == "ByRefParameterClass");
var method = type.Methods.Single(m => m.Name == "TryResolve");
var nameParam = method.Parameters.Single(p => p.Name == "name");

// Act
var keyword = DotNetEmitter.GetRefKindKeyword(nameParam);

// Assert
Assert.Equal(string.Empty, keyword);
}

/// <summary>
/// Validates that <see cref="DotNetEmitter.IsNamespaceDocCarrier"/> returns
/// <see langword="true"/> for the <c>NamespaceDoc</c> carrier class in the fixture assembly.
Expand Down
21 changes: 21 additions & 0 deletions test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,27 @@ public void TypeLinkResolver_Linkify_NullableIntraAssemblyType_ReturnsLinkWithQu
Assert.EndsWith("?", result, StringComparison.Ordinal);
}

/// <summary>
/// Validates that a byref parameter type (Cecil's <see cref="ByReferenceType"/>, used for
/// <c>ref</c>/<c>out</c>/<c>in</c> parameters) resolves to a Markdown link using the
/// un-suffixed element type name — never a dead link to a page named with a trailing <c>&amp;</c>.
/// </summary>
[Fact]
public void TypeLinkResolver_Linkify_ByReferenceType_ReturnsLinkWithPlainTypeName()
{
// Arrange: construct a byref wrapper around SampleClass, as Cecil does for ref/out/in parameters
var resolver = new TypeLinkResolver(["ApiMark.DotNet.Fixtures"], generateLinks: true);
var externalTypes = new HashSet<ExternalTypeInfo>();
var typeDef = _assembly.MainModule.Types.First(t => t.Name == "SampleClass");
var byRefType = new ByReferenceType(typeDef);

// Act
var result = resolver.Linkify(byRefType, "ApiMark.DotNet.Fixtures", "ApiMark.DotNet.Fixtures", externalTypes);

// Assert: link target and text must not contain "&" — the real page is SampleClass.md
Assert.Equal("[SampleClass](SampleClass.md)", result);
}

/// <summary>
/// Validates that an array type reference with <paramref name="isNullableAnnotated"/> set to
/// <see langword="true"/> produces a result ending with <c>[]?</c> — the array rank suffix
Expand Down
19 changes: 19 additions & 0 deletions test/ApiMark.DotNet.Tests/TypeNameSimplifierTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,25 @@ public void TypeNameSimplifier_Simplify_ArrayType_ReturnsBracketNotation()
Assert.Equal("string[]", result);
}

/// <summary>
/// Validates that a byref parameter type (Cecil's <see cref="ByReferenceType"/>, used for
/// <c>ref</c>/<c>out</c>/<c>in</c> parameters) is unwrapped to its element type name rather
/// than rendering the raw trailing <c>&amp;</c> marker.
/// </summary>
[Fact]
public void TypeNameSimplifier_Simplify_ByReferenceType_UnwrapsToElementTypeName()
{
// Arrange: construct a byref wrapper around SampleClass, as Cecil does for ref/out/in parameters
var sampleClass = GetType("SampleClass");
var byRefType = new ByReferenceType(sampleClass);

// Act
var result = TypeNameSimplifier.Simplify(byRefType, "ApiMark.DotNet.Fixtures");

// Assert: the "&" marker must not appear in the simplified name
Assert.Equal("SampleClass", result);
}

/// <summary>Validates that <c>Nullable&lt;T&gt;</c> value types are rendered as <c>T?</c>.</summary>
[Fact]
public void TypeNameSimplifier_Simplify_NullableValueTypes_UseQuestionMarkForm()
Expand Down
Loading