From b137ce71565082c6bf33a4a785fbb2fa49d32286 Mon Sep 17 00:00:00 2001 From: Kambiz Khojasteh Date: Tue, 23 Dec 2025 17:00:57 +0800 Subject: [PATCH 1/4] Refactor DetermineNameQualifier logic and add tests Refactored DetermineNameQualifier to clarify qualification rules for types, operators, extension methods, and members in the current type scope. Added comprehensive unit tests to cover all logic branches. --- src/DocumentationContextExtensions.cs | 21 +++++-- tests/DocumentationContextExtensionsTests.cs | 65 ++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/DocumentationContextExtensions.cs b/src/DocumentationContextExtensions.cs index 6750dffb..4a9865a6 100644 --- a/src/DocumentationContextExtensions.cs +++ b/src/DocumentationContextExtensions.cs @@ -180,16 +180,27 @@ public static NameQualifier DetermineNameQualifier(this IDocumentationContext co if (member is null) throw new ArgumentNullException(nameof(member)); + // Types always require declaring type qualification if (member is IType) return NameQualifier.DeclaringType; - if (member is IWithExtensionBehavior { IsExtension: true } or IOperator) + // Members in the current type scope do not require qualification + if (IsMemberInCurrentTypeScope()) return NameQualifier.None; - return context.AddressProvider.ActiveScope.Model is TypeModel typeModel - && ReferenceEquals(typeModel.Metadata, member.DeclaringType) - ? NameQualifier.None - : NameQualifier.DeclaringType; + // Operators and extension methods do not require qualification + if (member is IOperator or IWithExtensionBehavior { IsExtension: true }) + return NameQualifier.None; + + // Otherwise, qualify with declaring type + return NameQualifier.DeclaringType; + + bool IsMemberInCurrentTypeScope() => context.AddressProvider.ActiveScope.Model switch + { + TypeModel typeModel => ReferenceEquals(typeModel.Metadata, member.DeclaringType), + MemberModel memberModel => ReferenceEquals(memberModel.Metadata.DeclaringType, member.DeclaringType), + _ => false, + }; } } } diff --git a/tests/DocumentationContextExtensionsTests.cs b/tests/DocumentationContextExtensionsTests.cs index f2080b88..a4174412 100644 --- a/tests/DocumentationContextExtensionsTests.cs +++ b/tests/DocumentationContextExtensionsTests.cs @@ -8,6 +8,7 @@ namespace Kampute.DocToolkit.Test using Kampute.DocToolkit; using Kampute.DocToolkit.Formatters; using Kampute.DocToolkit.IO.Writers; + using Kampute.DocToolkit.Languages; using Kampute.DocToolkit.Metadata; using Kampute.DocToolkit.Routing; using Kampute.DocToolkit.XmlDoc; @@ -173,6 +174,70 @@ public void TryTransformText_UnsupportedFormat_ReturnsFalse() } } + [Test] + public void DetermineNameQualifier_TypeMember_ReturnsDeclaringType() + { + using var docContext = MockHelper.CreateDocumentationContext(); + var typeMock = Mock.Of(); + + var result = docContext.DetermineNameQualifier(typeMock); + + Assert.That(result, Is.EqualTo(NameQualifier.DeclaringType)); + } + + [Test] + public void DetermineNameQualifier_OperatorMember_ReturnsNone() + { + using var docContext = MockHelper.CreateDocumentationContext(); + var operatorMock = Mock.Of(); + + var result = docContext.DetermineNameQualifier(operatorMock); + + Assert.That(result, Is.EqualTo(NameQualifier.None)); + } + + [Test] + public void DetermineNameQualifier_ExtensionMethod_ReturnsNone() + { + using var docContext = MockHelper.CreateDocumentationContext(); + var extensionMock = Mock.Of(m => m.IsExtension == true); + + var result = docContext.DetermineNameQualifier(extensionMock); + + Assert.That(result, Is.EqualTo(NameQualifier.None)); + } + + [Test] + public void DetermineNameQualifier_MemberInCurrentTypeScope_ReturnsNone() + { + var assembly = MockHelper.CreateAssembly("TestAssembly", ["Test.Namespace"]); + using var docContext = MockHelper.CreateDocumentationContext(assembly); + var typeModel = docContext.Types.First(); + docContext.AddressProvider.BeginScope("test", typeModel); + + var memberMock = Mock.Of(m => m.DeclaringType == typeModel.Metadata); + + var result = docContext.DetermineNameQualifier(memberMock); + + Assert.That(result, Is.EqualTo(NameQualifier.None)); + } + + [Test] + public void DetermineNameQualifier_MemberNotInCurrentTypeScope_ReturnsDeclaringType() + { + var assembly = MockHelper.CreateAssembly("TestAssembly", ["Test.Namespace"]); + using var docContext = MockHelper.CreateDocumentationContext(assembly); + var typeModel = docContext.Types.First(); + docContext.AddressProvider.BeginScope("test", typeModel); + + var otherType = Mock.Of(); + var memberMock = Mock.Of(m => m.DeclaringType == otherType); + + var result = docContext.DetermineNameQualifier(memberMock); + + Assert.That(result, Is.EqualTo(NameQualifier.DeclaringType)); + } + private sealed class TestFormatter : IDocumentFormatter { public TestFormatter() From 0f4005038e5a1bf11bd8650b29b0916da40c47da Mon Sep 17 00:00:00 2001 From: Kambiz Khojasteh Date: Tue, 23 Dec 2025 17:01:54 +0800 Subject: [PATCH 2/4] Refactor UrlTransformer initialization in DocumentationContext for extensibility Refactored DocumentationContext to initialize UrlTransformer via a new protected virtual CreateUrlTransformer() method. This allows derived classes to override and provide custom URL transformer implementations. --- src/DocumentationContext.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/DocumentationContext.cs b/src/DocumentationContext.cs index 158e1515..2ffc15d8 100644 --- a/src/DocumentationContext.cs +++ b/src/DocumentationContext.cs @@ -85,7 +85,7 @@ public DocumentationContext ? new TopicCollection(this, (_, topic) => ToScopedTopic(topic), topics) : throw new ArgumentNullException(nameof(topics)); - UrlTransformer = new ContextAwareUrlTransformer(this); + UrlTransformer = CreateUrlTransformer(); namespaces = new(GetAllNamespaces); types = new(GetAllTypes); @@ -216,6 +216,20 @@ protected void Dispose(bool disposing) /// protected virtual TopicModel ToScopedTopic(ITopic topic) => new(this, topic ?? throw new ArgumentNullException(nameof(topic))); + /// + /// Creates the URL transformer for the documentation context. + /// + /// The URL transformer to use for transforming non-API URLs in the documentation. + /// + /// This method creates an implementation of for transforming non-API + /// site-root-relative URLs to absolute or document-relative URLs. + /// + /// The default implementation returns an instance of the class. + /// Override this method in derived classes to provide a custom URL transformer implementation if needed. + /// + /// + protected virtual IUrlTransformer CreateUrlTransformer() => new ContextAwareUrlTransformer(this); + /// /// Retrieves all unique namespaces with exported types from the assemblies in the documentation context. /// From 9a01fde310b29179b71cff56d92aedaaf58a0bda Mon Sep 17 00:00:00 2001 From: Kambiz Khojasteh Date: Wed, 24 Dec 2025 01:18:59 +0800 Subject: [PATCH 3/4] Add UrlReference and UrlReferenceCollector with tests Introduce UrlReference to encapsulate documentation URL metadata, and UrlReferenceCollector to record all URLs processed by an IUrlTransformer. Add comprehensive unit tests for collector behavior, including constructor validation and URL recording logic. --- src/Routing/UrlReference.cs | 105 +++++++++++ src/Routing/UrlReferenceCollector.cs | 73 ++++++++ tests/Routing/UrlReferenceCollectorTests.cs | 186 ++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 src/Routing/UrlReference.cs create mode 100644 src/Routing/UrlReferenceCollector.cs create mode 100644 tests/Routing/UrlReferenceCollectorTests.cs diff --git a/src/Routing/UrlReference.cs b/src/Routing/UrlReference.cs new file mode 100644 index 00000000..746b17f4 --- /dev/null +++ b/src/Routing/UrlReference.cs @@ -0,0 +1,105 @@ +// Copyright (C) 2025 Kampute +// +// Released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.DocToolkit.Routing +{ + using System; + + /// + /// Represents a non-API URL that has been referenced in the documentation. + /// + public class UrlReference + { + /// + /// Initializes a new instance of the class using the specified scope. + /// + /// The scope in which the URL is referenced. + /// The original URL string from the documentation source (e.g., XML comment or topic). + /// The URI corresponding to the in the generated documentation, if available. + /// Thrown when or is . + /// Thrown when the does not have an associated documentation model. + public UrlReference(DocumentUrlContext scope, string sourceUrl, Uri? targetUrl = null) + { + if (scope is null) + throw new ArgumentNullException(nameof(scope)); + + ReferencingModel = scope.Model ?? throw new ArgumentException("The scope must have an associated documentation model.", nameof(scope)); + BaseDirectory = scope.Directory; + SourceUrl = sourceUrl ?? throw new ArgumentNullException(nameof(sourceUrl)); + TargetUrl = targetUrl; + } + + /// + /// Initializes a new instance of the class using the referencing model and base directory. + /// + /// The documentation model in which the URL is referenced. + /// The directory path of the referencing model's documentation page, relative to the documentation root. + /// The original URL string from the documentation source (e.g., XML comment or topic). + /// The URI corresponding to the in the generated documentation, if available. + /// Thrown when , , or is . + public UrlReference(IDocumentModel referencingModel, string baseDirectory, string sourceUrl, Uri? targetUrl = null) + { + ReferencingModel = referencingModel ?? throw new ArgumentNullException(nameof(referencingModel)); + BaseDirectory = baseDirectory ?? throw new ArgumentNullException(nameof(baseDirectory)); + SourceUrl = sourceUrl ?? throw new ArgumentNullException(nameof(sourceUrl)); + TargetUrl = targetUrl; + } + + /// + /// Gets the documentation model in which the URL is referenced. + /// + /// + /// The documentation model in which the URL is referenced. + /// + public IDocumentModel ReferencingModel { get; } + + /// + /// Gets the directory path of the referencing model's documentation page, relative to the documentation root. + /// + /// + /// A string representing the relative directory path of the referencing model's documentation page. + /// + /// + /// This path is relative to the root directory of the documentation site. + /// + /// When the is a relative URL, it is relative to this directory. + /// + /// + public string BaseDirectory { get; } + + /// + /// Gets the original URL string from the documentation source (e.g., XML comment or topic). + /// + /// + /// A string representing the source URL. + /// + public string SourceUrl { get; } + + /// + /// Gets the URL corresponding to the in the generated documentation. + /// + /// + /// A representing the target URL, or if the URL could not be resolved. + /// + /// + /// If the could not be resolved to a URL of an internal resource, this property will be . + /// Common reasons for a value include: + /// + /// The source string is not a well-formed absolute or relative URI. + /// The source contains only a fragment identifier or only a query string with no path to resolve. + /// The source points to a URL outside the scope of the documentation set. + /// + /// When the is a relative URL, it is relative to the directory of the referencing model's + /// documentation page as indicated by the property. + /// + public Uri? TargetUrl { get; } + + /// + /// Returns a string that represents the current . + /// + /// A string that represents the current . + public override string ToString() => SourceUrl; + } +} \ No newline at end of file diff --git a/src/Routing/UrlReferenceCollector.cs b/src/Routing/UrlReferenceCollector.cs new file mode 100644 index 00000000..056f9b1b --- /dev/null +++ b/src/Routing/UrlReferenceCollector.cs @@ -0,0 +1,73 @@ +// Copyright (C) 2025 Kampute +// +// Released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.DocToolkit.Routing +{ + using Kampute.DocToolkit; + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + + /// + /// Provides a URL transformer that records all URLs that are processed through it. + /// + /// + /// This class decorates an existing instance, intercepting URL transformation requests + /// to record all URLs that are processed. It maintains a collection of instances representing + /// the URLs that have been recorded, along with their associated documentation models. + /// + /// The recorded URLs can be used to validate links, generate reports, or perform other analysis on the URLs referenced + /// in the documentation topics or <see> or <seealso> tags with href attributes in XML + /// comments. + /// + /// + /// + public sealed class UrlReferenceCollector : IUrlTransformer + { + private readonly IDocumentationContext context; + private readonly IUrlTransformer urlTransformer; + private readonly ConcurrentBag urls = []; + + /// + /// Initializes a new instance of the class. + /// + /// The documentation context used to obtain the active model. + /// The inner URL transformer to decorate. + /// Thrown when or is . + public UrlReferenceCollector(IDocumentationContext context, IUrlTransformer urlTransformer) + { + this.urlTransformer = urlTransformer ?? throw new ArgumentNullException(nameof(urlTransformer)); + this.context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + /// Gets the collection of URLs that have been recorded so far. + /// + /// + /// A read-only collection of instances representing the URLs that have been recorded. + /// + public IReadOnlyCollection Urls => urls; + + /// + /// + /// Since class records all URLs that are processed through it, the + /// property always returns , regardless of the state of the underlying URL transformer. + /// + public bool MayTransformUrls => true; + + /// + public bool TryTransformUrl(string urlString, [NotNullWhen(true)] out Uri? transformedUrl) + { + var scope = context.AddressProvider.ActiveScope; + var transformed = urlTransformer.TryTransformUrl(urlString, out transformedUrl); + + if (scope.Model is not null) + urls.Add(new UrlReference(scope, urlString, transformedUrl)); + + return transformed; + } + } +} \ No newline at end of file diff --git a/tests/Routing/UrlReferenceCollectorTests.cs b/tests/Routing/UrlReferenceCollectorTests.cs new file mode 100644 index 00000000..6e676735 --- /dev/null +++ b/tests/Routing/UrlReferenceCollectorTests.cs @@ -0,0 +1,186 @@ +// Copyright (C) 2025 Kampute +// +// Released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.DocToolkit.Test.Routing +{ + using Kampute.DocToolkit.Formatters; + using Kampute.DocToolkit.Routing; + using Moq; + using NUnit.Framework; + using System; + using System.Linq; + + [TestFixture] + public class UrlReferenceCollectorTests + { + [Test] + public void Constructor_WithNullContext_ThrowsArgumentNullException() + { + var urlTransformerMock = new Mock(); + + Assert.That + ( + () => new UrlReferenceCollector(null!, urlTransformerMock.Object), + Throws.ArgumentNullException.With.Property("ParamName").EqualTo("context") + ); + } + + [Test] + public void Constructor_WithNullUrlTransformer_ThrowsArgumentNullException() + { + using var context = MockHelper.CreateDocumentationContext(); + + Assert.That + ( + () => new UrlReferenceCollector(context, null!), + Throws.ArgumentNullException.With.Property("ParamName").EqualTo("urlTransformer") + ); + } + + [Test] + public void MayTransformUrls_AlwaysReturnsTrue() + { + using var context = MockHelper.CreateDocumentationContext(); + var urlTransformerMock = Mock.Of(m => m.MayTransformUrls == false); + var collector = new UrlReferenceCollector(context, urlTransformerMock); + + Assert.That(collector.MayTransformUrls, Is.True); + } + + [Test] + public void Urls_Initially_ReturnsEmptyCollection() + { + using var context = MockHelper.CreateDocumentationContext(); + var urlTransformerMock = new Mock(); + var collector = new UrlReferenceCollector(context, urlTransformerMock.Object); + + Assert.That(collector.Urls, Is.Empty); + } + + [Test] + public void TryTransformUrl_WhenUnderlyingTransformerReturnsTrueAndModelIsNull_ReturnsTrueAndDoesNotAddUrlReference() + { + var expectedTransformedUri = new Uri("../transformed", UriKind.Relative); + + using var context = MockHelper.CreateDocumentationContext(); + + var urlTransformerMock = new Mock(); + urlTransformerMock.Setup(x => x.TryTransformUrl("test", out It.Ref.IsAny)) + .Returns((string url, out Uri? uri) => + { + uri = expectedTransformedUri; + return true; + }); + + var collector = new UrlReferenceCollector(context, urlTransformerMock.Object); + + using var _ = context.AddressProvider.BeginScope("dir", null); + + var result = collector.TryTransformUrl("test", out var transformedUrl); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(transformedUrl, Is.EqualTo(expectedTransformedUri)); + Assert.That(collector.Urls, Is.Empty); + } + } + + [Test] + public void TryTransformUrl_WhenUnderlyingTransformerReturnsTrueAndModelIsNotNull_ReturnsTrueAndAddsUrlReference() + { + var expectedTransformedUri = new Uri("../transformed", UriKind.Relative); + + var model = Mock.Of(); + using var context = MockHelper.CreateDocumentationContext(); + + var urlTransformerMock = new Mock(); + urlTransformerMock.Setup(x => x.TryTransformUrl("test", out It.Ref.IsAny)) + .Returns((string url, out Uri? uri) => + { + uri = expectedTransformedUri; + return true; + }); + + var collector = new UrlReferenceCollector(context, urlTransformerMock.Object); + + using var _ = context.AddressProvider.BeginScope("dir", model); + + var result = collector.TryTransformUrl("test", out var transformedUrl); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(transformedUrl, Is.EqualTo(expectedTransformedUri)); + Assert.That(collector.Urls, Has.Count.EqualTo(1)); + + var urlReference = collector.Urls.First(); + using (Assert.EnterMultipleScope()) + { + Assert.That(urlReference.ReferencingModel, Is.SameAs(model)); + Assert.That(urlReference.BaseDirectory, Is.EqualTo("dir")); + Assert.That(urlReference.SourceUrl, Is.EqualTo("test")); + Assert.That(urlReference.TargetUrl, Is.EqualTo(expectedTransformedUri)); + } + } + } + + [Test] + public void TryTransformUrl_WhenUnderlyingTransformerReturnsFalseAndModelIsNull_ReturnsFalseAndDoesNotAddUrlReference() + { + using var context = MockHelper.CreateDocumentationContext(); + + var urlTransformerMock = new Mock(); + urlTransformerMock.Setup(x => x.TryTransformUrl("test", out It.Ref.IsAny)).Returns(false); + + var collector = new UrlReferenceCollector(context, urlTransformerMock.Object); + + using var _ = context.AddressProvider.BeginScope("dir", null); + + var result = collector.TryTransformUrl("test", out var transformedUrl); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(transformedUrl, Is.Null); + Assert.That(collector.Urls, Is.Empty); + } + } + + [Test] + public void TryTransformUrl_WhenUnderlyingTransformerReturnsFalseAndModelIsNotNull_ReturnsFalseAndAddsUrlReference() + { + var expectedTransformedUri = new Uri("../transformed", UriKind.Relative); + + var model = Mock.Of(); + using var context = MockHelper.CreateDocumentationContext(); + + var urlTransformerMock = new Mock(); + urlTransformerMock.Setup(x => x.TryTransformUrl("test", out It.Ref.IsAny)).Returns(false); + + var collector = new UrlReferenceCollector(context, urlTransformerMock.Object); + + using var _ = context.AddressProvider.BeginScope("dir", model); + + var result = collector.TryTransformUrl("test", out var transformedUrl); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(transformedUrl, Is.Null); + Assert.That(collector.Urls, Has.Count.EqualTo(1)); + + var urlReference = collector.Urls.First(); + using (Assert.EnterMultipleScope()) + { + Assert.That(urlReference.ReferencingModel, Is.SameAs(model)); + Assert.That(urlReference.BaseDirectory, Is.EqualTo("dir")); + Assert.That(urlReference.SourceUrl, Is.EqualTo("test")); + Assert.That(urlReference.TargetUrl, Is.Null); + } + } + } + } +} \ No newline at end of file From fd6db3f623ad8f0dd17778456420a5b68c525e74 Mon Sep 17 00:00:00 2001 From: Kambiz Khojasteh Date: Wed, 24 Dec 2025 01:48:15 +0800 Subject: [PATCH 4/4] Update version to 2.1.0 and upgrade NUnit3TestAdapter to 6.0.1 --- kampose.json | 3 ++- src/Kampute.DocToolkit.csproj | 2 +- tests/Kampute.DocToolkit.Test.csproj | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/kampose.json b/kampose.json index 10151996..1fd7ae3f 100644 --- a/kampose.json +++ b/kampose.json @@ -6,10 +6,11 @@ "recommended" ], "includeImplicitConstructors": true, + "verifyExternalLinks": false, "stopOnIssues": true }, "assemblies": [ - "src/bin/Release/**/Kampute.DocToolkit.dll" + "src/bin/**/Kampute.DocToolkit.dll" ], "topics": [ "docs/**/*.md" diff --git a/src/Kampute.DocToolkit.csproj b/src/Kampute.DocToolkit.csproj index f0031839..65838d1e 100644 --- a/src/Kampute.DocToolkit.csproj +++ b/src/Kampute.DocToolkit.csproj @@ -4,7 +4,7 @@ netstandard2.1 Kampute.DocDotLib 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. - 2.0.1 + 2.1.0 Kampute Kambiz Khojasteh Copyright (c) 2025 Kampute diff --git a/tests/Kampute.DocToolkit.Test.csproj b/tests/Kampute.DocToolkit.Test.csproj index 68ff485f..ce94d7d8 100644 --- a/tests/Kampute.DocToolkit.Test.csproj +++ b/tests/Kampute.DocToolkit.Test.csproj @@ -23,7 +23,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - +