|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CSharp.Extensions;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editing;
using Microsoft.CodeAnalysis.FindSymbols;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Roslyn.Utilities;
namespace Microsoft.CodeAnalysis.CSharp.ConvertToRecord;
using static CSharpSyntaxTokens;
using static SyntaxFactory;
internal static class ConvertToRecordEngine
{
private const SyntaxRemoveOptions RemovalOptions =
SyntaxRemoveOptions.KeepExteriorTrivia |
SyntaxRemoveOptions.KeepDirectives |
SyntaxRemoveOptions.AddElasticMarker;
public static async Task<CodeAction?> GetCodeActionAsync(
Document document, TypeDeclarationSyntax typeDeclaration, CancellationToken cancellationToken)
{
// any type declared partial requires complex movement, don't offer refactoring
if (typeDeclaration.Modifiers.Any(SyntaxKind.PartialKeyword))
return null;
var semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false);
if (semanticModel.GetDeclaredSymbol(typeDeclaration, cancellationToken) is not INamedTypeSymbol
{
// if type is an interface we don't want to refactor
TypeKind: TypeKind.Class or TypeKind.Struct,
// no need to convert if it's already a record
IsRecord: false,
// records can't be static and so if the class is static we probably shouldn't convert it
IsStatic: false,
} type)
{
return null;
}
var positionalParameterInfos = PositionalParameterInfo.GetPropertiesForPositionalParameters(
typeDeclaration.Members
.OfType<PropertyDeclarationSyntax>()
.AsImmutable(),
type,
semanticModel,
cancellationToken);
if (positionalParameterInfos.IsEmpty)
return null;
var positionalTitle = CSharpCodeFixesResources.Convert_to_positional_record;
var positional = CodeAction.Create(
positionalTitle,
cancellationToken => ConvertToPositionalRecordAsync(
document,
type,
positionalParameterInfos,
typeDeclaration,
cancellationToken),
nameof(CSharpCodeFixesResources.Convert_to_positional_record));
// note: when adding nested actions, use string.Format(CSharpFeaturesResources.Convert_0_to_record, type.Name) as title string
return positional;
}
private static async Task<Solution> ConvertToPositionalRecordAsync(
Document document,
INamedTypeSymbol type,
ImmutableArray<PositionalParameterInfo> positionalParameterInfos,
TypeDeclarationSyntax typeDeclaration,
CancellationToken cancellationToken)
{
var semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false);
// first see if we need to re-order our primary constructor parameters.
var (primaryConstructor, propertiesToAssign) = TryFindPrimaryConstructor();
var solutionEditor = new SolutionEditor(document.Project.Solution);
// we must refactor usages first because usages can appear within the class definition and
// individual members, and changing a parent first invalidates the tracking done on the child
await RefactorInitializersAsync(type, solutionEditor, propertiesToAssign, cancellationToken)
.ConfigureAwait(false);
var documentEditor = await solutionEditor
.GetDocumentEditorAsync(document.Id, cancellationToken).ConfigureAwait(false);
// generated hashcode and equals methods compare all instance fields
// including underlying fields accessed from properties
// copy constructor generation also uses all fields when copying
// so we track all the fields to make sure the methods we consider deleting
// would actually perform the same action as an autogenerated one
var expectedFields = type
.GetMembers()
.OfType<IFieldSymbol>()
.Where(field => !field.IsConst && !field.IsStatic)
.AsImmutable();
// remove properties we're bringing up to positional params
// or keep them as overrides and link the positional param to the original property
foreach (var result in positionalParameterInfos)
{
if (result.IsInherited)
{
// skip inherited params because they were declared elsewhere.
// We don't need to add or remove a declaration
continue;
}
var property = result.Declaration;
if (result.KeepAsOverride)
{
// add an initializer that links the property to the primary constructor parameter
documentEditor.ReplaceNode(property, property
.WithInitializer(
EqualsValueClause(IdentifierName(property.Identifier.WithoutTrivia())))
.WithSemicolonToken(SemicolonToken));
}
else
{
documentEditor.RemoveNode(property);
}
}
// We will fill in defaults when we see the primary constructor again if we saw it in the first place
var defaults = positionalParameterInfos.SelectAsArray(info => (EqualsValueClauseSyntax?)null);
foreach (var constructor in typeDeclaration.Members.OfType<ConstructorDeclarationSyntax>())
{
// we already did the work to find the primary constructor
if (constructor.Equals(primaryConstructor))
{
// grab parameter defaults and reorder positional param info
// to be in order of primary constructor params
positionalParameterInfos = propertiesToAssign
.SelectAsArray(symbol => positionalParameterInfos
.First(info => info.Symbol.Equals(symbol)));
defaults = constructor.ParameterList.Parameters.SelectAsArray(param => param.Default);
documentEditor.RemoveNode(constructor);
}
else
{
var constructorSymbol = semanticModel.GetRequiredDeclaredSymbol(constructor, cancellationToken);
var constructorOperation = (IConstructorBodyOperation?)semanticModel.GetOperation(constructor, cancellationToken);
if (constructorOperation is null)
continue;
// check for copy constructor
if (constructorSymbol is { Parameters: [{ Type: var parameterType }] } &&
parameterType.Equals(type))
{
if (ConvertToRecordHelpers.IsSimpleCopyConstructor(
constructorOperation, expectedFields, constructorSymbol.Parameters.First()))
{
documentEditor.RemoveNode(constructor);
}
}
// ignore any constructor that has the same signature as the primary constructor.
// If it wasn't already processed as the primary, it's too complex, and will
// already produce an error as the signatures conflict. Better to leave as is and show errors.
else if (!constructorSymbol.Parameters.Select(parameter => parameter.Type)
.SequenceEqual(propertiesToAssign.Select(property => property.Type)))
{
// non-primary, non-copy constructor, add ": this(...)" initializers to each
// and try to use assignments in the body to determine the values, otherwise default or null
var expressions = ConvertToRecordHelpers
.GetAssignmentValuesForNonPrimaryConstructor(constructorOperation, propertiesToAssign);
// go up to the ExpressionStatementSyntax so we take the semicolon and not just the assignment
var expressionStatementsToRemove = expressions
.Select(expression =>
(expression.Parent as AssignmentExpressionSyntax)?.Parent as ExpressionStatementSyntax)
.WhereNotNull()
.AsImmutable();
var modifiedConstructor = constructor
.RemoveNodes(expressionStatementsToRemove, RemovalOptions)!
.WithInitializer(ConstructorInitializer(
SyntaxKind.ThisConstructorInitializer,
ArgumentList([.. expressions.Select(Argument)])));
documentEditor.ReplaceNode(constructor, modifiedConstructor);
}
}
}
// get equality operators and potentially remove them
var equalsOp = (OperatorDeclarationSyntax?)typeDeclaration.Members.FirstOrDefault(member
=> member is OperatorDeclarationSyntax { OperatorToken.RawKind: (int)SyntaxKind.EqualsEqualsToken });
var notEqualsOp = (OperatorDeclarationSyntax?)typeDeclaration.Members.FirstOrDefault(member
=> member is OperatorDeclarationSyntax { OperatorToken.RawKind: (int)SyntaxKind.ExclamationEqualsToken });
if (equalsOp != null && notEqualsOp != null)
{
var equalsBodyOperation = (IMethodBodyOperation)semanticModel
.GetRequiredOperation(equalsOp, cancellationToken);
var notEqualsBodyOperation = (IMethodBodyOperation)semanticModel
.GetRequiredOperation(notEqualsOp, cancellationToken);
if (ConvertToRecordHelpers.IsDefaultEqualsOperator(equalsBodyOperation) &&
ConvertToRecordHelpers.IsDefaultNotEqualsOperator(notEqualsBodyOperation))
{
// they both evaluate to what would be the generated implementation
documentEditor.RemoveNode(equalsOp);
documentEditor.RemoveNode(notEqualsOp);
}
}
foreach (var method in typeDeclaration.Members.OfType<MethodDeclarationSyntax>())
{
var methodSymbol = (IMethodSymbol)semanticModel.GetRequiredDeclaredSymbol(method, cancellationToken);
var operation = (IMethodBodyOperation)semanticModel.GetRequiredOperation(method, cancellationToken);
if (methodSymbol.Name == "Clone")
{
// remove clone method as clone is a reserved method name in records
documentEditor.RemoveNode(method);
}
else if (ConvertToRecordHelpers.IsSimpleHashCodeMethod(
semanticModel.Compilation, methodSymbol, operation, expectedFields))
{
documentEditor.RemoveNode(method);
}
else if (ConvertToRecordHelpers.IsSimpleEqualsMethod(
semanticModel.Compilation, methodSymbol, operation, expectedFields))
{
// the Equals method implementation is fundamentally equivalent to the generated one
documentEditor.RemoveNode(method);
}
}
var lineFormattingOptions = await document.GetLineFormattingOptionsAsync(cancellationToken).ConfigureAwait(false);
var modifiedClassTrivia = GetModifiedClassTrivia(
positionalParameterInfos, typeDeclaration, lineFormattingOptions);
var propertiesToAddAsParams = positionalParameterInfos.Zip(defaults, (result, @default) =>
{
// if inherited we generate nodes and tokens for the type and identifier
var type = result.IsInherited
? result.Symbol.Type.GenerateTypeSyntax()
: result.Declaration.Type;
var identifier = result.IsInherited
? Identifier(result.Symbol.Name)
: result.Declaration.Identifier;
return Parameter(
GetModifiedAttributeListsForProperty(result),
modifiers: default,
type,
identifier,
@default: @default);
});
// if we have a class, move trivia from class keyword to record keyword
// if struct, split trivia and leading goes to record keyword, trailing goes to struct keyword
var recordKeyword = RecordKeyword;
recordKeyword = type.TypeKind == TypeKind.Class
? recordKeyword.WithTriviaFrom(typeDeclaration.Keyword)
: recordKeyword.WithLeadingTrivia(typeDeclaration.Keyword.LeadingTrivia);
// use the trailing trivia of the last item before the constructor parameter list as the param list trivia
var constructorTrivia = typeDeclaration.TypeParameterList?.GetTrailingTrivia() ??
typeDeclaration.Identifier.TrailingTrivia;
// delete IEquatable if it's explicit because it is implicit on records
var iEquatable = ConvertToRecordHelpers.GetIEquatableType(semanticModel.Compilation, type);
var baseList = typeDeclaration.BaseList;
if (baseList != null)
{
var typeList = baseList.Types;
if (iEquatable != null)
{
var iEquatableItem = typeList.FirstOrDefault(baseItem
=> iEquatable.Equals(semanticModel.GetTypeInfo(baseItem.Type, cancellationToken).Type));
if (iEquatableItem != null)
{
typeList = typeList.Remove(iEquatableItem);
}
}
if (typeList.IsEmpty())
{
baseList = null;
}
else
{
if (positionalParameterInfos.Any(info => info.IsInherited))
{
// if we have an inherited param, then we know we're inheriting from
// a record with a primary constructor.
// something like: public class C : B {...}
// where B is: public record B(int Foo, bool Bar);
// We created a parameter list with all the properties that shadow the inherited ones.
// Now we need to associate the parameters declared in the class
// with the ones the base record uses.
// Example: public record C(int Foo, int Bar, int OtherProp) : B(Foo, Bar) {...}
var baseRecord = typeList.First();
var baseTrailingTrivia = baseRecord.Type.GetTrailingTrivia();
// get the positional parameters in the order they are declared from the base record
var inheritedPositionalParams = PositionalParameterInfo
.GetInheritedPositionalParams(type, cancellationToken)
.SelectAsArray(prop =>
Argument(IdentifierName(prop.Name)));
typeList = typeList.Replace(baseRecord,
PrimaryConstructorBaseType(baseRecord.Type.WithoutTrailingTrivia(),
ArgumentList([.. inheritedPositionalParams])
.WithTrailingTrivia(baseTrailingTrivia)));
}
baseList = baseList.WithTypes(typeList);
}
}
documentEditor.ReplaceNode(typeDeclaration, (declaration, _) =>
CreateRecordDeclaration(type, (TypeDeclarationSyntax)declaration, modifiedClassTrivia,
propertiesToAddAsParams, recordKeyword, constructorTrivia, baseList));
return solutionEditor.GetChangedSolution();
(ConstructorDeclarationSyntax? constructor, ImmutableArray<IPropertySymbol> propertiesToAssign) TryFindPrimaryConstructor()
{
var propertiesToAssign = positionalParameterInfos.SelectAsArray(info => info.Symbol);
var orderedPropertyTypesToAssign = propertiesToAssign.SelectAsArray(s => s.Type).OrderBy(type => type.Name);
foreach (var member in typeDeclaration.Members)
{
if (member is not ConstructorDeclarationSyntax constructor)
continue;
var constructorSymbol = semanticModel.GetRequiredDeclaredSymbol(constructor, cancellationToken);
var constructorOperation = (IConstructorBodyOperation?)semanticModel.GetOperation(constructor, cancellationToken);
if (constructorOperation is null)
continue;
// We want to make sure that each type in the parameter list corresponds
// to exactly one positional parameter type, but they don't need to be in the same order.
// We can't use something like set equality because some parameter types may be duplicate.
// So, we order the types in a consistent way (by name) and then compare the lists of types.
var orderedParameterTypes = constructorSymbol.Parameters
.SelectAsArray(parameter => parameter.Type)
.OrderBy(type => type.Name);
if (!orderedParameterTypes.SequenceEqual(orderedPropertyTypesToAssign))
continue;
// make sure that we do all the correct assignments. There may be multiple constructors
// that meet the parameter condition but only one actually assigns all properties.
// If successful, we set propertiesToAssign in the order of the parameters.
if (!ConvertToRecordHelpers.IsSimplePrimaryConstructor(
constructorOperation, propertiesToAssign, constructorSymbol.Parameters, out var orderedPropertiesToAssign))
{
continue;
}
return (constructor, orderedPropertiesToAssign);
}
return (null, propertiesToAssign);
}
}
private static RecordDeclarationSyntax CreateRecordDeclaration(
INamedTypeSymbol type,
TypeDeclarationSyntax typeDeclaration,
SyntaxTriviaList modifiedClassTrivia,
IEnumerable<ParameterSyntax> propertiesToAddAsParams,
SyntaxToken recordKeyword,
SyntaxTriviaList constructorTrivia,
BaseListSyntax? baseList)
{
// if we have no members, use semicolon instead of braces
// use default if we don't want it, otherwise use the original token if it exists or a generated one
SyntaxToken openBrace, closeBrace, semicolon;
if (typeDeclaration.Members.IsEmpty())
{
openBrace = default;
closeBrace = default;
semicolon = typeDeclaration.SemicolonToken == default
? SemicolonToken
: typeDeclaration.SemicolonToken;
}
else
{
openBrace = typeDeclaration.OpenBraceToken == default
? OpenBraceToken
: typeDeclaration.OpenBraceToken;
closeBrace = typeDeclaration.CloseBraceToken == default
? CloseBraceToken
: typeDeclaration.CloseBraceToken;
semicolon = default;
// remove any potential leading blank lines right after the class declaration, as we could have
// something like a method which was spaced out from the previous properties, but now shouldn't
// have that leading space
typeDeclaration = typeDeclaration.ReplaceNode(
typeDeclaration.Members[0], typeDeclaration.Members[0].GetNodeWithoutLeadingBlankLines());
}
return RecordDeclaration(
type.TypeKind == TypeKind.Class
? SyntaxKind.RecordDeclaration
: SyntaxKind.RecordStructDeclaration,
typeDeclaration.AttributeLists,
typeDeclaration.Modifiers,
recordKeyword,
type.TypeKind == TypeKind.Class
? default
: typeDeclaration.Keyword.WithTrailingTrivia(ElasticMarker),
// remove trailing trivia from places where we would want to insert the parameter list before a line break
typeDeclaration.Identifier.WithTrailingTrivia(ElasticMarker),
typeDeclaration.TypeParameterList?.WithTrailingTrivia(ElasticMarker),
ParameterList([.. propertiesToAddAsParams])
.WithAppendedTrailingTrivia(constructorTrivia),
baseList,
typeDeclaration.ConstraintClauses,
openBrace,
typeDeclaration.Members,
closeBrace,
semicolon)
.WithLeadingTrivia(modifiedClassTrivia)
.WithAdditionalAnnotations(Formatter.Annotation);
}
private static SyntaxList<AttributeListSyntax> GetModifiedAttributeListsForProperty(PositionalParameterInfo result)
{
if (result.IsInherited || result.KeepAsOverride)
{
// if the property is declared elsewhere (base class or because we keep the property definition),
// then any attributes associated with the property don't need to be redeclared
// on the primary constructor parameter because the primary constructor parameter is no longer the
// only/first definition. So we can just have an empty attribute list.
// For example, if we want to move:
// [SomeAttribute]
// public int Foo { get; private set; }
// but then decide that we want to keep the definition, then the attribute can stay on the original
// definition, and our primary constructor param can associate that attribute when we add:
// public int Foo { get; private set; } = Foo;
return [];
}
return [.. result.Declaration.AttributeLists.SelectAsArray(attributeList =>
{
if (attributeList.Target == null)
{
// convert attributes attached to the property with no target into "property :" targeted attributes
return attributeList
.WithTarget(AttributeTargetSpecifier(PropertyKeyword))
.WithoutTrivia();
}
else
{
return attributeList.WithoutTrivia();
}
})];
}
private static async Task RefactorInitializersAsync(
INamedTypeSymbol type,
SolutionEditor solutionEditor,
ImmutableArray<IPropertySymbol> positionalParameters,
CancellationToken cancellationToken)
{
var symbolReferences = await SymbolFinder
.FindReferencesAsync(type, solutionEditor.OriginalSolution, cancellationToken).ConfigureAwait(false);
var referenceLocations = symbolReferences.SelectMany(reference => reference.Locations);
var documentLookup = referenceLocations.ToLookup(refLoc => refLoc.Document);
foreach (var (document, documentLocations) in documentLookup)
{
// We don't want to process source-generated documents. Make sure we can get back to a real document here.
if (document is SourceGeneratedDocument)
continue;
var documentEditor = await solutionEditor
.GetDocumentEditorAsync(document.Id, cancellationToken).ConfigureAwait(false);
if (documentEditor.OriginalDocument.Project.Language != LanguageNames.CSharp)
{
// since this is a CSharp-dependent file, we need to have specific VB support.
// for now skip VB usages.
// https://github.com/dotnet/roslyn/issues/63756
continue;
}
var objectCreationExpressions = documentLocations
// we should find the identifier node of an object creation expression
.Select(referenceLocations => referenceLocations.Location.FindNode(cancellationToken).Parent)
.OfType<ObjectCreationExpressionSyntax>()
// order by smaller spans first so in the nested case we don't overwrite our previous changes
.OrderBy(node => (node.FullWidth(), node.SpanStart));
foreach (var objectCreationExpression in objectCreationExpressions)
{
var operation = documentEditor.SemanticModel.GetRequiredOperation(objectCreationExpression, cancellationToken);
if (operation is not IObjectCreationOperation objectCreationOperation)
continue;
var expressions = ConvertToRecordHelpers.GetAssignmentValuesFromObjectCreation(
objectCreationOperation, positionalParameters);
if (expressions.IsEmpty)
continue;
var expressionIndices = expressions.SelectAsArray(
// if initializer was null we wouldn't have found expressions
// any constructed nodes (default/null) should give -1 because parent is null
expression => objectCreationExpression.Initializer!.Expressions.IndexOf(expression.Parent));
documentEditor.ReplaceNode(objectCreationExpression, (node, generator) =>
{
var updatedObjectCreation = (ObjectCreationExpressionSyntax)node;
var newInitializer = (InitializerExpressionSyntax)generator
.RemoveNodes(updatedObjectCreation.Initializer!,
expressionIndices
.Where(i => i != -1)
.Select(i => updatedObjectCreation.Initializer!.Expressions[i]));
// if there are no more assignments other than the ones that
// could go in the primary constructor, we can remove the block entirely
if (newInitializer.Expressions.IsEmpty())
{
newInitializer = null;
}
// note: index here is the position in the initializer assignment list of the expression
// if it was found at all. The expressions are actually in order of how they should be
// supplied as arguments for the primary constructor.
var updatedExpressions = expressions.Zip(expressionIndices, (expression, index) =>
{
if (index == -1)
{
// default/null constructed expression
return expression;
}
else
{
// corresponds to a real node, need to get the updated one
var assignmentExpression = (AssignmentExpressionSyntax)
updatedObjectCreation.Initializer!.Expressions[index];
return assignmentExpression.Right;
}
});
// replace: new C { Foo = 0; Bar = false; };
// with: new C(0, false);
return ObjectCreationExpression(
updatedObjectCreation.NewKeyword,
updatedObjectCreation.Type.WithoutTrailingTrivia(),
ArgumentList([.. updatedExpressions.Select(expression => Argument(expression.WithoutTrivia()))]),
newInitializer);
});
}
}
}
#region TriviaMovement
// format should be:
// 1. comments and other trivia from class that were already on class
// 2. comments from each property
// 3. Class documentation comment summary
// 4. Property summary documentation (as param)
// 5. Rest of class documentation comments
private static SyntaxTriviaList GetModifiedClassTrivia(
ImmutableArray<PositionalParameterInfo> propertyResults,
TypeDeclarationSyntax typeDeclaration,
LineFormattingOptions lineFormattingOptions)
{
var classTrivia = typeDeclaration.GetLeadingTrivia().Where(trivia => !trivia.IsWhitespace()).AsImmutable();
var propertyNonDocComments = propertyResults
.SelectMany(result =>
{
if (result.IsInherited)
{
return [];
}
var p = result.Declaration;
var leadingPropTrivia = p.GetLeadingTrivia()
.Where(trivia => !trivia.IsDocComment() && !trivia.IsWhitespace());
// since we remove attributes and reformat, we want to take any comments
// in between attribute and declaration
if (!p.AttributeLists.IsEmpty())
{
// get the leading trivia of the node/token right after
// the attribute lists (either modifier or type of property)
leadingPropTrivia = leadingPropTrivia.Concat(p.Modifiers.IsEmpty()
? p.Type.GetLeadingTrivia()
: p.Modifiers.First().LeadingTrivia);
}
return leadingPropTrivia;
})
.AsImmutable();
// we use the class doc comment to see if we use single line doc comments or multi line doc comments
// if the class one isn't found, then we find the first property with a doc comment
// this variable doubles as a flag to see if we need to generate doc comments at all, as
// if it is still null, we found no meaningful doc comments anywhere
var exteriorTrivia = GetExteriorTrivia(typeDeclaration) ??
propertyResults
.Where(result => !result.IsInherited)
.Select(result => GetExteriorTrivia(result.Declaration!))
.FirstOrDefault(trivia => trivia != null);
if (exteriorTrivia == null)
{
// we didn't find any substantive doc comments, just give the current non-doc comments
return [.. classTrivia.Concat(propertyNonDocComments).Select(trivia => trivia.AsElastic())];
}
var propertyParamComments = CreateParamComments(propertyResults, exteriorTrivia!.Value, lineFormattingOptions);
var classDocComment = classTrivia.FirstOrNull(trivia => trivia.IsDocComment());
DocumentationCommentTriviaSyntax newClassDocComment;
if (classDocComment?.GetStructure() is DocumentationCommentTriviaSyntax originalClassDoc)
{
// insert parameters after summary node and the extra newline or at start if no summary
var summaryIndex = originalClassDoc.Content.IndexOf(node =>
node is XmlElementSyntax element &&
element.StartTag?.Name.LocalName.ValueText == DocumentationCommentXmlNames.SummaryElementName);
// if not found, summaryIndex + 1 = -1 + 1 = 0, so our params go to the start
newClassDocComment = originalClassDoc.WithContent(originalClassDoc.Content
.Replace(originalClassDoc.Content[0], originalClassDoc.Content[0])
.InsertRange(summaryIndex + 1, propertyParamComments));
}
else
{
// no class doc comment, if we have non-single line parameter comments we need a start and end
// we must have had at least one property with a doc comment
if (propertyResults
.SelectAsArray(result => !result.IsInherited,
result => result.Declaration!.GetLeadingTrivia().FirstOrNull(trivia => trivia.IsDocComment()))
.FirstOrDefault(t => t != null)?.GetStructure() is DocumentationCommentTriviaSyntax propDoc &&
propDoc.IsMultilineDocComment())
{
// add /** and */
newClassDocComment = DocumentationCommentTrivia(
SyntaxKind.MultiLineDocumentationCommentTrivia,
// Our parameter method gives a newline (without leading trivia) to start
// because we assume we're following some other comment, we replace that newline to add
// the start of comment leading trivia as well since we're not following another comment
[.. propertyParamComments.Skip(1)
.Prepend(XmlText(XmlTextNewLine(lineFormattingOptions.NewLine, continueXmlDocumentationComment: false)
.WithLeadingTrivia(DocumentationCommentExterior("/**"))
.WithTrailingTrivia(exteriorTrivia)))
.Append(XmlText(XmlTextNewLine(lineFormattingOptions.NewLine, continueXmlDocumentationComment: false)))],
EndOfDocumentationCommentToken
.WithTrailingTrivia(DocumentationCommentExterior("*/"), ElasticCarriageReturnLineFeed));
}
else
{
// add extra line at end to end doc comment
// also skip first newline and replace with non-newline
newClassDocComment = DocumentationCommentTrivia(
SyntaxKind.MultiLineDocumentationCommentTrivia,
[.. propertyParamComments.Skip(1)
.Prepend(XmlText(XmlTextLiteral(" ").WithLeadingTrivia(exteriorTrivia)))])
.WithAppendedTrailingTrivia(ElasticCarriageReturnLineFeed);
}
}
var lastComment = classTrivia.LastOrDefault(trivia => trivia.IsRegularOrDocComment());
if (classDocComment == null || lastComment == classDocComment)
{
// doc comment was last non-whitespace/newline trivia or there was no class doc comment originally
return [.. classTrivia
.Where(trivia => !trivia.IsDocComment())
.Concat(propertyNonDocComments)
.Append(Trivia(newClassDocComment))
.Select(trivia => trivia.AsElastic())];
}
else
{
// there were comments after doc comment
return [.. classTrivia
.Replace(classDocComment.Value, Trivia(newClassDocComment))
.Concat(propertyNonDocComments)
.Select(trivia => trivia.AsElastic())];
}
}
private static SyntaxTriviaList? GetExteriorTrivia(SyntaxNode declaration)
{
var potentialDocComment = declaration.GetLeadingTrivia().FirstOrNull(trivia => trivia.IsDocComment());
if (potentialDocComment?.GetStructure() is DocumentationCommentTriviaSyntax docComment)
{
// if single line, we return a normal single line trivia, we can format it fine later
if (docComment.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia))
{
// first token of comment should have correct trivia
return docComment.GetLeadingTrivia();
}
else
{
// for multiline comments, the continuation trivia (usually "*") doesn't get formatted correctly
// so we want to keep whitespace alignment across the entire comment
return SearchInNodes(docComment.Content);
}
}
return null;
}
// potentially recurse into elements to find the first exterior trivia of the element that is after a newline token
// since we can only find newlines in TextNodes, we need to look inside element contents for text
private static SyntaxTriviaList? SearchInNodes(SyntaxList<XmlNodeSyntax> nodes)
{
foreach (var node in nodes)
{
switch (node)
{
case XmlElementSyntax element:
var potentialResult = SearchInNodes(element.Content);
if (potentialResult != null)
{
return potentialResult;
}
break;
case XmlTextSyntax text:
SyntaxToken prevToken = default;
// find first text token after a newline
foreach (var token in text.TextTokens)
{
if (prevToken.IsKind(SyntaxKind.XmlTextLiteralNewLineToken))
{
return token.LeadingTrivia;
}
prevToken = token;
}
break;
default:
break;
}
}
return null;
}
private static IEnumerable<XmlNodeSyntax> CreateParamComments(
ImmutableArray<PositionalParameterInfo> propertyResults,
SyntaxTriviaList exteriorTrivia,
LineFormattingOptions lineFormattingOptions)
{
foreach (var result in propertyResults)
{
// add an extra line and space with the exterior trivia, so that our params start on the next line and each
// param goes on a new line with the continuation trivia
// when adding a new line, the continue flag adds a single line documentation trivia, but we don't necessarily want that
yield return XmlText(
XmlTextNewLine(lineFormattingOptions.NewLine, continueXmlDocumentationComment: false),
XmlTextLiteral(" ").WithLeadingTrivia(exteriorTrivia));
if (result.IsInherited)
{
// generate a param comment with an inherited doc
yield return XmlParamElement(result.Symbol.Name, XmlEmptyElement(
XmlName(DocumentationCommentXmlNames.InheritdocElementName)));
}
else
{
// get the documentation comment
var potentialDocComment = result.Declaration.GetLeadingTrivia().FirstOrNull(trivia => trivia.IsDocComment());
var paramContent = ImmutableArray<XmlNodeSyntax>.Empty;
if (potentialDocComment?.GetStructure() is DocumentationCommentTriviaSyntax docComment)
{
// get the summary node if there is one
var summaryNode = docComment.Content.FirstOrDefault(node =>
node is XmlElementSyntax element &&
element.StartTag?.Name.LocalName.ValueText == DocumentationCommentXmlNames.SummaryElementName);
if (summaryNode != null)
{
// construct a parameter element from the contents of the property summary
// right now we throw away all other documentation parts of the property, because we don't really know where they should go
var summaryContent = ((XmlElementSyntax)summaryNode).Content;
paramContent = summaryContent.Select((node, index) =>
{
if (node is XmlTextSyntax text)
{
// any text token that is not on it's own line should have replaced trivia
var tokens = text.TextTokens.SelectAsArray(token =>
token.IsKind(SyntaxKind.XmlTextLiteralToken)
? token.WithLeadingTrivia(exteriorTrivia)
: token);
if (index == 0 &&
tokens is [(kind: SyntaxKind.XmlTextLiteralNewLineToken), _, ..])
{
// remove the starting line and trivia from the first line
tokens = tokens.RemoveAt(0);
}
// remove trivia from first statement because it should never be on a separate line
tokens = tokens.Replace(tokens[0], tokens[0].WithoutLeadingTrivia());
if (index == summaryContent.Count - 1 &&
tokens is [.., (kind: SyntaxKind.XmlTextLiteralNewLineToken), (kind: SyntaxKind.XmlTextLiteralToken) textLiteral] &&
textLiteral.Text.GetFirstNonWhitespaceIndexInString() == -1)
{
// the last text token contains a new line, then a whitespace only text (which would start the closing tag)
// remove the new line and the trivia from the extra text
tokens = tokens.RemoveAt(tokens.Length - 2);
tokens = tokens.Replace(tokens[^1], tokens[^1].WithoutLeadingTrivia());
}
return text.WithTextTokens([.. tokens]);
}
return node;
}).AsImmutable();
}
}
yield return XmlParamElement(result.Declaration.Identifier.ValueText, paramContent.AsArray());
}
}
}
#endregion
}
|