Skip to content

Commit 7f75c9c

Browse files
authored
Source generator: Fix XML doc resolution for real projects (#9041)
1 parent c3fff2a commit 7f75c9c

File tree

6 files changed

+158
-9
lines changed

6 files changed

+158
-9
lines changed

src/HotChocolate/Core/src/Types.Analyzers/Helpers/SymbolExtensions.cs

Lines changed: 139 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using HotChocolate.Types.Analyzers.Models;
66
using Microsoft.CodeAnalysis;
77
using Microsoft.CodeAnalysis.CSharp;
8+
using Microsoft.CodeAnalysis.CSharp.Syntax;
89
using static Microsoft.CodeAnalysis.SymbolDisplayFormat;
910
using static Microsoft.CodeAnalysis.SymbolDisplayMiscellaneousOptions;
1011

@@ -225,7 +226,7 @@ public static MethodDescription GetDescription(this IMethodSymbol method, Compil
225226
return null;
226227
}
227228

228-
var xml = symbol.GetDocumentationCommentXml();
229+
var xml = GetXmlDocumentationFromSyntax(symbol);
229230
if (string.IsNullOrEmpty(xml))
230231
{
231232
return null;
@@ -365,6 +366,46 @@ static string GetReturnsElementText(XDocument doc)
365366
}
366367
}
367368

