From 938ecce41fe9c0b3a03e64cb5704920e2a8ec2b2 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sat, 3 Oct 2026 09:03:32 -0400 Subject: [PATCH] Exclude compiler-generated nested types at --visibility All GetVisibleNestedTypes was the only visibility filter that didn''t exclude compiler-generated types (closures, cached-lambda classes, async/iterator state machines, etc.). At --visibility All these were emitted as documentation pages, and several of their names contain characters (<, >, |) invalid in Windows file paths, causing an I/O error. - Exclude compiler-generated nested types via the existing IsCompilerGenerated helper, matching the filtering already applied to top-level types and members. - Add a regression fixture/test exercising lambdas that synthesize a cached-lambda class and a closure, confirming neither is visible at ApiVisibility.All. - Document the exclusion rule in the DotNetEmitter design doc. Fixes the reported crash: "The filename, directory name, or volume label syntax is incorrect. : ''out\Repro\Sample\<>c.md''" Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api-mark-dot-net/dot-net-emitter.md | 5 ++++ src/ApiMark.DotNet/DotNetEmitter.cs | 7 ++++- .../CompilerGeneratedNestedClass.cs | 22 +++++++++++++++ .../DotNetEmitterTests.cs | 27 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 test/ApiMark.DotNet.Fixtures/CompilerGeneratedNestedClass.cs 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 2cbc0cc..97ada06 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter.md @@ -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)`, diff --git a/src/ApiMark.DotNet/DotNetEmitter.cs b/src/ApiMark.DotNet/DotNetEmitter.cs index e2759df..0bff99c 100644 --- a/src/ApiMark.DotNet/DotNetEmitter.cs +++ b/src/ApiMark.DotNet/DotNetEmitter.cs @@ -218,7 +218,11 @@ internal bool IsTypeVisible(TypeDefinition type) /// Nested-type visibility is tested with the IsNested* flags rather than the /// top-level IsPublic 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 : + /// they are implementation details with no source-level documentation, and several of + /// their names contain characters (<, >, |) that are invalid in + /// Windows file paths. /// /// The declaring type whose nested types are to be filtered. /// @@ -228,6 +232,7 @@ internal bool IsTypeVisible(TypeDefinition type) internal IEnumerable GetVisibleNestedTypes(TypeDefinition type) { return type.NestedTypes + .Where(t => !IsCompilerGenerated(t)) .Where(t => Model.Options.Visibility switch { ApiVisibility.Public => t.IsNestedPublic, diff --git a/test/ApiMark.DotNet.Fixtures/CompilerGeneratedNestedClass.cs b/test/ApiMark.DotNet.Fixtures/CompilerGeneratedNestedClass.cs new file mode 100644 index 0000000..de46ee4 --- /dev/null +++ b/test/ApiMark.DotNet.Fixtures/CompilerGeneratedNestedClass.cs @@ -0,0 +1,22 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +namespace ApiMark.DotNet.Fixtures; + +/// +/// 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. +/// +public class CompilerGeneratedNestedClass +{ + /// Doubles every value using a lambda that captures nothing, synthesizing a cached-lambda class. + /// The values to double. + /// An array containing each value doubled. + public int[] DoubleAll(int[] values) => Array.ConvertAll(values, v => v * 2); + + /// Adds to every value using a lambda that captures it, synthesizing a closure class. + /// The values to offset. + /// The amount to add to each value. + /// An array containing each value plus . + public int[] AddOffsetToAll(int[] values, int offset) => Array.ConvertAll(values, v => v + offset); +} diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs index 9d27ca0..0f8ba72 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs @@ -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); } + + /// + /// Validates that excludes compiler-generated + /// nested types (cached-lambda classes, closures, etc.) even at , + /// where they would otherwise be included because they are private/internal and carry no + /// meaningful documentation, and their names (e.g. <>c) are invalid Windows file names. + /// + [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)); + } }