diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/DescriptorContext.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/DescriptorContext.cs index 3e58ed2faab..b8918d19ed8 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/DescriptorContext.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/DescriptorContext.cs @@ -69,7 +69,7 @@ public INamingConventions Naming field ??= GetConventionOrDefault(() => Options.UseXmlDocumentation ? new DefaultNamingConventions( new XmlDocumentationProvider( - new XmlDocumentationFileResolver( + new XmlDocumentationResolver( Options.ResolveXmlDocumentationFileName), _serviceHelper.GetStringBuilderPool())) : new DefaultNamingConventions( diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/IXmlDocumentationFileResolver.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/IXmlDocumentationResolver.cs similarity index 50% rename from src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/IXmlDocumentationFileResolver.cs rename to src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/IXmlDocumentationResolver.cs index 0173130f678..ccc08e3ac4f 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/IXmlDocumentationFileResolver.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/IXmlDocumentationResolver.cs @@ -7,12 +7,11 @@ namespace HotChocolate.Types.Descriptors; /// /// Resolves an XML documentation file from an assembly. /// -public interface IXmlDocumentationFileResolver +public interface IXmlDocumentationResolver { /// - /// Trues to resolve an XML documentation file from the given assembly.. + /// Trues to resolve an XML documentation element from the given assembly.. /// - bool TryGetXmlDocument( - Assembly assembly, - [NotNullWhen(true)] out XDocument? document); + bool TryGetXmlDocument(Assembly assembly, + [NotNullWhen(true)] out IReadOnlyDictionary? memberLookup); } diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/XmlDocumentationProvider.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/XmlDocumentationProvider.cs index b4f1df150cf..98803dc5eae 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/XmlDocumentationProvider.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/XmlDocumentationProvider.cs @@ -1,15 +1,12 @@ -using System.Globalization; using System.Reflection; using System.Text; using System.Text.RegularExpressions; using System.Xml.Linq; -using System.Xml.XPath; -using HotChocolate.Utilities; using Microsoft.Extensions.ObjectPool; namespace HotChocolate.Types.Descriptors; -public class XmlDocumentationProvider : IDocumentationProvider +public partial class XmlDocumentationProvider : IDocumentationProvider { private const string SummaryElementName = "summary"; private const string ExceptionElementName = "exception"; @@ -22,15 +19,21 @@ public class XmlDocumentationProvider : IDocumentationProvider private const string Code = "code"; private const string Paramref = "paramref"; private const string Name = "name"; - - private readonly IXmlDocumentationFileResolver _fileResolver; + private const BindingFlags BindingFlags = + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.Static + | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic + | System.Reflection.BindingFlags.DeclaredOnly; + + private readonly IXmlDocumentationResolver _documentationResolver; private readonly ObjectPool _stringBuilderPool; public XmlDocumentationProvider( - IXmlDocumentationFileResolver fileResolver, + IXmlDocumentationResolver documentationResolver, ObjectPool stringBuilderPool) { - _fileResolver = fileResolver ?? throw new ArgumentNullException(nameof(fileResolver)); + _documentationResolver = documentationResolver ?? throw new ArgumentNullException(nameof(documentationResolver)); _stringBuilderPool = stringBuilderPool; } @@ -49,15 +52,22 @@ public XmlDocumentationProvider( return null; } - var description = new StringBuilder(); - AppendText(element, description); + var description = _stringBuilderPool.Get(); + try + { + AppendText(element, description); + + if (description.Length == 0) + { + return null; + } - if (description.Length == 0) + return RemoveLineBreakWhiteSpaces(description); + } + finally { - return null; + _stringBuilderPool.Return(description); } - - return RemoveLineBreakWhiteSpaces(description.ToString()); } private string? GetDescriptionInternal(MemberInfo member) @@ -69,12 +79,14 @@ public XmlDocumentationProvider( return null; } - var description = ComposeMemberDescription( - element.Element(SummaryElementName), - element.Element(ReturnsElementName), - element.Elements(ExceptionElementName)); + var summaryNode = element.Element(SummaryElementName); + var returnsNode = element.Element(ReturnsElementName); + var exceptionNodes = element.Descendants(ExceptionElementName); - return RemoveLineBreakWhiteSpaces(description); + return ComposeMemberDescription( + summaryNode, + returnsNode, + exceptionNodes); } private string? ComposeMemberDescription( @@ -104,7 +116,7 @@ public XmlDocumentationProvider( AppendErrorDescription(errors, description, needsNewLine); - return description.Length == 0 ? null : description.ToString(); + return description.Length == 0 ? null : RemoveLineBreakWhiteSpaces(description); } finally { @@ -120,34 +132,39 @@ private void AppendErrorDescription( var errorCount = 0; foreach (var error in errors) { - var code = error.Attribute(Code); - if (code is { } - && !string.IsNullOrEmpty(error.Value) - && !string.IsNullOrEmpty(code.Value)) + if (!error.IsEmpty) { - if (errorCount == 0) - { - AppendNewLineIfNeeded(description, needsNewLine); - description.AppendLine("**Errors:**"); - } - else + var code = error.Attribute(Code); + if (code is not null) { - description.AppendLine(); + var codeValue = code.Value; + if (!string.IsNullOrEmpty(codeValue)) + { + if (errorCount == 0) + { + AppendNewLineIfNeeded(description, needsNewLine); + description.AppendLine("**Errors:**"); + } + else + { + description.AppendLine(); + } + + description.Append($"{++errorCount}. "); + description.Append($"{codeValue}: "); + + AppendText(error, description); + } } - - description.Append($"{++errorCount}. "); - description.Append($"{code.Value}: "); - - AppendText(error, description); } } } private static void AppendText( - XElement? element, + XElement element, StringBuilder description) { - if (element is null || string.IsNullOrWhiteSpace(element.Value)) + if (element.IsEmpty) { return; } @@ -167,7 +184,6 @@ private static void AppendText( if (currentElement.Name == Paramref) { var nameAttribute = currentElement.Attribute(Name); - if (nameAttribute != null) { description.Append(nameAttribute.Value); @@ -188,7 +204,7 @@ private static void AppendText( continue; } - if (!string.IsNullOrEmpty(currentElement.Value)) + if (!currentElement.IsEmpty) { description.Append(currentElement.Value); } @@ -197,9 +213,15 @@ private static void AppendText( attribute = currentElement.Attribute(Cref); if (attribute != null) { - description.Append(attribute.Value - .Trim('!', ':').Trim() - .Split('.').Last()); + var value = attribute.Value.AsSpan().Trim(['!', ':', ' ']); + + var lastDotIndex = value.LastIndexOf('.'); + if (lastDotIndex >= 0) + { + value = value[(lastDotIndex + 1)..]; + } + + description.Append(value); } else { @@ -213,14 +235,13 @@ private static void AppendText( } } - private void AppendNewLineIfNeeded( + private static void AppendNewLineIfNeeded( StringBuilder description, bool condition) { if (condition) { - description.AppendLine(); - description.AppendLine(); + description.Append("\n\n"); } } @@ -228,17 +249,17 @@ private void AppendNewLineIfNeeded( { try { - if (_fileResolver.TryGetXmlDocument( + if (_documentationResolver.TryGetXmlDocument( member.Module.Assembly, - out var document)) + out var elementLookup)) { var name = GetMemberElementName(member); - var element = document.XPathSelectElements(name.Path) - .FirstOrDefault(); - - ReplaceInheritdocElements(member, element); + if (!elementLookup.TryGetValue(name, out var element)) + { + return null; + } - return element; + return ReplaceInheritdocElements(member, element); } return null; @@ -253,33 +274,27 @@ private void AppendNewLineIfNeeded( { try { - if (_fileResolver.TryGetXmlDocument( + if (_documentationResolver.TryGetXmlDocument( parameter.Member.Module.Assembly, - out var document)) + out var elementLookup)) { var name = GetMemberElementName(parameter.Member); - var result = document.XPathSelectElements(name.Path); - var element = result.FirstOrDefault(); - - if (element is null) + if (!elementLookup.TryGetValue(name, out var element)) { return null; } - ReplaceInheritdocElements(parameter.Member, element); + element = ReplaceInheritdocElements(parameter.Member, element); if (parameter.IsRetval || string.IsNullOrEmpty(parameter.Name)) { - result = document.XPathSelectElements(name.ReturnsPath); - } - else - { - result = document.XPathSelectElements( - name.GetParameterPath(parameter.Name)); + return element.Element("returns"); } - return result.FirstOrDefault(); + return element + .Elements("param") + .FirstOrDefault(m => m.Attribute("name")?.Value == parameter.Name); } return null; @@ -290,192 +305,208 @@ private void AppendNewLineIfNeeded( } } - private void ReplaceInheritdocElements( - MemberInfo member, - XElement? element) + private IEnumerable? ProcessInheritdocInterfaceElements( + MemberInfo member) { - if (element is null) + if (member.DeclaringType is not null) { - return; - } - - var children = element.Nodes().ToList(); - foreach (var child in children.OfType()) - { - if (string.Equals(child.Name.LocalName, Inheritdoc, - StringComparison.OrdinalIgnoreCase)) + foreach (var baseInterface in member.DeclaringType.GetInterfaces()) { - var baseType = - member.DeclaringType?.GetTypeInfo().BaseType; - var baseMember = - baseType?.GetTypeInfo().DeclaredMembers - .SingleOrDefault(m => m.Name == member.Name); - + var baseMember = baseInterface.GetMember(member.Name, BindingFlags).SingleOrDefault(); if (baseMember != null) { var baseDoc = GetMemberElement(baseMember); if (baseDoc != null) { - var nodes = - baseDoc.Nodes().OfType().ToArray(); - child.ReplaceWith(nodes); - } - else - { - ProcessInheritdocInterfaceElements(member, child); + return baseDoc.Nodes(); } } - else - { - ProcessInheritdocInterfaceElements(member, child); - } } } + + return null; } - private void ProcessInheritdocInterfaceElements( + private XElement ReplaceInheritdocElements( MemberInfo member, - XElement child) + XElement element) { - if (member.DeclaringType is { }) + if (element.Element(Inheritdoc) is null) { - foreach (var baseInterface in member.DeclaringType - .GetTypeInfo().ImplementedInterfaces) - { - var baseMember = baseInterface.GetTypeInfo() - .DeclaredMembers.SingleOrDefault(m => - m.Name.EqualsOrdinal(member.Name)); - if (baseMember != null) - { - var baseDoc = GetMemberElement(baseMember); - if (baseDoc != null) - { - child.ReplaceWith( - baseDoc.Nodes().OfType().ToArray()); - } - } - } + return element; } - } - private static string? RemoveLineBreakWhiteSpaces(string? documentation) - { - if (string.IsNullOrWhiteSpace(documentation)) + var baseType = member.DeclaringType?.BaseType; + if (baseType is null) { - return null; + return element; } - documentation = - "\n" + documentation.Replace("\r", string.Empty).Trim('\n'); + // Shallow copy to ensure that we do not mutate the original element from the cache. + // We use a shallow copy instead of a deep copy (new XElement(element)) to avoid the allocation + // overhead since we only need to replace a few (generally 1) inheritdoc-elements. + var elementCopy = new XElement(element.Name); - var whitespace = - Regex.Match(documentation, "(\\n[ \\t]*)").Value; + var baseMember = baseType.GetMember(member.Name, BindingFlags).SingleOrDefault(); + foreach (var child in element.Elements()) + { + if (child.Name != Inheritdoc) + { + elementCopy.Add(child); + continue; + } - documentation = documentation.Replace(whitespace, "\n"); + if (baseMember != null) + { + var baseDoc = GetMemberElement(baseMember); + elementCopy.Add(baseDoc != null ? baseDoc.Nodes() : ProcessInheritdocInterfaceElements(member)); + } + else + { + elementCopy.Add(ProcessInheritdocInterfaceElements(member)); + } + } - return documentation.Trim('\n').Trim(); + return elementCopy; } - private static MemberName GetMemberElementName(MemberInfo member) + private static string? RemoveLineBreakWhiteSpaces(StringBuilder stringBuilder) { - char prefixCode; - - var memberName = - member is Type { FullName: { Length: > 0 } } memberType - ? memberType.FullName - : member.DeclaringType is null - ? member.Name - : member.DeclaringType.FullName + "." + member.Name; - - switch (member.MemberType) + if (stringBuilder.Length == 0) { - case MemberTypes.Constructor: - memberName = memberName.Replace(".ctor", "#ctor"); - goto case MemberTypes.Method; - - case MemberTypes.Method: - prefixCode = 'M'; - - var paramTypesList = string.Join(",", - ((MethodBase)member).GetParameters() - .Select(x => Regex - .Replace(x.ParameterType.FullName!, - "(`[0-9]+)|(, .*?PublicKeyToken=[0-9a-z]*)", - string.Empty) - .Replace("[[", "{") - .Replace("]]", "}") - .Replace("],[", ",")) - .ToArray()); - - if (!string.IsNullOrEmpty(paramTypesList)) - { - memberName += "(" + paramTypesList + ")"; - } - - break; + return null; + } - case MemberTypes.Event: - prefixCode = 'E'; - break; + if (stringBuilder[^1] == '\n') + { + stringBuilder.Remove(stringBuilder.Length - 1, 1); + } - case MemberTypes.Field: - prefixCode = 'F'; + var containsNewLineChar = false; + foreach (var chunk in stringBuilder.GetChunks()) + { + if (chunk.Span.Contains('\n')) + { + containsNewLineChar = true; break; + } + } - case MemberTypes.NestedType: - memberName = memberName?.Replace('+', '.'); - goto case MemberTypes.TypeInfo; - - case MemberTypes.TypeInfo: - prefixCode = 'T'; - break; + if (!containsNewLineChar) + { + return stringBuilder.ToString().Trim(); + } - case MemberTypes.Property: - prefixCode = 'P'; - break; + stringBuilder.Replace("\r", string.Empty); + if (stringBuilder[0] != '\n') + { + stringBuilder.Insert(0, '\n'); + } - default: - throw new ArgumentException( - "Unknown member type.", - nameof(member)); + var materializedString = stringBuilder.ToString(); + var whitespace = DetectWhitespaceIndentRegex().Match(materializedString).Value; + if (!string.IsNullOrEmpty(whitespace)) + { + stringBuilder.Replace(whitespace, "\n"); + materializedString = stringBuilder.ToString(); } - return new MemberName( - $"{prefixCode}:{memberName?.Replace("+", ".")}"); + return materializedString.Trim('\n', ' '); } - private ref struct MemberName + private string GetMemberElementName(MemberInfo member) { - private const string GetMemberDocPathFormat = "/doc/members/member[@name='{0}']"; - private const string ReturnsPathFormat = "{0}/returns"; - private const string ParamsPathFormat = "{0}/param[@name='{1}']"; - - public MemberName(string name) + var builder = _stringBuilderPool.Get(); + try { - Value = name; - Path = string.Format( - CultureInfo.InvariantCulture, - GetMemberDocPathFormat, - name); - ReturnsPath = string.Format( - CultureInfo.InvariantCulture, - ReturnsPathFormat, - Path); - } + if (member is Type { FullName.Length: > 0 } memberType) + { + builder.Append(memberType.FullName); + } + else if (member.DeclaringType is null) + { + builder.Append(member.Name); + } + else + { + builder.Append(member.DeclaringType.FullName).Append('.').Append(member.Name); + } + + char prefixCode; + switch (member.MemberType) + { + case MemberTypes.Constructor: + builder.Replace(".ctor", "#ctor"); + goto case MemberTypes.Method; + + case MemberTypes.Method: + prefixCode = 'M'; + + var parameters = ((MethodBase)member).GetParameters(); + if (parameters.Length > 0) + { + builder.Append('('); + for (var index = 0; index < parameters.Length; index++) + { + var parameterInfo = parameters[index]; + var result = NormalizeParameterNameRegex() + .Replace(parameterInfo.ParameterType.FullName!, + static m => m.Value switch + { + "[[" => "{", + "]]" => "}", + "],[" => ",", + _ => "" + }); + builder.Append(result); + if (index < parameters.Length - 1) + { + builder.Append(','); + } + } + + builder.Append(')'); + } + + break; + + case MemberTypes.Event: + prefixCode = 'E'; + break; - public string Value { get; } + case MemberTypes.Field: + prefixCode = 'F'; + break; - public string Path { get; } + case MemberTypes.NestedType: + builder.Replace('+', '.'); + goto case MemberTypes.TypeInfo; - public string ReturnsPath { get; } + case MemberTypes.TypeInfo: + prefixCode = 'T'; + break; - public string GetParameterPath(string name) + case MemberTypes.Property: + prefixCode = 'P'; + break; + + default: + throw new ArgumentException( + "Unknown member type.", + nameof(member)); + } + + return builder.Insert(0, prefixCode).Insert(1, ':').Replace("+", ".").ToString(); + } + finally { - return string.Format( - CultureInfo.InvariantCulture, - ParamsPathFormat, - Path, - name); + _stringBuilderPool.Return(builder); } } + + [GeneratedRegex("(\\n[ \\t]*)")] + private static partial Regex DetectWhitespaceIndentRegex(); + + [GeneratedRegex(@"(`\d+)|(, .*?PublicKeyToken=[0-9a-z]*)|\[\[|\]\]|\],\[")] + private static partial Regex NormalizeParameterNameRegex(); } diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/XmlDocumentationFileResolver.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/XmlDocumentationResolver.cs similarity index 72% rename from src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/XmlDocumentationFileResolver.cs rename to src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/XmlDocumentationResolver.cs index 58530014338..3b57cc91c46 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/XmlDocumentationFileResolver.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/Conventions/XmlDocumentationResolver.cs @@ -6,44 +6,54 @@ namespace HotChocolate.Types.Descriptors; -public class XmlDocumentationFileResolver : IXmlDocumentationFileResolver +public class XmlDocumentationResolver : IXmlDocumentationResolver { private const string Bin = "bin"; private readonly Func? _resolveXmlDocumentationFileName; - private readonly ConcurrentDictionary _cache = - new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary?> _cache = + new(StringComparer.Ordinal); - public XmlDocumentationFileResolver() + public XmlDocumentationResolver() { _resolveXmlDocumentationFileName = null; } - public XmlDocumentationFileResolver(Func? resolveXmlDocumentationFileName) + public XmlDocumentationResolver(Func? resolveXmlDocumentationFileName) { _resolveXmlDocumentationFileName = resolveXmlDocumentationFileName; } public bool TryGetXmlDocument( Assembly assembly, - [NotNullWhen(true)] out XDocument? document) + [NotNullWhen(true)] out IReadOnlyDictionary? memberLookup) { var fullName = assembly.GetName().FullName; - if (!_cache.TryGetValue(fullName, out var doc)) + if (!_cache.TryGetValue(fullName, out memberLookup)) { var xmlDocumentFileName = GetXmlDocumentationPath(assembly); - if (xmlDocumentFileName is not null && File.Exists(xmlDocumentFileName)) { - doc = XDocument.Load(xmlDocumentFileName, LoadOptions.PreserveWhitespace); - _cache[fullName] = doc; + var doc = XDocument.Load(xmlDocumentFileName, LoadOptions.PreserveWhitespace); + memberLookup = + doc.Element("doc")? + .Element("members")? + .Elements("member") + .Where(static x => x.Attribute("name") != null) + .ToDictionary(static x => x.Attribute("name")!.Value, static delegate(XElement x) + { + // Optimize memory usage: We already stored the name as key in the dictionary. + x.RemoveAttributes(); + return x; + }); } + + _cache.TryAdd(fullName, memberLookup); } - document = doc; - return document != null; + return memberLookup != null; } private string? GetXmlDocumentationPath(Assembly? assembly) diff --git a/src/HotChocolate/Core/test/Types.Tests/HotChocolate.Types.Tests.csproj b/src/HotChocolate/Core/test/Types.Tests/HotChocolate.Types.Tests.csproj index fcfee76f3fe..c934c01195f 100644 --- a/src/HotChocolate/Core/test/Types.Tests/HotChocolate.Types.Tests.csproj +++ b/src/HotChocolate/Core/test/Types.Tests/HotChocolate.Types.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/Conventions/DefaultNamingConventionsTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/Conventions/DefaultNamingConventionsTests.cs index 4b76e42a431..7f44e5c27c8 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/Conventions/DefaultNamingConventionsTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/Conventions/DefaultNamingConventionsTests.cs @@ -19,7 +19,7 @@ public void GetFormattedFieldName_ReturnsFormattedFieldName(string fieldName, st // arrange var namingConventions = new DefaultNamingConventions( new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool())); // act @@ -44,7 +44,7 @@ public void GetEnumName(string runtimeName, string expectedSchemaName) // arrange var namingConventions = new DefaultNamingConventions( new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool())); // act @@ -64,7 +64,7 @@ public void GetEnumValueDescription_NoDescription(object value) // arrange var namingConventions = new DefaultNamingConventions( new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool())); // act @@ -80,7 +80,7 @@ public void GetEnumValueDescription_XmlDescription() // arrange var namingConventions = new DefaultNamingConventions( new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool())); // act @@ -96,7 +96,7 @@ public void GetEnumValueDescription_AttributeDescription() // arrange var namingConventions = new DefaultNamingConventions( new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool())); // act var result = namingConventions.GetEnumValueDescription(Foo.Baz); @@ -117,7 +117,7 @@ public void Input_Naming_Convention(Type type, string expectedName) // arrange var namingConventions = new DefaultNamingConventions( new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool())); // act diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/Conventions/XmlDocumentProviderBenchmarks.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/Conventions/XmlDocumentProviderBenchmarks.cs new file mode 100644 index 00000000000..501c4c94eee --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/Conventions/XmlDocumentProviderBenchmarks.cs @@ -0,0 +1,1260 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Globalization; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using System.Xml.XPath; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Running; +using HotChocolate.Utilities; +using Microsoft.Extensions.ObjectPool; +using Xunit.Abstractions; +using IOPath = System.IO.Path; + +#pragma warning disable + +namespace HotChocolate.Types.Descriptors; + +public class XmlDocumentProviderBenchmarks +{ + private readonly ITestOutputHelper _testOutputHelper; + + public XmlDocumentProviderBenchmarks(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact(Skip = "Run manually when required")] + public void RunBenchmarks() + { + var config = ManualConfig.Create(DefaultConfig.Instance) + .AddDiagnoser(MemoryDiagnoser.Default) + .AddJob(Job.ShortRun) + .AddExporter(MarkdownExporter.GitHub); + + var summary = BenchmarkRunner.Run(typeof(Bench), config); + + var files = MarkdownExporter.GitHub.ExportToFiles(summary, NullLogger.Instance); + + _testOutputHelper.WriteLine($"Benchmark report saved to: {string.Join(" ,", files)}"); + _testOutputHelper.WriteLine(string.Join("\n", summary.ValidationErrors)); + } + + public class Bench + { + private static readonly XmlDocumentationProvider s_documentationProvider = new XmlDocumentationProvider( + new XmlDocumentationResolver(), + new DefaultObjectPoolProvider().CreateStringBuilderPool()); + + private static readonly OldXmlDocumentationProvider s_oldDocumentationProvider = new OldXmlDocumentationProvider( + new OldXmlDocumentationFileResolver(), + new DefaultObjectPoolProvider().CreateStringBuilderPool()); + + // Example parameterization + [Params(1, 10, 100, 1000, 10000, 100000)] public int N { get; set; } + + [Benchmark] + public void When_xml_doc_is_missing_then_description_is_empty() + { + for (int i = 0; i < N; i++) + { + s_documentationProvider.GetDescription(typeof(Point)); + } + } + + [Benchmark] + public void When_xml_doc_is_missing_then_description_is_empty_old() + { + for (int i = 0; i < N; i++) + { + s_oldDocumentationProvider.GetDescription(typeof(Point)); + } + } + + [Benchmark] + public void When_xml_doc_with_multiple_breaks_is_read_then_they_are_not_stripped_away() + { + for (int i = 0; i < N; i++) + { + s_documentationProvider.GetDescription( + typeof(WithMultilineXmlDoc) + .GetProperty(nameof(WithMultilineXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_xml_doc_with_multiple_breaks_is_read_then_they_are_not_stripped_away_old() + { + for (int i = 0; i < N; i++) + { + s_oldDocumentationProvider.GetDescription( + typeof(WithMultilineXmlDoc) + .GetProperty(nameof(WithMultilineXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_description_has_see_tag_then_it_is_converted() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(WithSeeTagInXmlDoc) + .GetProperty(nameof(WithSeeTagInXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_description_has_see_tag_then_it_is_converted_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(WithSeeTagInXmlDoc) + .GetProperty(nameof(WithSeeTagInXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_description_has_paramref_tag_then_it_is_converted() + { + for (int i = 0; i < N; i++) + { + s_documentationProvider.GetDescription( + typeof(WithParamrefTagInXmlDoc) + .GetMethod(nameof(WithParamrefTagInXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_description_has_paramref_tag_then_it_is_converted_old() + { + for (int i = 0; i < N; i++) + { + s_oldDocumentationProvider.GetDescription( + typeof(WithParamrefTagInXmlDoc) + .GetMethod(nameof(WithParamrefTagInXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_description_has_generic_tags_then_it_is_converted() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(WithGenericTagsInXmlDoc) + .GetProperty(nameof(WithGenericTagsInXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_description_has_generic_tags_then_it_is_converted_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(WithGenericTagsInXmlDoc) + .GetProperty(nameof(WithGenericTagsInXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_type_has_description_then_it_it_resolved() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(BaseBaseClass)); + } + } + + [Benchmark] + public void When_type_has_description_then_it_it_resolved_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(BaseBaseClass)); + } + } + + [Benchmark] + public void When_we_use_custom_documentation_files_they_are_correctly_loaded() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(BaseBaseClass)); + } + } + + [Benchmark] + public void When_we_use_custom_documentation_files_they_are_correctly_loaded_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(BaseBaseClass)); + } + } + + [Benchmark] + public void When_parameter_has_inheritdoc_then_it_is_resolved() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(ClassWithInheritdoc) + .GetMethod(nameof(ClassWithInheritdoc.Bar))! + .GetParameters() + .Single(p => p.Name == "baz")); + } + } + + [Benchmark] + public void When_parameter_has_inheritdoc_then_it_is_resolved_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(ClassWithInheritdoc) + .GetMethod(nameof(ClassWithInheritdoc.Bar))! + .GetParameters() + .Single(p => p.Name == "baz")); + } + } + + [Benchmark] + public void When_method_has_inheritdoc_then_it_is_resolved() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(ClassWithInheritdoc) + .GetMethod(nameof(ClassWithInheritdoc.Bar))!); + } + } + + [Benchmark] + public void When_method_has_inheritdoc_then_it_is_resolved_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(ClassWithInheritdoc) + .GetMethod(nameof(ClassWithInheritdoc.Bar))!); + } + } + + [Benchmark] + public void When_property_has_inheritdoc_then_it_is_resolved() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(ClassWithInheritdoc) + .GetProperty(nameof(ClassWithInheritdoc.Foo))!); + } + } + + [Benchmark] + public void When_property_has_inheritdoc_then_it_is_resolved_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(ClassWithInheritdoc) + .GetProperty(nameof(ClassWithInheritdoc.Foo))!); + } + } + + [Benchmark] + public void When_type_is_an_interface_then_description_is_resolved() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(IBaseBaseInterface)); + } + } + [Benchmark] + public void When_type_is_an_interface_then_description_is_resolved_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(IBaseBaseInterface)); + } + } + + [Benchmark] + public void When_parameter_has_inheritdoc_on_interface_then_it_is_resolved() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(ClassWithInheritdocOnInterface) + .GetMethod(nameof(ClassWithInheritdocOnInterface.Bar))! + .GetParameters() + .Single(p => p.Name == "baz")); + } + } + + [Benchmark] + public void When_parameter_has_inheritdoc_on_interface_then_it_is_resolved_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(ClassWithInheritdocOnInterface) + .GetMethod(nameof(ClassWithInheritdocOnInterface.Bar))! + .GetParameters() + .Single(p => p.Name == "baz")); + } + } + + + [Benchmark] + public void When_property_has_inheritdoc_on_interface_then_it_is_resolved() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(ClassWithInheritdocOnInterface) + .GetProperty(nameof(ClassWithInheritdocOnInterface.Foo))!); + } + } + + [Benchmark] + public void When_property_has_inheritdoc_on_interface_then_it_is_resolved_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(ClassWithInheritdocOnInterface) + .GetProperty(nameof(ClassWithInheritdocOnInterface.Foo))!); + } + } + + [Benchmark] + public void When_method_has_inheritdoc_then_on_interface_it_is_resolved() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(ClassWithInheritdocOnInterface) + .GetMethod(nameof(ClassWithInheritdocOnInterface.Bar))!); + } + } + + [Benchmark] + public void When_method_has_inheritdoc_then_on_interface_it_is_resolved_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(ClassWithInheritdocOnInterface) + .GetMethod(nameof(ClassWithInheritdocOnInterface.Bar))!); + } + } + + [Benchmark] + public void When_class_implements_interface_and_property_has_description_then_property_description_is_used() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(ClassWithInterfaceAndCustomSummaries) + .GetProperty(nameof(ClassWithInterfaceAndCustomSummaries.Foo))!); + } + } + + [Benchmark] + public void When_class_implements_interface_and_property_has_description_then_property_description_is_used_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(ClassWithInterfaceAndCustomSummaries) + .GetProperty(nameof(ClassWithInterfaceAndCustomSummaries.Foo))!); + } + } + + [Benchmark] + public void When_class_implements_interface_and_method_has_description_then_method_description_is_used() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(ClassWithInterfaceAndCustomSummaries) + .GetMethod(nameof(ClassWithInterfaceAndCustomSummaries.Bar))!); + } + } + + [Benchmark] + public void When_class_implements_interface_and_method_has_description_then_method_description_is_used_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(ClassWithInterfaceAndCustomSummaries) + .GetMethod(nameof(ClassWithInterfaceAndCustomSummaries.Bar))!); + } + } + + [Benchmark] + public void + When_class_implements_interface_and_method_has_description_then_method_parameter_description_is_used() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(ClassWithInterfaceAndCustomSummaries) + .GetMethod(nameof(ClassWithInterfaceAndCustomSummaries.Bar))! + .GetParameters() + .Single(p => p.Name == "baz")); + } + } + + [Benchmark] + public void + When_class_implements_interface_and_method_has_description_then_method_parameter_description_is_used_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(ClassWithInterfaceAndCustomSummaries) + .GetMethod(nameof(ClassWithInterfaceAndCustomSummaries.Bar))! + .GetParameters() + .Single(p => p.Name == "baz")); + } + } + + [Benchmark] + public void When_class_has_description_then_it_is_converted() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(ClassWithSummary)); + } + } + + [Benchmark] + public void When_class_has_description_then_it_is_converted_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(ClassWithSummary)); + } + } + + [Benchmark] + public void When_method_has_exceptions_then_it_is_converted() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(WithExceptionsXmlDoc).GetMethod(nameof(WithExceptionsXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_method_has_exceptions_then_it_is_converted_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(WithExceptionsXmlDoc).GetMethod(nameof(WithExceptionsXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_method_has_exceptions_then_exceptions_with_no_code_will_be_ignored() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(WithExceptionsXmlDoc).GetMethod(nameof(WithExceptionsXmlDoc.Bar))!); + } + } + + [Benchmark] + public void When_method_has_exceptions_then_exceptions_with_no_code_will_be_ignored_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(WithExceptionsXmlDoc).GetMethod(nameof(WithExceptionsXmlDoc.Bar))!); + } + } + + [Benchmark] + public void When_method_has_only_exceptions_with_no_code_then_error_section_will_not_be_written() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(WithExceptionsXmlDoc).GetMethod(nameof(WithExceptionsXmlDoc.Baz))!); + } + } + + [Benchmark] + public void When_method_has_only_exceptions_with_no_code_then_error_section_will_not_be_written_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(WithExceptionsXmlDoc).GetMethod(nameof(WithExceptionsXmlDoc.Baz))!); + } + } + + [Benchmark] + public void When_method_has_no_exceptions_then_it_is_ignored() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(WithoutExceptionsXmlDoc).GetMethod(nameof(WithoutExceptionsXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_method_has_no_exceptions_then_it_is_ignored_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(WithoutExceptionsXmlDoc).GetMethod(nameof(WithoutExceptionsXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_method_has_returns_then_it_is_converted() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(WithReturnsXmlDoc).GetMethod(nameof(WithReturnsXmlDoc.Foo))!); + } + } + + + [Benchmark] + public void When_method_has_returns_then_it_is_converted_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(WithReturnsXmlDoc).GetMethod(nameof(WithReturnsXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_method_has_no_returns_then_it_is_ignored() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(WithoutReturnsXmlDoc).GetMethod(nameof(WithoutReturnsXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_method_has_no_returns_then_it_is_ignored_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(WithoutReturnsXmlDoc).GetMethod(nameof(WithoutReturnsXmlDoc.Foo))!); + } + } + + [Benchmark] + public void When_method_has_dictionary_args_then_it_is_found() + { + for (int i = 0; i < N; i++) + { + // act + s_documentationProvider.GetDescription( + typeof(WithDictionaryArgs).GetMethod(nameof(WithDictionaryArgs.Method))!); + } + } + + [Benchmark] + public void When_method_has_dictionary_args_then_it_is_found_old() + { + for (int i = 0; i < N; i++) + { + // act + s_oldDocumentationProvider.GetDescription( + typeof(WithDictionaryArgs).GetMethod(nameof(WithDictionaryArgs.Method))!); + } + } + } +} + +/// +/// Resolves an XML documentation file from an assembly. +/// +public interface IOldXmlDocumentationFileResolver +{ + /// + /// Trues to resolve an XML documentation file from the given assembly.. + /// + bool TryGetXmlDocument(Assembly assembly, + [NotNullWhen(true)] out XDocument? document); +} + + +public class OldXmlDocumentationFileResolver : IOldXmlDocumentationFileResolver +{ + private const string Bin = "bin"; + + private readonly Func? _resolveXmlDocumentationFileName; + + private readonly ConcurrentDictionary _cache = + new(StringComparer.OrdinalIgnoreCase); + + public OldXmlDocumentationFileResolver() + { + _resolveXmlDocumentationFileName = null; + } + + public OldXmlDocumentationFileResolver(Func? resolveXmlDocumentationFileName) + { + _resolveXmlDocumentationFileName = resolveXmlDocumentationFileName; + } + + public bool TryGetXmlDocument( + Assembly assembly, + [NotNullWhen(true)] out XDocument? document) + { + var fullName = assembly.GetName().FullName; + + if (!_cache.TryGetValue(fullName, out var doc)) + { + var xmlDocumentFileName = GetXmlDocumentationPath(assembly); + + if (xmlDocumentFileName is not null && File.Exists(xmlDocumentFileName)) + { + doc = XDocument.Load(xmlDocumentFileName, LoadOptions.PreserveWhitespace); + _cache[fullName] = doc; + } + } + + document = doc; + return document != null; + } + + private string? GetXmlDocumentationPath(Assembly? assembly) + { + try + { + if (assembly is null) + { + return null; + } + + var assemblyName = assembly.GetName(); + if (string.IsNullOrEmpty(assemblyName.Name)) + { + return null; + } + + if (_cache.ContainsKey(assemblyName.FullName)) + { + return null; + } + + var expectedDocFile = _resolveXmlDocumentationFileName is null + ? $"{assemblyName.Name}.xml" + : _resolveXmlDocumentationFileName(assembly); + + string path; + if (!string.IsNullOrEmpty(assembly.Location)) + { + var assemblyDirectory = IOPath.GetDirectoryName(assembly.Location); + path = IOPath.Combine(assemblyDirectory!, expectedDocFile); + if (File.Exists(path)) + { + return path; + } + } + +#pragma warning disable SYSLIB0012 + var codeBase = assembly.CodeBase; +#pragma warning restore SYSLIB0012 + if (!string.IsNullOrEmpty(codeBase)) + { + path = IOPath.Combine( + IOPath.GetDirectoryName(codeBase.Replace("file:///", string.Empty))!, + expectedDocFile) + .Replace("file:\\", string.Empty); + + if (File.Exists(path)) + { + return path; + } + } + + var baseDirectory = AppDomain.CurrentDomain.BaseDirectory; + if (!string.IsNullOrEmpty(baseDirectory)) + { + path = IOPath.Combine(baseDirectory, expectedDocFile); + if (File.Exists(path)) + { + return path; + } + + return IOPath.Combine(baseDirectory, Bin, expectedDocFile); + } + + var currentDirectory = Directory.GetCurrentDirectory(); + path = IOPath.Combine(currentDirectory, expectedDocFile); + if (File.Exists(path)) + { + return path; + } + + path = IOPath.Combine(currentDirectory, Bin, expectedDocFile); + + if (File.Exists(path)) + { + return path; + } + + return null; + } + catch + { + return null; + } + } +} + + +public class OldXmlDocumentationProvider : IDocumentationProvider +{ + private const string SummaryElementName = "summary"; + private const string ExceptionElementName = "exception"; + private const string ReturnsElementName = "returns"; + private const string Inheritdoc = "inheritdoc"; + private const string See = "see"; + private const string Langword = "langword"; + private const string Cref = "cref"; + private const string Href = "href"; + private const string Code = "code"; + private const string Paramref = "paramref"; + private const string Name = "name"; + + private readonly IOldXmlDocumentationFileResolver _fileResolver; + private readonly ObjectPool _stringBuilderPool; + + public OldXmlDocumentationProvider( + IOldXmlDocumentationFileResolver fileResolver, + ObjectPool stringBuilderPool) + { + _fileResolver = fileResolver ?? throw new ArgumentNullException(nameof(fileResolver)); + _stringBuilderPool = stringBuilderPool; + } + + public string? GetDescription(Type type) => + GetDescriptionInternal(type); + + public string? GetDescription(MemberInfo member) => + GetDescriptionInternal(member); + + public string? GetDescription(ParameterInfo parameter) + { + var element = GetParameterElement(parameter); + + if (element is null) + { + return null; + } + + var description = new StringBuilder(); + AppendText(element, description); + + if (description.Length == 0) + { + return null; + } + + return RemoveLineBreakWhiteSpaces(description.ToString()); + } + + private string? GetDescriptionInternal(MemberInfo member) + { + var element = GetMemberElement(member); + + if (element is null) + { + return null; + } + + var description = ComposeMemberDescription( + element.Element(SummaryElementName), + element.Element(ReturnsElementName), + element.Elements(ExceptionElementName)); + + return RemoveLineBreakWhiteSpaces(description); + } + + private string? ComposeMemberDescription( + XElement? summary, + XElement? returns, + IEnumerable errors) + { + var description = _stringBuilderPool.Get(); + + try + { + var needsNewLine = false; + + if (!string.IsNullOrEmpty(summary?.Value)) + { + AppendText(summary, description); + needsNewLine = true; + } + + if (!string.IsNullOrEmpty(returns?.Value)) + { + AppendNewLineIfNeeded(description, needsNewLine); + description.AppendLine("**Returns:**"); + AppendText(returns, description); + needsNewLine = true; + } + + AppendErrorDescription(errors, description, needsNewLine); + + return description.Length == 0 ? null : description.ToString(); + } + finally + { + _stringBuilderPool.Return(description); + } + } + + private void AppendErrorDescription( + IEnumerable errors, + StringBuilder description, + bool needsNewLine) + { + var errorCount = 0; + foreach (var error in errors) + { + var code = error.Attribute(Code); + if (code is { } + && !string.IsNullOrEmpty(error.Value) + && !string.IsNullOrEmpty(code.Value)) + { + if (errorCount == 0) + { + AppendNewLineIfNeeded(description, needsNewLine); + description.AppendLine("**Errors:**"); + } + else + { + description.AppendLine(); + } + + description.Append($"{++errorCount}. "); + description.Append($"{code.Value}: "); + + AppendText(error, description); + } + } + } + + private static void AppendText( + XElement? element, + StringBuilder description) + { + if (element is null || string.IsNullOrWhiteSpace(element.Value)) + { + return; + } + + foreach (var node in element.Nodes()) + { + if (node is not XElement currentElement) + { + if (node is XText text) + { + description.Append(text.Value); + } + + continue; + } + + if (currentElement.Name == Paramref) + { + var nameAttribute = currentElement.Attribute(Name); + + if (nameAttribute != null) + { + description.Append(nameAttribute.Value); + continue; + } + } + + if (currentElement.Name != See) + { + description.Append(currentElement.Value); + continue; + } + + var attribute = currentElement.Attribute(Langword); + if (attribute != null) + { + description.Append(attribute.Value); + continue; + } + + if (!string.IsNullOrEmpty(currentElement.Value)) + { + description.Append(currentElement.Value); + } + else + { + attribute = currentElement.Attribute(Cref); + if (attribute != null) + { + description.Append(attribute.Value + .Trim('!', ':').Trim() + .Split('.').Last()); + } + else + { + attribute = currentElement.Attribute(Href); + if (attribute != null) + { + description.Append(attribute.Value); + } + } + } + } + } + + private void AppendNewLineIfNeeded( + StringBuilder description, + bool condition) + { + if (condition) + { + description.AppendLine(); + description.AppendLine(); + } + } + + private XElement? GetMemberElement(MemberInfo member) + { + try + { + if (_fileResolver.TryGetXmlDocument( + member.Module.Assembly, + out var document)) + { + var name = GetMemberElementName(member); + var element = document.XPathSelectElements(name.Path) + .FirstOrDefault(); + + ReplaceInheritdocElements(member, element); + + return element; + } + + return null; + } + catch + { + return null; + } + } + + private XElement? GetParameterElement(ParameterInfo parameter) + { + try + { + if (_fileResolver.TryGetXmlDocument( + parameter.Member.Module.Assembly, + out var document)) + { + var name = GetMemberElementName(parameter.Member); + var result = document.XPathSelectElements(name.Path); + var element = result.FirstOrDefault(); + + if (element is null) + { + return null; + } + + ReplaceInheritdocElements(parameter.Member, element); + + if (parameter.IsRetval + || string.IsNullOrEmpty(parameter.Name)) + { + result = document.XPathSelectElements(name.ReturnsPath); + } + else + { + result = document.XPathSelectElements( + name.GetParameterPath(parameter.Name)); + } + + return result.FirstOrDefault(); + } + + return null; + } + catch + { + return null; + } + } + + private void ReplaceInheritdocElements( + MemberInfo member, + XElement? element) + { + if (element is null) + { + return; + } + + var children = element.Nodes().ToList(); + foreach (var child in children.OfType()) + { + if (string.Equals(child.Name.LocalName, Inheritdoc, + StringComparison.OrdinalIgnoreCase)) + { + var baseType = + member.DeclaringType?.GetTypeInfo().BaseType; + var baseMember = + baseType?.GetTypeInfo().DeclaredMembers + .SingleOrDefault(m => m.Name == member.Name); + + if (baseMember != null) + { + var baseDoc = GetMemberElement(baseMember); + if (baseDoc != null) + { + var nodes = + baseDoc.Nodes().OfType().ToArray(); + child.ReplaceWith(nodes); + } + else + { + ProcessInheritdocInterfaceElements(member, child); + } + } + else + { + ProcessInheritdocInterfaceElements(member, child); + } + } + } + } + + private void ProcessInheritdocInterfaceElements( + MemberInfo member, + XElement child) + { + if (member.DeclaringType is { }) + { + foreach (var baseInterface in member.DeclaringType + .GetTypeInfo().ImplementedInterfaces) + { + var baseMember = baseInterface.GetTypeInfo() + .DeclaredMembers.SingleOrDefault(m => + m.Name.EqualsOrdinal(member.Name)); + if (baseMember != null) + { + var baseDoc = GetMemberElement(baseMember); + if (baseDoc != null) + { + child.ReplaceWith( + baseDoc.Nodes().OfType().ToArray()); + } + } + } + } + } + + private static string? RemoveLineBreakWhiteSpaces(string? documentation) + { + if (string.IsNullOrWhiteSpace(documentation)) + { + return null; + } + + documentation = + "\n" + documentation.Replace("\r", string.Empty).Trim('\n'); + + var whitespace = + Regex.Match(documentation, "(\\n[ \\t]*)").Value; + + documentation = documentation.Replace(whitespace, "\n"); + + return documentation.Trim('\n').Trim(); + } + + private static MemberName GetMemberElementName(MemberInfo member) + { + char prefixCode; + + var memberName = + member is Type { FullName: { Length: > 0 } } memberType + ? memberType.FullName + : member.DeclaringType is null + ? member.Name + : member.DeclaringType.FullName + "." + member.Name; + + switch (member.MemberType) + { + case MemberTypes.Constructor: + memberName = memberName.Replace(".ctor", "#ctor"); + goto case MemberTypes.Method; + + case MemberTypes.Method: + prefixCode = 'M'; + + var paramTypesList = string.Join(",", + ((MethodBase)member).GetParameters() + .Select(x => Regex + .Replace(x.ParameterType.FullName!, + "(`[0-9]+)|(, .*?PublicKeyToken=[0-9a-z]*)", + string.Empty) + .Replace("[[", "{") + .Replace("]]", "}") + .Replace("],[", ",")) + .ToArray()); + + if (!string.IsNullOrEmpty(paramTypesList)) + { + memberName += "(" + paramTypesList + ")"; + } + + break; + + case MemberTypes.Event: + prefixCode = 'E'; + break; + + case MemberTypes.Field: + prefixCode = 'F'; + break; + + case MemberTypes.NestedType: + memberName = memberName?.Replace('+', '.'); + goto case MemberTypes.TypeInfo; + + case MemberTypes.TypeInfo: + prefixCode = 'T'; + break; + + case MemberTypes.Property: + prefixCode = 'P'; + break; + + default: + throw new ArgumentException( + "Unknown member type.", + nameof(member)); + } + + return new MemberName( + $"{prefixCode}:{memberName?.Replace("+", ".")}"); + } + + private ref struct MemberName + { + private const string GetMemberDocPathFormat = "/doc/members/member[@name='{0}']"; + private const string ReturnsPathFormat = "{0}/returns"; + private const string ParamsPathFormat = "{0}/param[@name='{1}']"; + + public MemberName(string name) + { + Value = name; + Path = string.Format( + CultureInfo.InvariantCulture, + GetMemberDocPathFormat, + name); + ReturnsPath = string.Format( + CultureInfo.InvariantCulture, + ReturnsPathFormat, + Path); + } + + public string Value { get; } + + public string Path { get; } + + public string ReturnsPath { get; } + + public string GetParameterPath(string name) + { + return string.Format( + CultureInfo.InvariantCulture, + ParamsPathFormat, + Path, + name); + } + } +} + + diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/Conventions/XmlDocumentationProviderTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/Conventions/XmlDocumentationProviderTests.cs index a807d4329ce..a66388ab54c 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/Conventions/XmlDocumentationProviderTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/Conventions/XmlDocumentationProviderTests.cs @@ -10,7 +10,7 @@ public void When_xml_doc_is_missing_then_description_is_empty() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -25,7 +25,7 @@ public void When_xml_doc_with_multiple_breaks_is_read_then_they_are_not_stripped { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -45,7 +45,7 @@ public void When_description_has_see_tag_then_it_is_converted() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -65,7 +65,7 @@ public void When_description_has_paramref_tag_then_it_is_converted() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -84,7 +84,7 @@ public void When_description_has_generic_tags_then_it_is_converted() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -101,7 +101,7 @@ public void When_type_has_description_then_it_it_resolved() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -117,7 +117,7 @@ public void When_we_use_custom_documentation_files_they_are_correctly_loaded() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(_ => "Dummy.xml"), + new XmlDocumentationResolver(_ => "Dummy.xml"), new NoOpStringBuilderPool()); // act @@ -133,7 +133,7 @@ public void When_parameter_has_inheritdoc_then_it_is_resolved() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -143,6 +143,12 @@ public void When_parameter_has_inheritdoc_then_it_is_resolved() .GetParameters() .Single(p => p.Name == "baz")); + parameterXml = documentationProvider.GetDescription( + typeof(ClassWithInheritdoc) + .GetMethod(nameof(ClassWithInheritdoc.Bar))! + .GetParameters() + .Single(p => p.Name == "baz")); + // assert Assert.Equal("Parameter details.", parameterXml); } @@ -152,7 +158,7 @@ public void When_method_has_inheritdoc_then_it_is_resolved() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -169,7 +175,7 @@ public void When_property_has_inheritdoc_then_it_is_resolved() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -186,7 +192,7 @@ public void When_type_is_an_interface_then_description_is_resolved() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -202,7 +208,7 @@ public void When_parameter_has_inheritdoc_on_interface_then_it_is_resolved() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -221,7 +227,7 @@ public void When_property_has_inheritdoc_on_interface_then_it_is_resolved() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -238,7 +244,7 @@ public void When_method_has_inheritdoc_then_on_interface_it_is_resolved() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -255,7 +261,7 @@ public void When_class_implements_interface_and_property_has_description_then_pr { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -272,7 +278,7 @@ public void When_class_implements_interface_and_method_has_description_then_meth { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -289,7 +295,7 @@ public void When_class_implements_interface_and_method_has_description_then_meth { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -308,7 +314,7 @@ public void When_class_has_description_then_it_is_converted() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -324,7 +330,7 @@ public void When_method_has_exceptions_then_it_is_converted() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -340,7 +346,7 @@ public void When_method_has_exceptions_then_exceptions_with_no_code_will_be_igno { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -356,7 +362,7 @@ public void When_method_has_only_exceptions_with_no_code_then_error_section_will { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -372,7 +378,7 @@ public void When_method_has_no_exceptions_then_it_is_ignored() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -388,7 +394,7 @@ public void When_method_has_returns_then_it_is_converted() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -404,7 +410,7 @@ public void When_method_has_no_returns_then_it_is_ignored() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act @@ -420,7 +426,7 @@ public void When_method_has_dictionary_args_then_it_is_found() { // arrange var documentationProvider = new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool()); // act diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/DescriptorContextTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/DescriptorContextTests.cs index e06b242e16e..59eb9a209c6 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/DescriptorContextTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/DescriptorContextTests.cs @@ -13,7 +13,7 @@ public void Create_With_Custom_NamingConventions() var options = new SchemaOptions(); var namingConventions = new DefaultNamingConventions( new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool())); var services = new DictionaryServiceProvider( typeof(INamingConventions), @@ -40,7 +40,7 @@ public void Create_With_Custom_NamingConventions_AsIConvention() var options = new SchemaOptions(); var naming = new DefaultNamingConventions( new XmlDocumentationProvider( - new XmlDocumentationFileResolver(), + new XmlDocumentationResolver(), new NoOpStringBuilderPool())); var namingConventionKey = new ConventionKey(typeof(INamingConventions), null);