Skip to content

Commit d9f36c9

Browse files
kamputeKhojasteh
andauthored
V1.1.1 (#2)
* Corrected owned type argument calculation for nested generic types to ensure the proper number of type arguments is determined. * Improved detection of explicit interface implementations by excluding compiler-generated bridge methods created for by-ref parameter variations. * Refactored custom attribute argument handling to properly represent arrays and nested values for reflection-only types. * Enhanced assembly resolution strategy to prioritize exact version matches when loading assemblies from probe folders. * Improved qualification of explicitly implemented interface members to eliminate ambiguity between implementing member names and corresponding interface member names. --------- Co-authored-by: Kambiz Khojasteh <kambiz.khojasteh@gmail.com>
1 parent 25485c6 commit d9f36c9

19 files changed

+609
-59
lines changed

.github/workflows/main.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,17 @@ jobs:
2222
with:
2323
dotnet-version: '8.0.x'
2424

25-
- name: Install Kampose
26-
run: dotnet tool install --global kampose
27-
2825
- name: Restore Dependencies
2926
run: dotnet restore
3027

3128
- name: Build Solution
3229
run: dotnet build --no-restore -c Release
3330

3431
- name: Test Solution
35-
run: dotnet test --no-restore --verbosity minimal
32+
run: dotnet test --no-restore --no-build -c Release --verbosity minimal
33+
34+
- name: Install Kampose for Documentation Generation
35+
run: dotnet tool install --global kampose
3636

3737
- name: Generate Documentation
3838
run: kampose build

kampose.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
]
2424
}
2525
],
26+
"topicHierarchy": "directory",
2627
"theme": "classic",
2728
"themeSettings": {
2829
"projectName": "Kampute.DocToolkit",
@@ -33,6 +34,9 @@
3334
"pageFooter": "- Copyright © {{now 'yyyy'}} [Kampute](https://kampute.com)\n- Site built with [Kampose](https://kampute.github.io/kampose/).",
3435
"groupTypesByNamespace": true,
3536
"showTypeMembersSummary": true,
36-
"seeAlsoSubtopics": true
37+
"seeAlsoSubtopics": true,
38+
"menuItems": [
39+
"*"
40+
]
3741
}
3842
}

src/Kampute.DocToolkit.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<TargetFramework>netstandard2.1</TargetFramework>
55
<Title>Kampute.DocDotLib</Title>
66
<Description>Provides extensible pipeline for generating .NET API documentation by transforming assembly metadata and XML documentation into structured models, with automatic cross-reference resolution, support for multiple output formats (HTML, Markdown), and integration of conceptual topics.</Description>
7-
<Version>1.1.0</Version>
7+
<Version>1.1.1</Version>
88
<Company>Kampute</Company>
99
<Authors>Kambiz Khojasteh</Authors>
1010
<Copyright>Copyright (c) 2025 Kampute</Copyright>
@@ -15,7 +15,7 @@
1515
<IncludeSymbols>true</IncludeSymbols>
1616
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
1717
<GenerateDocumentationFile>true</GenerateDocumentationFile>
18-
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
18+
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
1919
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
2020
<PackageId>Kampute.DocToolkit</PackageId>
2121
<PackageTags>reflection xmldoc</PackageTags>

src/Languages/CSharp.Literals.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ namespace Kampute.DocToolkit.Languages
77
{
88
using Kampute.DocToolkit.Metadata;
99
using System;
10+
using System.Collections.Generic;
1011
using System.Globalization;
1112
using System.IO;
1213
using System.Linq;
@@ -234,5 +235,39 @@ static int CountSetBits(ulong value)
234235
return count;
235236
}
236237
}
238+
239+
/// <summary>
240+
/// Writes the specified typed value to the <see cref="TextWriter"/>.
241+
/// </summary>
242+
/// <param name="writer">The <see cref="TextWriter"/> to write to.</param>
243+
/// <param name="typedValue">The <see cref="TypedValue"/> to write.</param>
244+
/// <param name="linker">The <see cref="MemberDocLinker"/> to use for linking to documentation.</param>
245+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="writer"/> is <see langword="null"/>.</exception>
246+
/// <exception cref="ArgumentException">Thrown when <paramref name="typedValue"/> does not represent a constant value.</exception>
247+
private void WriteTypedValue(TextWriter writer, TypedValue typedValue, MemberDocLinker linker)
248+
{
249+
if (writer is null)
250+
throw new ArgumentNullException(nameof(writer));
251+
252+
if (typedValue.Value is IEnumerable<TypedValue> values)
253+
{
254+
writer.Write('[');
255+
var needsSeparator = false;
256+
foreach (var value in values)
257+
{
258+
if (needsSeparator)
259+
writer.Write(", ");
260+
else
261+
needsSeparator = true;
262+
263+
WriteTypedValue(writer, value, linker);
264+
}
265+
writer.Write(']');
266+
}
267+
else
268+
{
269+
WriteConstantValue(writer, typedValue.Value, typedValue.Type, linker);
270+
}
271+
}
237272
}
238273
}