369+
private static string? GetXmlDocumentationFromSyntax(ISymbol symbol)
370+
{
371+
// Note: One currently can't use GetDocumentationCommentXml in source generators.
372+
// See https://github.com/dotnet/roslyn/issues/23673
373+
var syntax = symbol.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax();
374+
while (syntax is VariableDeclaratorSyntax vds)
375+
{
376+
syntax = vds.Parent?.Parent;
377+
}
378+
379+
if (syntax == null || syntax.SyntaxTree.Options.DocumentationMode == DocumentationMode.None)
380+
{
381+
// See https://github.com/dotnet/roslyn/issues/58210,
382+
// for DocumentationMode.None we can't reliably extract the XML doc header.
383+
return null;
384+
}
385+
386+
var trivia = syntax.GetLeadingTrivia();
387+
StringBuilder? builder = null;
388+
foreach (var comment in trivia)
389+
{
390+
if (comment.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia)
391+
|| comment.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia))
392+
{
393+
var stringComment = comment.ToString();
394+
foreach (var s in stringComment.Split('\n'))
395+
{
396+
builder ??= new StringBuilder();
397+
builder.Append(s.TrimStart().Replace("///", string.Empty));
398+
builder.Append('\n');
399+
}
400+
}
401+
}
402+
403+
return builder?
404+
.Insert(0, "<member>")
405+
.Append("</member>")
406+
.ToString();
407+
}
408+
368409
/// <summary>
369410
/// Resolves an inheritdoc element by finding the referenced member.
370411
/// </summary>
@@ -378,7 +419,7 @@ static string GetReturnsElementText(XDocument doc)
378419
var crefAttr = inheritdocElement.Attribute("cref");
379420
if (crefAttr != null)
380421
{
381-
var referencedSymbol = ResolveDocumentationId(crefAttr.Value, compilation);
422+
var referencedSymbol = ResolveDocumentationId(crefAttr.Value, compilation, symbol);
382423
if (referencedSymbol != null)
383424
{
384425
return GetSummaryDocumentationWithInheritanceCore(referencedSymbol, compilation, visited);
@@ -494,16 +535,108 @@ static string GetReturnsElementText(XDocument doc)
494535
/// Resolves a documentation ID (cref value) to a symbol.
495536
/// Handles format like "T:Namespace.Type", "M:Namespace.Type.Method", "T:Namespace.Type`1", etc.
496537
/// </summary>
497-
private static ISymbol? ResolveDocumentationId(string documentationId, Compilation compilation)
538+
private static ISymbol? ResolveDocumentationId(string documentationId, Compilation compilation, ISymbol contextSymbol)
539+
{
540+
if (string.IsNullOrEmpty(documentationId))
541+
{
542+
return null;
543+
}
544+
545+
if (documentationId.Length > 1 && documentationId[1] == ':')
546+
{
547+
documentationId = documentationId.Substring(2);
548+
}
549+
550+
var result = compilation.GetTypeByMetadataName(documentationId) ??
551+
ResolveMemberSymbol(documentationId, compilation) ??
552+
ResolveMethodSymbol(documentationId, compilation);
553+
554+
var @namespace = contextSymbol.ContainingNamespace?.ToString();
555+
if (result == null && !string.IsNullOrEmpty(@namespace) && !documentationId.StartsWith(@namespace))
556+
{
557+
documentationId = @namespace + "." + documentationId;
558+
result = compilation.GetTypeByMetadataName(documentationId) ??
559+
ResolveMemberSymbol(documentationId, compilation) ??
560+
ResolveMethodSymbol(documentationId, compilation);
561+
}
562+
563+
return result;
564+
}
565+
566+
private static ISymbol? ResolveMethodSymbol(string documentationId, Compilation compilation)
498567
{
499568
if (string.IsNullOrEmpty(documentationId))
500569
{
501570
return null;
502571
}
503572

504-
// Documentation ID format: Prefix:FullyQualifiedName
505-
// Prefixes: T: (type), M: (method), P: (property), F: (field), E: (event)#
506-
return DocumentationCommentId.GetSymbolsForDeclarationId(documentationId, compilation).FirstOrDefault();
573+
var openParenthesisIndex = documentationId.LastIndexOf('(');
574+
var qualifiedName = openParenthesisIndex >= 0
575+
? documentationId.Substring(0, openParenthesisIndex)
576+
: documentationId;
577+
578+
var lastDotIndex = qualifiedName.LastIndexOf('.');
579+
if (lastDotIndex < 0)
580+
{
581+
return null;
582+
}
583+
584+
var typeName = qualifiedName.Substring(0, lastDotIndex);
585+
var methodName = qualifiedName.Substring(lastDotIndex + 1);
586+
587+
var typeSymbol = ResolveTypeSymbol(typeName, compilation);
588+
if (typeSymbol == null)
589+
{
590+
return null;
591+
}
592+
593+
return typeSymbol
594+
.GetMembers(methodName)
595+
.OfType<IMethodSymbol>()
596+
.FirstOrDefault(m => m.ToString() == documentationId);
597+
}
598+
599+
private static ISymbol? ResolveMemberSymbol(string documentationId, Compilation compilation)
600+
{
601+
var lastDotIndex = documentationId.LastIndexOf('.');
602+
if (lastDotIndex < 0)
603+
{
604+
return null;
605+
}
606+
607+
var typeName = documentationId.Substring(0, lastDotIndex);
608+
var memberName = documentationId.Substring(lastDotIndex + 1);
609+
610+
var typeSymbol = ResolveTypeSymbol(typeName, compilation);
611+
return typeSymbol?.GetMembers(memberName).FirstOrDefault();
612+
}
613+
614+
private static INamedTypeSymbol? ResolveTypeSymbol(string typeName, Compilation compilation)
615+
{
616+
// Non-nested type
617+
var symbol = compilation.GetTypeByMetadataName(typeName);
618+
if (symbol != null)
619+
{
620+
return symbol;
621+
}
622+
623+
// Nested type
624+
var nestedName = typeName;
625+
while (true)
626+
{
627+
var lastDot = nestedName.LastIndexOf('.');
628+
if (lastDot < 0)
629+
{
630+
return null;
631+
}
632+
633+
nestedName = nestedName.Remove(lastDot, 1).Insert(lastDot, "+");
634+
symbol = compilation.GetTypeByMetadataName(nestedName);
635+
if (symbol != null)
636+
{
637+
return symbol;
638+
}
639+
}
507640
}
508641

509642
public static bool IsNullableType(this ITypeSymbol typeSymbol)

src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/HotChocolate.Types.Analyzers.Integration.Tests.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
<NoWarn>$(NoWarn);GD0001</NoWarn>
1414
</PropertyGroup>
1515

16+
<PropertyGroup>
17+
<!--Required since otherwise the XML docs won't be inferred by the source generator.-->
18+
<GenerateDocumentationFile>True</GenerateDocumentationFile>
19+
</PropertyGroup>
20+
1621
<ItemGroup>
1722
<ProjectReference Include="..\..\..\..\GreenDonut\src\GreenDonut.Data.Abstractions\GreenDonut.Data.Abstractions.csproj" />
1823
<ProjectReference Include="..\..\..\..\GreenDonut\src\GreenDonut.Data.EntityFramework\GreenDonut.Data.EntityFramework.csproj" />

src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/Product.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ public static string NullableArgumentWithExplicitType(
4040
[QueryType]
4141
public static partial class Query
4242
{
43+
/// <summary>
44+
/// Gets the product.
45+
/// </summary>
46+
/// <returns>The only product.</returns>
4347
public static Product GetProduct()
4448
=> new Book { Id = "1", Title = "GraphQL in Action" };
4549

src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/__snapshots__/InterfaceTests.Schema_Snapshot.snap

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ type ProductsEdge {
4444
}
4545

4646
type Query {
47+
"""
48+
Gets the product.
49+
50+
51+
**Returns:**
52+
The only product.
53+
"""
4754
product: Product!
4855
products("Returns the elements in the list that come after the specified cursor." after: Version2 "Returns the first _n_ elements from the list." first: Int "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): ProductsConnection @listSize(assumedSize: 50, slicingArguments: [ "first", "last" ], slicingArgumentDefaultValue: 10, sizedFields: [ "edges", "nodes" ], requireOneSlicingArgument: false) @cost(weight: "10")
4956
argumentWithExplicitType(arg: Version2): String!

src/HotChocolate/Core/test/Types.Analyzers.Tests/ObjectTypeTests.XmlDocInference.Ported.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ internal static partial class Query
6868
var content = snapshot.Match();
6969
var emitted = s_description.Matches(content).Single().Groups;
7070
Assert.Equal(
71-
"Query and manages users.\\n \\nPlease note:\\n* Users ...\\n* Users ...\\n * Users ...\\n"
72-
+ " * Users ...\\n \\nYou need one of the following role: Owner,\\n"
71+
"Query and manages users.\\n\\nPlease note:\\n* Users ...\\n* Users ...\\n * Users ...\\n"
72+
+ " * Users ...\\n\\nYou need one of the following role: Owner,\\n"
7373
+ "Editor, use XYZ to manage permissions.",
7474
emitted[1].Value);
7575
}

src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ObjectTypeXmlDocInferenceTests.When_xml_doc_with_multiple_breaks_is_read_then_they_are_not_stripped_away.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ namespace TestNamespace
7878
var bindingResolver = field.Context.ParameterBindingResolver;
7979
var naming = field.Context.Naming;
8080

81-
configuration.Description = "Query and manages users.\n \nPlease note:\n* Users ...\n* Users ...\n * Users ...\n * Users ...\n \nYou need one of the following role: Owner,\nEditor, use XYZ to manage permissions.";
81+
configuration.Description = "Query and manages users.\n\nPlease note:\n* Users ...\n* Users ...\n * Users ...\n * Users ...\n\nYou need one of the following role: Owner,\nEditor, use XYZ to manage permissions.";
8282
configuration.Type = typeInspector.GetTypeRef(typeof(string), HotChocolate.Types.TypeContext.Output);
8383
configuration.ResultType = typeof(string);
8484

0 commit comments

Comments
 (0)