File: DocumentationComments\CSharpDocumentationCommentSnippetService.cs
Web Access
Project: src\src\roslyn\src\Features\CSharp\Portable\Microsoft.CodeAnalysis.CSharp.Features.csproj (Microsoft.CodeAnalysis.CSharp.Features)
// 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.Composition;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis.CSharp.Extensions;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.DocumentationComments;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.CSharp.DocumentationComments;

[ExportLanguageService(typeof(IDocumentationCommentSnippetService), LanguageNames.CSharp), Shared]
[method: ImportingConstructor]
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
internal sealed class CSharpDocumentationCommentSnippetService() : AbstractDocumentationCommentSnippetService<DocumentationCommentTriviaSyntax, MemberDeclarationSyntax>
{
    public override string DocumentationCommentCharacter => "/";

    protected override bool AddIndent => true;
    protected override string ExteriorTriviaText => "///";

    protected override MemberDeclarationSyntax? GetContainingMember(SyntaxTree syntaxTree, int position, CancellationToken cancellationToken)
    {
        return syntaxTree.GetRoot(cancellationToken).FindToken(position).GetAncestor<MemberDeclarationSyntax>();
    }

    protected override bool SupportsDocumentationComments(MemberDeclarationSyntax member)
    {
        switch (member.Kind())
        {
            case SyntaxKind.ClassDeclaration:
            case SyntaxKind.RecordDeclaration:
            case SyntaxKind.InterfaceDeclaration:
            case SyntaxKind.StructDeclaration:
            case SyntaxKind.RecordStructDeclaration:
            case SyntaxKind.DelegateDeclaration:
            case SyntaxKind.EnumDeclaration:
            case SyntaxKind.EnumMemberDeclaration:
            case SyntaxKind.FieldDeclaration:
            case SyntaxKind.MethodDeclaration:
            case SyntaxKind.ConstructorDeclaration:
            case SyntaxKind.DestructorDeclaration:
            case SyntaxKind.PropertyDeclaration:
            case SyntaxKind.IndexerDeclaration:
            case SyntaxKind.EventDeclaration:
            case SyntaxKind.EventFieldDeclaration:
            case SyntaxKind.OperatorDeclaration:
            case SyntaxKind.ConversionOperatorDeclaration:
            case SyntaxKind.ExtensionBlockDeclaration:
                return true;

            default:
                return false;
        }
    }

    protected override bool HasDocumentationComment(MemberDeclarationSyntax member)
        => member.GetFirstToken().LeadingTrivia.Any(t => t is (kind: SyntaxKind.SingleLineDocumentationCommentTrivia or SyntaxKind.MultiLineDocumentationCommentTrivia));

    protected override int GetPrecedingDocumentationCommentCount(MemberDeclarationSyntax member)
    {
        var firstToken = member.GetFirstToken();

        var count = firstToken.LeadingTrivia.Count(t => t.IsDocComment());

        var previousToken = firstToken.GetPreviousToken();
        if (previousToken.Kind() != SyntaxKind.None)
        {
            count += previousToken.TrailingTrivia.Count(t => t.IsDocComment());
        }

        return count;
    }

    protected override List<string> GetDocumentationCommentStubLines(MemberDeclarationSyntax member, string existingCommentText, DocumentationCommentOptions options)
    {
        // When collapsed mode is enabled, generate single-line summary tags
        var useSingleLine = options.GenerateSummaryTagOnSingleLine;
        var onlySummary = options.GenerateOnlySummaryTag;

        var list = new List<string>();

        if (useSingleLine)
        {
            // Single-line: /// <summary></summary>
            list.Add($"/// <summary>{existingCommentText.Trim()}</summary>");
        }
        else
        {
            // Multi-line (original behavior)
            list.Add("/// <summary>");
            list.Add("///" + (existingCommentText.StartsWith(" ") ? existingCommentText : $" {existingCommentText}"));
            list.Add("/// </summary>");
        }

        // If onlySummary is true, skip generating other tags
        if (onlySummary)
        {
            return list;
        }

        var typeParameterList = member.GetTypeParameterList();
        if (typeParameterList != null)
        {
            foreach (var typeParam in typeParameterList.Parameters)
            {
                list.Add("/// <typeparam name=\"" + typeParam.Identifier.ValueText + "\"></typeparam>");
            }
        }

        var parameterList = member.GetParameterList();
        if (parameterList != null)
        {
            foreach (var param in parameterList.Parameters)
            {
                list.Add("/// <param name=\"" + param.Identifier.ValueText + "\"></param>");
            }
        }

        if (member.Kind() is
                SyntaxKind.MethodDeclaration or
                SyntaxKind.IndexerDeclaration or
                SyntaxKind.DelegateDeclaration or
                SyntaxKind.OperatorDeclaration or
                SyntaxKind.ConstructorDeclaration or
                SyntaxKind.DestructorDeclaration)
        {
            var returnType = member.GetMemberType();
            if (returnType != null &&
                !(returnType is PredefinedTypeSyntax predefinedType && predefinedType.Keyword.IsKindOrHasMatchingText(SyntaxKind.VoidKeyword)))
            {
                list.Add("/// <returns></returns>");
            }

            foreach (var exceptionType in GetExceptions(member))
            {
                list.Add(@$"/// <exception cref=""{exceptionType}""></exception>");
            }
        }

        return list;
    }

    private static IEnumerable<string> GetExceptions(SyntaxNode member)
    {
        var throwExpressionsAndStatements = member.DescendantNodes().Where(n => n.Kind() is SyntaxKind.ThrowExpression or SyntaxKind.ThrowStatement);

        var usings = member.GetEnclosingUsingDirectives();
        var hasUsingSystem = usings.Any(u => u.Name is IdentifierNameSyntax { Identifier.ValueText: nameof(System) });

        using var _ = PooledHashSet<string>.GetInstance(out var seenExceptionTypes);
        foreach (var throwExpressionOrStatement in throwExpressionsAndStatements)
        {
            var expression = throwExpressionOrStatement switch
            {
                ThrowExpressionSyntax throwExpression => throwExpression.Expression,
                ThrowStatementSyntax throwStatement => throwStatement.Expression,
                _ => throw ExceptionUtilities.Unreachable()
            };

            if (expression.IsKind(SyntaxKind.NullLiteralExpression))
            {
                // `throw null;` throws NullReferenceException at runtime.
                var exception = hasUsingSystem ? nameof(NullReferenceException) : $"{nameof(System)}.{nameof(NullReferenceException)}";
                if (seenExceptionTypes.Add(exception))
                    yield return exception;
            }
            else if (expression is ObjectCreationExpressionSyntax { Type: TypeSyntax exceptionType })
            {
                exceptionType = exceptionType.ConvertToSingleLine();
                if (!IsExceptionCaughtAndNotRethrown(hasUsingSystem, exceptionType))
                {
                    var exception = exceptionType.ToString();
                    if (seenExceptionTypes.Add(exception))
                        yield return exception.Replace('<', '{').Replace('>', '}');
                }
            }
        }
    }

    private static bool IsExceptionCaughtAndNotRethrown(bool hasUsingSystem, TypeSyntax exceptionType)
    {
        for (SyntaxNode? current = exceptionType; current != null; current = current?.Parent)
        {
            if (current is not BlockSyntax { Parent: TryStatementSyntax tryStatement } block ||
                tryStatement.Block != block ||
                block.DescendantNodes().OfType<ThrowStatementSyntax>().Any(t => t.Expression is null))
            {
                continue;
            }

            foreach (var catchClause in tryStatement.Catches)
            {
                if (catchClause.Filter != null)
                    continue;

                // AN empty `catch { }` will always catch everything.
                if (catchClause.Declaration == null)
                    return true;

                // Poor mans equivalence check since we don't have semantics here.
                if (SyntaxFactory.AreEquivalent(exceptionType, catchClause.Declaration.Type.ConvertToSingleLine()))
                    return true;

                if (hasUsingSystem &&
                    catchClause.Declaration.Type is IdentifierNameSyntax { Identifier.ValueText: nameof(Exception) })
                {
                    return true;
                }

                if (catchClause.Declaration.Type is QualifiedNameSyntax
                    {
                        Left: IdentifierNameSyntax { Identifier.ValueText: nameof(System) },
                        Right: IdentifierNameSyntax { Identifier.ValueText: nameof(Exception) },
                    })
                {
                    return true;
                }
            }
        }

        return false;
    }

    protected override SyntaxToken GetTokenToRight(
        SyntaxTree syntaxTree, int position, CancellationToken cancellationToken)
    {
        if (position >= syntaxTree.GetText(cancellationToken).Length)
        {
            return default;
        }

        return syntaxTree.GetRoot(cancellationToken).FindTokenOnRightOfPosition(
            position, includeDirectives: true, includeDocumentationComments: true);
    }