src/Languages/CSharp.TypeMembers.cs

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,34 @@ public partial class CSharp
2020
/// <param name="qualifier">The level of qualification to apply to the member's name.</param>
2121
/// <param name="linker">A delegate for linking to the documentation of a type or type's member.</param>
2222
/// <param name="indexerName">The name to use for indexers if <paramref name="member"/> is an indexer property; otherwise, <see langword="null"/>.</param>
23+
/// <remarks>
24+
/// For explicit interface implementations, when <paramref name="qualifier"/> is <see cref="NameQualifier.None"/>, the interface
25+
/// member name is qualified according to <see cref="CodeStyleOptions.FullyQualifyExplicitInterfaceMemberNames"/>. When
26+
/// <paramref name="qualifier"/> is not <see cref="NameQualifier.None"/>, the implementing type is written first according to
27+
/// the specified <paramref name="qualifier"/>, followed by the fully qualified interface member name.
28+
/// <para>
29+
/// Constructors have no name written, and indexer properties use the provided <paramref name="indexerName"/> if specified.
30+
/// </para>
31+
/// </remarks>
2332
private void WriteTypeMemberName(TextWriter writer, ITypeMember member, NameQualifier qualifier, MemberDocLinker linker, string? indexerName = null)
2433
{
2534
if (member is IVirtualTypeMember { IsExplicitInterfaceImplementation: true, ImplementedMember: ITypeMember interfaceMember })
2635
{
27-
member = interfaceMember;
28-
if (qualifier is not NameQualifier.Full)
36+
if (qualifier != NameQualifier.None)
37+
{
38+
WriteTypeSignature(writer, member.DeclaringType, qualifier, linker);
39+
writer.Write(Type.Delimiter);
40+
qualifier = NameQualifier.Full;
41+
}
42+
else
2943
{
3044
qualifier = Options.FullyQualifyExplicitInterfaceMemberNames
3145
? NameQualifier.Full
3246
: NameQualifier.DeclaringType;
3347
}
34-
}
3548

36-
if (qualifier != NameQualifier.None)
37-
WriteTypeSignature(writer, member.DeclaringType, qualifier, linker);
49+
member = interfaceMember;
50+
}
3851

3952
var name = member switch
4053
{
@@ -43,13 +56,16 @@ private void WriteTypeMemberName(TextWriter writer, ITypeMember member, NameQual
4356
_ => member.Name
4457
};
4558

46-
if (!string.IsNullOrEmpty(name))
59+
if (qualifier != NameQualifier.None)
4760
{
48-
if (qualifier != NameQualifier.None)
49-
writer.Write(Type.Delimiter);
61+
WriteTypeSignature(writer, member.DeclaringType, qualifier, linker);
62+
if (string.IsNullOrEmpty(name))
63+
return;
5064

51-
linker(writer, member, name);
65+
writer.Write(Type.Delimiter);
5266
}
67+
68+
linker(writer, member, name);
5369
}
5470

5571
#region Constructors

