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
5 changes: 5 additions & 0 deletions docs/design/api-mark-dot-net/dot-net-emitter.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ These helpers are grouped by concern:
`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.
- *Type/member classification* — `IsOperator`, `IsSpecialNameNonConstructor`,
`IsCompilerGeneratedField`, `IsDelegate`, `IsExtensionMethod`,
`IsCompilerGenerated(ICustomAttributeProvider)`, `IsCompilerGenerated(TypeDefinition)`,
Expand Down
7 changes: 6 additions & 1 deletion src/ApiMark.DotNet/DotNetEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,11 @@ internal bool IsTypeVisible(TypeDefinition type)
/// Nested-type visibility is tested with the <c>IsNested*</c> flags rather than the
/// top-level <c>IsPublic</c> flag because Cecil assigns separate flags to each
/// nested-access level. Ordering by name ensures deterministic output regardless of
/// metadata table order.
/// metadata table order. Compiler-generated nested types (closures, state machines,
/// the cached-lambda class, etc.) are always excluded, even at <see cref="ApiVisibility.All"/>:
/// they are implementation details with no source-level documentation, and several of
/// their names contain characters (<c>&lt;</c>, <c>&gt;</c>, <c>|</c>) that are invalid in
/// Windows file paths.
/// </remarks>
/// <param name="type">The declaring type whose nested types are to be filtered.</param>
/// <returns>
Expand All @@ -228,6 +232,7 @@ internal bool IsTypeVisible(TypeDefinition type)
internal IEnumerable<TypeDefinition> GetVisibleNestedTypes(TypeDefinition type)
{
return type.NestedTypes
.Where(t => !IsCompilerGenerated(t))
.Where(t => Model.Options.Visibility switch
{
ApiVisibility.Public => t.IsNestedPublic,
Expand Down
22 changes: 22 additions & 0 deletions test/ApiMark.DotNet.Fixtures/CompilerGeneratedNestedClass.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) DemaConsulting LLC. All rights reserved.
// Licensed under the MIT License.

namespace ApiMark.DotNet.Fixtures;

/// <summary>
/// A class whose members trigger compiler-generated nested types (a cached-lambda class and
/// a closure/display class), used to verify ApiMark excludes them at every visibility.
/// </summary>
public class CompilerGeneratedNestedClass
{
/// <summary>Doubles every value using a lambda that captures nothing, synthesizing a cached-lambda class.</summary>
/// <param name="values">The values to double.</param>
/// <returns>An array containing each value doubled.</returns>
public int[] DoubleAll(int[] values) => Array.ConvertAll(values, v => v * 2);

/// <summary>Adds <paramref name="offset"/> to every value using a lambda that captures it, synthesizing a closure class.</summary>
/// <param name="values">The values to offset.</param>
/// <param name="offset">The amount to add to each value.</param>
/// <returns>An array containing each value plus <paramref name="offset"/>.</returns>
public int[] AddOffsetToAll(int[] values, int offset) => Array.ConvertAll(values, v => v + offset);
}
27 changes: 27 additions & 0 deletions test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -474,4 +474,31 @@ public void DotNetEmitter_BuildPropertyAccessors_AsymmetricGetSet_UsesMostPermis
// Assert: getter must be prefixed with its restricted accessibility; setter must have no prefix
Assert.Equal("protected get; set;", result);
}

/// <summary>
/// Validates that <see cref="DotNetEmitter.GetVisibleNestedTypes"/> excludes compiler-generated
/// nested types (cached-lambda classes, closures, etc.) even at <see cref="ApiVisibility.All"/>,
/// where they would otherwise be included because they are private/internal and carry no
/// meaningful documentation, and their names (e.g. <c>&lt;&gt;c</c>) are invalid Windows file names.
/// </summary>
[Fact]
public void DotNetEmitter_GetVisibleNestedTypes_AllVisibility_ExcludesCompilerGeneratedTypes()
{
// Arrange
var options = BuildOptions();
options.Visibility = ApiVisibility.All;
var emitter = (DotNetEmitter)new DotNetGenerator(options).Parse(new InMemoryContext());
using var assembly = AssemblyDefinition.ReadAssembly(FixturePaths.GetFixtureDll());
var type = assembly.MainModule.Types.Single(t => t.Name == "CompilerGeneratedNestedClass");

// Sanity check: the compiler did synthesize at least one nested type for the lambdas
Assert.NotEmpty(type.NestedTypes);

// Act
var visibleNestedTypes = emitter.GetVisibleNestedTypes(type).ToList();

// Assert: none of the synthesized nested types (whose names start with '<') are visible
Assert.Empty(visibleNestedTypes);
Assert.DoesNotContain(visibleNestedTypes, t => t.Name.Contains('<', StringComparison.Ordinal));
}
}
Loading