    protected override SyntaxToken GetTokenToLeft(
        SyntaxTree syntaxTree, int position, CancellationToken cancellationToken)
    {
        if (position < 1)
        {
            return default;
        }

        return syntaxTree.GetRoot(cancellationToken).FindTokenOnLeftOfPosition(
            position, includeDirectives: true, includeDocumentationComments: true, includeSkipped: true);
    }

    protected override bool IsDocCommentNewLine(SyntaxToken token)
        => token.RawKind == (int)SyntaxKind.XmlTextLiteralNewLineToken;

    protected override bool IsEndOfLineTrivia(SyntaxTrivia trivia)
        => trivia.RawKind == (int)SyntaxKind.EndOfLineTrivia;

    protected override bool IsSingleExteriorTrivia(DocumentationCommentTriviaSyntax documentationComment, [NotNullWhen(true)] out string? existingCommentText)
    {
        existingCommentText = null;

        if (IsMultilineDocComment(documentationComment))
        {
            return false;
        }

        if (documentationComment.Content.Count != 1)
        {
            return false;
        }

        if (documentationComment.Content[0] is not XmlTextSyntax xmlText)
        {
            return false;
        }

        var textTokens = xmlText.TextTokens;
        if (!textTokens.Any())
        {
            return false;
        }

        var lastTextToken = textTokens.Last();
        var firstTextToken = textTokens.First();

        // We only allow more than one token if the first one is an actual comment, not whitespace
        if (textTokens.Count != 1 && string.IsNullOrWhiteSpace(firstTextToken.ValueText))
        {
            return false;
        }

        // If there are exactly two text tokens it means there is an existing comment on a single
        // line that we want to preserve. When there are more tokens the parser has merged multiple
        // consecutive `///` lines into one DocumentationCommentTriviaSyntax; in that case the
        // existing text belongs to earlier lines, not the line the user just typed, so we must not
        // use it as the replacement-span length (doing so would overflow into the declaration).
        existingCommentText = textTokens.Count == 2 ? firstTextToken.ValueText : "";

        return lastTextToken.Kind() == SyntaxKind.XmlTextLiteralNewLineToken
            && lastTextToken.TrailingTrivia.Count == 0
            && firstTextToken.LeadingTrivia is [(kind: SyntaxKind.DocumentationCommentExteriorTrivia) firstTrivia]
            && firstTrivia.ToString() == ExteriorTriviaText;
    }

    private static IList<SyntaxToken> GetTextTokensFollowingExteriorTrivia(XmlTextSyntax xmlText)
    {
        var result = new List<SyntaxToken>();

        var tokenList = xmlText.TextTokens;
        foreach (var token in tokenList.Reverse())
        {
            result.Add(token);

            if (token.LeadingTrivia.Any(SyntaxKind.DocumentationCommentExteriorTrivia))
            {
                break;
            }
        }

        result.Reverse();

        return result;
    }

    protected override bool EndsWithSingleExteriorTrivia(DocumentationCommentTriviaSyntax? documentationComment)
    {
        if (documentationComment == null)
        {
            return false;
        }

        if (IsMultilineDocComment(documentationComment))
        {
            return false;
        }

        if (documentationComment.Content.LastOrDefault() is not XmlTextSyntax xmlText)
        {
            return false;
        }

        var textTokens = GetTextTokensFollowingExteriorTrivia(xmlText);

        if (textTokens.Any(t => !string.IsNullOrWhiteSpace(t.ToString())))
        {
            return false;
        }

        var lastTextToken = textTokens.LastOrDefault();
        var firstTextToken = textTokens.FirstOrDefault();

        return lastTextToken.Kind() == SyntaxKind.XmlTextLiteralNewLineToken
            && firstTextToken.LeadingTrivia.Count == 1
            && firstTextToken.LeadingTrivia.ElementAt(0).Kind() == SyntaxKind.DocumentationCommentExteriorTrivia
            && firstTextToken.LeadingTrivia.ElementAt(0).ToString() == ExteriorTriviaText
            && lastTextToken.TrailingTrivia.Count == 0;
    }

    protected override bool IsMultilineDocComment(DocumentationCommentTriviaSyntax? documentationComment)
        => documentationComment.IsMultilineDocComment();

    protected override bool HasSkippedTrailingTrivia(SyntaxToken token)
        => token.TrailingTrivia.Any(t => t.Kind() == SyntaxKind.SkippedTokensTrivia);
}