src/Languages/CSharp.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,15 +351,15 @@ public virtual void WriteAttribute(TextWriter writer, ICustomAttribute attribute
351351
foreach (var argument in attribute.ConstructorArguments)
352352
{
353353
EnsureCommaBeforeNext();
354-
WriteConstantValue(writer, argument.Value, argument.Type, linker);
354+
WriteTypedValue(writer, argument, linker);
355355
}
356356

357357
foreach (var (name, argument) in attribute.NamedArguments)
358358
{
359359
EnsureCommaBeforeNext();
360360
writer.Write(name);
361361
writer.Write(" = ");
362-
WriteConstantValue(writer, argument.Value, argument.Type, linker);
362+
WriteTypedValue(writer, argument, linker);
363363
}
364364

365365
if (anyParameterBefore)

src/Metadata/Adapters/CompositeTypeAdapter.cs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
namespace Kampute.DocToolkit.Metadata.Adapters
77
{
8+
using Kampute.DocToolkit.Support;
89
using System;
910
using System.Collections.Generic;
1011
using System.Linq;
@@ -184,7 +185,7 @@ protected virtual IEnumerable<IOperator> GetOperators() => Reflection
184185
/// <returns>An enumeration of <see cref="IMethod"/> objects representing the explicit interface methods implemented by the type.</returns>
185186
protected virtual IEnumerable<IMethod> GetExplicitInterfaceMethods() => Reflection
186187
.GetMethods(BindingFlags.DeclaredOnly | BindingFlags.NonPublic | BindingFlags.Instance)
187-
.Where(m => !m.IsSpecialName && IsExplicitMember(m))
188+
.Where(m => !m.IsSpecialName && IsExplicitMethod(m))
188189
.Select(Assembly.Repository.GetMethodMetadata<IMethod>)
189190
.OrderBy(m => m.Name, StringComparer.Ordinal)
190191
.ThenBy(m => m.Parameters.Count);
@@ -249,13 +250,46 @@ protected virtual bool IsVisibleNestedType(Type type)
249250
/// <summary>
250251
/// Determines whether a member is an explicit interface implementation.
251252
/// </summary>
252-
/// <param name="member">The member to check.</param>
253+
/// <param name="member">The reflection information of the member to check.</param>
253254
/// <returns><see langword="true"/> if the member is an explicit interface implementation; otherwise, <see langword="false"/>.</returns>
254255
protected virtual bool IsExplicitMember(MemberInfo member)
255256
{
256257
return member is not null
257258
&& member.Name.IndexOf('.') > 0
258259
&& !member.CustomAttributes.Any(attr => attr.AttributeType.FullName == "System.Runtime.CompilerServices.CompilerGeneratedAttribute");
259260
}
261+
262+
/// <summary>
263+
/// Determines whether a method is an explicit interface implementation.
264+
/// </summary>
265+
/// <param name="method">The reflection information of the method to check.</param>
266+
/// <returns><see langword="true"/> if the method is an explicit interface implementation; otherwise, <see langword="false"/>.</returns>
267+
/// <remarks>
268+
/// This method excludes compiler-generated bridge methods for implicit interface implementations with by-ref parameters.
269+
/// </remarks>
270+
protected virtual bool IsExplicitMethod(MethodInfo method)
271+
{
272+
if (!IsExplicitMember(method) || !(method.IsPrivate && method.IsFinal && method.IsVirtual))
273+
return false;
274+
275+
// Compiler-generated bridge methods are created for implicit interface implementations
276+
// that have by-ref parameters (in, ref, out). Check if any parameter is by-ref.
277+
var parameters = method.GetParameters();
278+
if (!parameters.Any(p => p.ParameterType.IsByRef))
279+
return true; // No by-ref parameters, so it's a user-written explicit implementation
280+
281+
// Has by-ref parameters - check if there's a public method with the same signature
282+
var publicMethod = Reflection.GetMethod
283+
(
284+
name: method.Name.SubstringAfterLast('.'),
285+
bindingAttr: BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance,
286+
binder: null,
287+
types: [.. parameters.Select(p => p.ParameterType)],
288+
modifiers: null
289+
);
290+
291+
// If a public method with the same signature exists, this is a compiler-generated bridge
292+
return publicMethod is null;
293+
}
260294
}
261295
}

src/Metadata/Adapters/CustomAttributeAdapter.cs

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,8 @@ public CustomAttributeAdapter(CustomAttributeData attribute, AttributeTarget tar
5454
Target = target;
5555

5656
attributeType = new(Native.AttributeType.GetMetadata<IClassType>);
57-
58-
constructorArguments = new(() => [.. Native.ConstructorArguments.Select(
59-
arg => new TypedValue(arg.ArgumentType.GetMetadata(), GetArgumentValue(arg)))]);
60-
61-
namedArguments = new(() => Native.NamedArguments.ToDictionary(
62-
arg => arg.MemberName,
63-
arg => new TypedValue(arg.TypedValue.ArgumentType.GetMetadata(), GetArgumentValue(arg.TypedValue))));
57+
constructorArguments = new(() => [.. Native.ConstructorArguments.Select(CreateTypedValue)]);
58+
namedArguments = new(() => Native.NamedArguments.ToDictionary(arg => arg.MemberName, arg => CreateTypedValue(arg.TypedValue)));
6459
}
6560

