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
57 changes: 50 additions & 7 deletions src/ApiMark.DotNet/DotNetEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -551,9 +551,9 @@ 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})";
return $"public delegate {GetReturnRefKeyword(invoke.ReturnType)}{returnType} {name}({parameters})";
}

/// <summary>Builds a human-readable C# declaration signature for a type definition.</summary>
Expand Down Expand Up @@ -692,14 +692,57 @@ 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.
}));

var accessibility = GetAccessibilityKeyword(method);
var staticModifier = method.IsStatic && method.Name != ConstructorMethodName ? " static" : string.Empty;
return method.Name == ConstructorMethodName
? $"{accessibility} {name}({parameters})"
: $"{accessibility}{staticModifier} {returnType} {name}({parameters})";
: $"{accessibility}{staticModifier} {GetReturnRefKeyword(method.ReturnType)}{returnType} {name}({parameters})";
}

/// <summary>
/// Returns the C# <c>ref </c> keyword when a method or delegate returns by reference, or an
/// empty string for an ordinary by-value return.
/// </summary>
/// <remarks>
/// Mono.Cecil represents a <c>ref</c> return with a <see cref="Mono.Cecil.ByReferenceType"/>
/// return type, the same representation used for byref parameters (see
/// <see cref="GetRefKindKeyword"/>). Unlike parameters, C# return values only ever use the
/// plain <c>ref</c> keyword — <c>out</c>/<c>in</c> do not apply to return values.
/// </remarks>
/// <param name="returnType">The method's raw return type, before <see cref="TypeNameSimplifier"/> is applied.</param>
/// <returns>The literal <c>"ref "</c>, or <see cref="string.Empty"/>.</returns>
internal static string GetReturnRefKeyword(TypeReference returnType) =>
returnType is ByReferenceType ? "ref " : string.Empty;

/// <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>
Expand Down Expand Up @@ -902,15 +945,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 +1091,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
19 changes: 13 additions & 6 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 All @@ -71,7 +72,7 @@ public static string Simplify(TypeReference typeRef, string contextNamespace, bo
return name;
}

/// <summary>Applies Rules 1–6 to produce a simplified type name without nullable-reference annotation.</summary>
/// <summary>Applies Rules 0–6 to produce a simplified type name without nullable-reference annotation.</summary>
/// <remarks>
/// Exists as a named helper so that <see cref="Simplify"/> can apply Rule 7 (nullable reference
/// annotation) as a single post-processing step without duplicating the core switch logic.
Expand All @@ -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.

// 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
50 changes: 50 additions & 0 deletions test/ApiMark.DotNet.Fixtures/ByRefParameterClass.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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;
}

/// <summary>Returns a reference to the backing field's element.</summary>
/// <returns>A reference to the stored value.</returns>
public ref ByRefTargetClass GetByRef()
{
return ref _stored;
}

private ByRefTargetClass _stored = new();

Check warning on line 49 in test/ApiMark.DotNet.Fixtures/ByRefParameterClass.cs

View workflow job for this annotation

GitHub Actions / Build / Build windows-latest

Make '_stored' 'readonly'.

Check warning on line 49 in test/ApiMark.DotNet.Fixtures/ByRefParameterClass.cs

View workflow job for this annotation

GitHub Actions / Build / Build ubuntu-latest

Make '_stored' 'readonly'.

Check warning on line 49 in test/ApiMark.DotNet.Fixtures/ByRefParameterClass.cs

View workflow job for this annotation

GitHub Actions / Build / Build macos-latest

Make '_stored' 'readonly'.
}
140 changes: 140 additions & 0 deletions test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,146 @@ 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.BuildMethodSignature"/> renders a <c>ref</c>-returning
/// method with the <c>ref</c> keyword before the return type, rather than silently dropping it
/// now that <see cref="TypeNameSimplifier"/> unwraps <see cref="Mono.Cecil.ByReferenceType"/>.
/// </summary>
[Fact]
public void DotNetEmitter_BuildMethodSignature_RefReturningMethod_RendersRefKeywordBeforeReturnType()
{
// 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 == "GetByRef");

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

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

/// <summary>
/// Validates that <see cref="DotNetEmitter.GetReturnRefKeyword"/> returns an empty string for
/// an ordinary by-value return type.
/// </summary>
[Fact]
public void DotNetEmitter_GetReturnRefKeyword_ByValueReturn_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");

// Act
var keyword = DotNetEmitter.GetReturnRefKeyword(method.ReturnType);

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

/// <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
Loading
Loading