File: DocumentationComments\AbstractDocumentationCommentSnippetService.cs
Web Access
Project: src\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.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.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
 
namespace Microsoft.CodeAnalysis.DocumentationComments;
 
internal abstract class AbstractDocumentationCommentSnippetService<TDocumentationComment, TMemberNode> : IDocumentationCommentSnippetService
    where TDocumentationComment : SyntaxNode, IStructuredTriviaSyntax
    where TMemberNode : SyntaxNode
{
    protected abstract TMemberNode? GetContainingMember(SyntaxTree syntaxTree, int position, CancellationToken cancellationToken);
    protected abstract bool SupportsDocumentationComments(TMemberNode member);
    protected abstract bool HasDocumentationComment(TMemberNode member);
    protected abstract int GetPrecedingDocumentationCommentCount(TMemberNode member);
    protected abstract List<string> GetDocumentationCommentStubLines(TMemberNode member, string existingCommentText);
 
    protected abstract SyntaxToken GetTokenToRight(SyntaxTree syntaxTree, int position, CancellationToken cancellationToken);
    protected abstract SyntaxToken GetTokenToLeft(SyntaxTree syntaxTree, int position, CancellationToken cancellationToken);
    protected abstract bool IsDocCommentNewLine(SyntaxToken token);
    protected abstract bool IsEndOfLineTrivia(SyntaxTrivia trivia);
 
    protected abstract bool IsSingleExteriorTrivia(TDocumentationComment documentationComment, [NotNullWhen(true)] out string? existingCommentText);
    protected abstract bool EndsWithSingleExteriorTrivia(TDocumentationComment? documentationComment);
    protected abstract bool IsMultilineDocComment(TDocumentationComment? documentationComment);
    protected abstract bool HasSkippedTrailingTrivia(SyntaxToken token);
 
    public abstract string DocumentationCommentCharacter { get; }
 
    protected abstract string ExteriorTriviaText { get; }
    protected abstract bool AddIndent { get; }
 
    public DocumentationCommentSnippet? GetDocumentationCommentSnippetOnCharacterTyped(
        ParsedDocument document,
        int position,
        in DocumentationCommentOptions options,
        CancellationToken cancellationToken,
        bool addIndentation = true)
    {
        if (!options.AutoXmlDocCommentGeneration)
        {
            return null;
        }
 
        // Only generate if the position is immediately after '///', 
        // and that is the only documentation comment on the target member.
 
        var syntaxTree = document.SyntaxTree;
        var text = document.Text;
 
        var token = syntaxTree.GetRoot(cancellationToken).FindToken(position, findInsideTrivia: true);
        if (position != token.SpanStart)
        {
            return null;
        }
 
        var lines = addIndentation
            ? GetDocumentationCommentLines(token, text, options, out _, out var caretOffset, out var spanToReplaceLength)
            : GetDocumentationCommentLinesNoIndentation(token, text, options, out caretOffset, out spanToReplaceLength);
 
        if (lines == null)
        {
            return null;
        }
 
        var newLine = options.NewLine;
 
        var lastLine = lines[^1];
        lines[^1] = lastLine[..^newLine.Length];
 
        var comments = string.Join(string.Empty, lines);
 
        var replaceSpan = new TextSpan(token.Span.Start, spanToReplaceLength);
 
        return new DocumentationCommentSnippet(replaceSpan, comments, caretOffset);
    }
 
    private List<string>? GetDocumentationCommentLines(SyntaxToken token, SourceText text, in DocumentationCommentOptions options, out string? indentText, out int caretOffset, out int spanToReplaceLength)
    {
        indentText = null;
 
        var lines = GetDocumentationStubLines(token, text, options, out caretOffset, out spanToReplaceLength, out var existingCommentText);
        if (lines is null)
        {
            return lines;
        }
 
        var documentationComment = token.GetAncestor<TDocumentationComment>();
        var line = text.Lines.GetLineFromPosition(documentationComment!.FullSpan.Start);
        if (line.IsEmptyOrWhitespace())
        {
            return null;
        }
 
        // Add indents
        var lineOffset = line.GetColumnOfFirstNonWhitespaceCharacterOrEndOfLine(options.TabSize);
        indentText = lineOffset.CreateIndentationString(options.UseTabs, options.TabSize);
 
        IndentLines(lines, indentText);
 
        // We always want the caret text to be on the second line, with one space after the doc comment XML
        // GetDocumentationCommentStubLines ensures that space is always there
        caretOffset = lines[0].Length + indentText.Length + ExteriorTriviaText.Length + 1;
        spanToReplaceLength = existingCommentText!.Length;
 
        return lines;
    }
 
    private List<string>? GetDocumentationCommentLinesNoIndentation(SyntaxToken token, SourceText text, in DocumentationCommentOptions options, out int caretOffset, out int spanToReplaceLength)
    {
        var lines = GetDocumentationStubLines(token, text, options, out caretOffset, out spanToReplaceLength, out var existingCommentText);
        if (lines is null)
        {
            return lines;
        }
 
        // We always want the caret text to be on the second line, with one space after the doc comment XML
        // GetDocumentationCommentStubLines ensures that space is always there
        caretOffset = lines[0].Length + ExteriorTriviaText.Length + 1;
        spanToReplaceLength = existingCommentText!.Length;
 
        return lines;
    }
 
    private List<string>? GetDocumentationStubLines(SyntaxToken token, SourceText text, in DocumentationCommentOptions options, out int caretOffset, out int spanToReplaceLength, out string? existingCommentText)
    {
        caretOffset = 0;
        spanToReplaceLength = 0;
        existingCommentText = null;
 
        var documentationComment = token.GetAncestor<TDocumentationComment>();
 
        if (documentationComment == null || !IsSingleExteriorTrivia(documentationComment, out existingCommentText))
        {
            return null;
        }
 
        var targetMember = GetTargetMember(documentationComment);
 
        // Ensure that the target member is only preceded by a single documentation comment (i.e. our ///).
        if (targetMember == null || GetPrecedingDocumentationCommentCount(targetMember) != 1)
        {
            return null;
        }
 
        var line = text.Lines.GetLineFromPosition(documentationComment.FullSpan.Start);
        if (line.IsEmptyOrWhitespace())
        {
            return null;
        }
 
        var lines = GetDocumentationCommentStubLines(targetMember, existingCommentText);
        Debug.Assert(lines.Count > 2);
 
        AddLineBreaks(lines, options.NewLine);
 
        // Shave off initial three slashes
        lines[0] = lines[0][3..];
 
        return lines;
    }
 
    public bool IsValidTargetMember(ParsedDocument document, int position, CancellationToken cancellationToken)
        => GetTargetMember(document, position, cancellationToken) != null;
 
    private TMemberNode? GetTargetMember(ParsedDocument document, int position, CancellationToken cancellationToken)
    {
        var syntaxTree = document.SyntaxTree;
        var text = document.Text;
 
        var member = GetContainingMember(syntaxTree, position, cancellationToken);
        if (member == null)
        {
            return null;
        }
 
        if (!SupportsDocumentationComments(member) || HasDocumentationComment(member))
        {
            return null;
        }
 
        var startPosition = member.GetFirstToken().SpanStart;
        var line = text.Lines.GetLineFromPosition(startPosition);
        var lineOffset = line.GetFirstNonWhitespaceOffset();
        if (!lineOffset.HasValue || line.Start + lineOffset.Value < startPosition)
        {
            return null;
        }
 
        return member;
    }
 
    private TMemberNode? GetTargetMember(TDocumentationComment documentationComment)
    {
        var targetMember = documentationComment.ParentTrivia.Token.GetAncestor<TMemberNode>();
 
        if (targetMember == null || !SupportsDocumentationComments(targetMember))
        {
            return null;
        }
 
        if (targetMember.SpanStart < documentationComment.SpanStart)
        {
            return null;
        }
 
        return targetMember;
    }
 
    private static void AddLineBreaks(IList<string> lines, string newLine)
    {
        for (var i = 0; i < lines.Count; i++)
        {
            lines[i] = lines[i] + newLine;
        }
    }
 
    private static void IndentLines(List<string> lines, string? indentText)
    {
        for (var i = 1; i < lines.Count; i++)
        {
            lines[i] = indentText + lines[i];
        }
    }
 
    public DocumentationCommentSnippet? GetDocumentationCommentSnippetOnEnterTyped(ParsedDocument document, int position, in DocumentationCommentOptions options, CancellationToken cancellationToken)
    {
        // Don't attempt to generate a new XML doc comment on ENTER if the option to auto-generate
        // them isn't set. Regardless of the option, we should generate exterior trivia (i.e. /// or ''')
        // on ENTER inside an existing XML doc comment.
 
        if (options.AutoXmlDocCommentGeneration)
        {
            var result = GenerateDocumentationCommentAfterEnter(document, position, options, cancellationToken);
            if (result != null)
                return result;
        }
 
        return GenerateExteriorTriviaAfterEnter(document, position, options, cancellationToken);
    }
 
    private DocumentationCommentSnippet? GenerateDocumentationCommentAfterEnter(ParsedDocument document, int position, in DocumentationCommentOptions options, CancellationToken cancellationToken)
    {
        // Find the documentation comment before the new line that was just pressed
        var syntaxTree = document.SyntaxTree;
        var text = document.Text;
 
        var token = GetTokenToLeft(syntaxTree, position, cancellationToken);
        if (!IsDocCommentNewLine(token))
        {
            return null;
        }
 
        var newLine = options.NewLine;
        var lines = GetDocumentationCommentLines(token, text, options, out var indentText, out _, out _);
        if (lines == null)
        {
            return null;
        }
 
        var newText = string.Join(string.Empty, lines);
        var offset = lines[0].Length + lines[1].Length - newLine.Length;
 
        // Shave off final line break or add trailing indent if necessary
        var trivia = syntaxTree.GetRoot(cancellationToken).FindTrivia(position, findInsideTrivia: false);
        if (IsEndOfLineTrivia(trivia))
        {
            newText = newText[..^newLine.Length];
        }
        else
        {
            newText += indentText;
        }
 
        var replaceSpan = token.Span;
        var currentLine = text.Lines.GetLineFromPosition(position);
        var currentLinePosition = currentLine.GetFirstNonWhitespacePosition();
        if (currentLinePosition.HasValue)
        {
            var start = token.Span.Start;
            replaceSpan = new TextSpan(start, currentLinePosition.Value - start);
        }
 
        return new DocumentationCommentSnippet(replaceSpan, newText, offset);
    }
 
    public DocumentationCommentSnippet? GetDocumentationCommentSnippetOnCommandInvoke(ParsedDocument document, int position, in DocumentationCommentOptions options, CancellationToken cancellationToken)
    {
        var text = document.Text;
 
        var targetMember = GetTargetMember(document, position, cancellationToken);
        if (targetMember == null)
        {
            return null;
        }
 
        var token = targetMember.GetFirstToken();
        var startPosition = token.SpanStart;
        var line = text.Lines.GetLineFromPosition(startPosition);
        Debug.Assert(!line.IsEmptyOrWhitespace());
 
        var lines = GetDocumentationCommentStubLines(targetMember, string.Empty);
        Debug.Assert(lines.Count > 2);
 
        var newLine = options.NewLine;
        AddLineBreaks(lines, newLine);
 
        // Add indents
        var lineOffset = line.GetColumnOfFirstNonWhitespaceCharacterOrEndOfLine(options.TabSize);
        Debug.Assert(line.Start + lineOffset == startPosition);
 
        var indentText = lineOffset.CreateIndentationString(options.UseTabs, options.TabSize);
        IndentLines(lines, indentText);
 
        lines[^1] = lines[^1] + indentText;
 
        var comments = string.Join(string.Empty, lines);
        var offset = lines[0].Length + lines[1].Length - newLine.Length;
 
        // For a command we don't replace a token, but insert before it
        var replaceSpan = new TextSpan(token.Span.Start, 0);
 
        return new DocumentationCommentSnippet(replaceSpan, comments, offset);
    }
 
    private DocumentationCommentSnippet? GenerateExteriorTriviaAfterEnter(ParsedDocument document, int position, in DocumentationCommentOptions options, CancellationToken cancellationToken)
    {
        var syntaxTree = document.SyntaxTree;
        var text = document.Text;
 
        // Find the documentation comment before the new line that was just pressed
        var syntaxFacts = document.LanguageServices.GetRequiredService<ISyntaxFactsService>();
        if (syntaxFacts.IsEntirelyWithinStringOrCharOrNumericLiteral(syntaxTree, position, cancellationToken))
            return null;
 
        var token = GetTokenToLeft(syntaxTree, position, cancellationToken);
        if (!IsDocCommentNewLine(token) && HasSkippedTrailingTrivia(token))
        {
            // See PressingEnter_InsertSlashes11 for an example of
            // a case where multiple skipped tokens trivia appear at the same position.
            // In that case, we need to ask for the token from the next position over.
            token = GetTokenToLeft(syntaxTree, position + 1, cancellationToken);
 
            if (!IsDocCommentNewLine(token))
            {
                return null;
            }
        }
 
        var currentLine = text.Lines.GetLineFromPosition(position);
        if (currentLine.LineNumber == 0)
        {
            return null;
        }
 
        // Previous line must begin with a doc comment
        var previousLine = text.Lines[currentLine.LineNumber - 1];
        var previousLineText = previousLine.ToString().Trim();
        if (!previousLineText.StartsWith(ExteriorTriviaText, StringComparison.Ordinal))
        {
            return null;
        }
 
        var nextLineStartsWithDocComment = text.Lines.Count > currentLine.LineNumber + 1 &&
            text.Lines[currentLine.LineNumber + 1].ToString().Trim().StartsWith(ExteriorTriviaText, StringComparison.Ordinal);
 
        // if previous line has only exterior trivia, current line is empty and next line doesn't begin
        // with exterior trivia then stop inserting auto generated xml doc string
        if (previousLineText.Equals(ExteriorTriviaText) &&
            string.IsNullOrWhiteSpace(currentLine.ToString()) &&
            !nextLineStartsWithDocComment)
        {
            return null;
        }
 
        var documentationComment = token.GetAncestor<TDocumentationComment>();
        if (IsMultilineDocComment(documentationComment))
        {
            return null;
        }
 
        if (EndsWithSingleExteriorTrivia(documentationComment) && currentLine.IsEmptyOrWhitespace() && !nextLineStartsWithDocComment)
        {
            return null;
        }
 
        return GetDocumentationCommentSnippetFromPreviousLine(options, currentLine, previousLine);
    }
 
    public DocumentationCommentSnippet GetDocumentationCommentSnippetFromPreviousLine(in DocumentationCommentOptions options, TextLine currentLine, TextLine previousLine)
    {
        var insertionText = CreateInsertionTextFromPreviousLine(previousLine, options);
 
        var firstNonWhitespaceOffset = currentLine.GetFirstNonWhitespaceOffset();
        var replaceSpan = firstNonWhitespaceOffset != null
            ? TextSpan.FromBounds(currentLine.Start, currentLine.Start + firstNonWhitespaceOffset.Value)
            : currentLine.Span;
 
        return new DocumentationCommentSnippet(replaceSpan, insertionText, insertionText.Length);
    }
 
    private string CreateInsertionTextFromPreviousLine(TextLine previousLine, in DocumentationCommentOptions options)
    {
        var previousLineText = previousLine.ToString();
        var firstNonWhitespaceColumn = previousLineText.GetColumnOfFirstNonWhitespaceCharacterOrEndOfLine(options.TabSize);
 
        var trimmedPreviousLine = previousLineText.Trim();
        Debug.Assert(trimmedPreviousLine.StartsWith(ExteriorTriviaText), "Unexpected: previous line does not begin with doc comment exterior trivia.");
 
        // skip exterior trivia.
        trimmedPreviousLine = trimmedPreviousLine[3..];
 
        var firstNonWhitespaceOffsetInPreviousXmlText = trimmedPreviousLine.GetFirstNonWhitespaceOffset();
 
        var extraIndent = firstNonWhitespaceOffsetInPreviousXmlText != null
            ? trimmedPreviousLine[..firstNonWhitespaceOffsetInPreviousXmlText.Value]
            : " ";
 
        return firstNonWhitespaceColumn.CreateIndentationString(options.UseTabs, options.TabSize) + ExteriorTriviaText + extraIndent;
    }
}