6661
/// <summary>
@@ -90,25 +85,25 @@ public CustomAttributeAdapter(CustomAttributeData attribute, AttributeTarget tar
9085
public virtual bool Represents(CustomAttributeData reflection) => ReferenceEquals(Native, reflection);
9186

9287
/// <summary>
93-
/// Gets the value of the specified argument, handling array arguments appropriately.
88+
/// Converts a custom attribute typed argument into a <see cref="TypedValue"/>, handling array arguments
89+
/// by wrapping element values in an array of <see cref="TypedValue"/> instances.
9490
/// </summary>
95-
/// <param name="typedArgument">The argument whose value is to be retrieved.</param>
96-
/// <returns>The value of the argument.</returns>
97-
protected virtual object? GetArgumentValue(CustomAttributeTypedArgument typedArgument)
91+
/// <param name="typedArgument">The typed argument to convert.</param>
92+
/// <returns>A <see cref="TypedValue"/> representing the typed argument.</returns>
93+
protected virtual TypedValue CreateTypedValue(CustomAttributeTypedArgument typedArgument)
9894
{
9995
if (typedArgument.Value is IReadOnlyCollection<CustomAttributeTypedArgument> args)
10096
{
101-
var elementType = typedArgument.ArgumentType.GetElementType() ?? typeof(object);
102-
var array = Array.CreateInstance(elementType, args.Count);
97+
var values = new TypedValue[args.Count];
10398

104-
var index = 0;
99+
var i = 0;
105100
foreach (var arg in args)
106-
array.SetValue(GetArgumentValue(arg), index++);
101+
values[i++] = CreateTypedValue(arg);
107102

108-
return array;
103+
return new(typeof(TypedValue[]).GetMetadata(), values);
109104
}
110105

111-
return typedArgument.Value;
106+
return new(typedArgument.ArgumentType.GetMetadata(), typedArgument.Value);
112107
}
113108
}
114109
}

src/Metadata/Adapters/GenericCapableTypeAdapter.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,9 @@ protected virtual IEnumerable<IType> GetTypeArguments()
174174
if (DeclaringType is IGenericCapableType genericCapableDeclaringType)
175175
{
176176
var (offset, count) = genericCapableDeclaringType.OwnGenericParameterRange;
177-
ownedOffset += offset + count;
178-
ownedCount -= count;
177+
var inheritedCount = offset + count;
178+
ownedOffset += inheritedCount;
179+
ownedCount -= inheritedCount;
179180
}
180181

181182
return (ownedOffset, ownedCount);

src/Metadata/MetadataUniverse.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ public Assembly LoadFromPath(string assemblyPath)
146146
/// </summary>
147147
private sealed class FolderAssemblyResolver : MetadataAssemblyResolver
148148
{
149+
private static readonly Version ZeroVersion = new(0, 0, 0, 0);
150+
149151
private readonly PathAssemblyResolver pathResolver;
150152
private readonly List<string> probeFolders;
151153
private readonly ConcurrentDictionary<string, Assembly?> cache = new(StringComparer.OrdinalIgnoreCase);
@@ -199,6 +201,7 @@ public FolderAssemblyResolver(IEnumerable<string> assemblyPaths, IEnumerable<str
199201
private Assembly? FindAssemblyInProbeFolders(MetadataLoadContext context, AssemblyName assemblyName)
200202
{
201203
var fileName = assemblyName.Name! + ".dll";
204+
var candidates = new SortedList<Version, string>(Comparer<Version>.Create((a, b) => b.CompareTo(a)));
202205

203206
foreach (var folder in probeFolders)
204207
{
@@ -209,7 +212,10 @@ public FolderAssemblyResolver(IEnumerable<string> assemblyPaths, IEnumerable<str
209212
{
210213
var foundName = AssemblyName.GetAssemblyName(path);
211214
if (AssemblyName.ReferenceMatchesDefinition(assemblyName, foundName))
212-
return context.LoadFromAssemblyPath(path);
215+
{
216+
var version = foundName.Version ?? ZeroVersion;
217+
candidates.TryAdd(version, path);
218+
}
213219
}
214220
catch
215221
{
@@ -218,7 +224,15 @@ public FolderAssemblyResolver(IEnumerable<string> assemblyPaths, IEnumerable<str
218224
}
219225
}
220226

221-
return null;
227+
if (candidates.Count == 0)
228+
return null;
229+
230+
// Prioritize exact version match
231+
if (assemblyName.Version is not null && candidates.TryGetValue(assemblyName.Version, out var exactPath))
232+
return context.LoadFromAssemblyPath(exactPath);
233+
234+
// Fall back to highest compatible version
235+
return context.LoadFromAssemblyPath(candidates.Values[0]);
222236
}
223237
}
224238
}

0 commit comments

Comments
 (0)