|
// 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.
#nullable disable
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Commanding;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor.Commanding.Commands;
using Microsoft.VisualStudio.Text.Operations;
using Roslyn.Utilities;
namespace Microsoft.CodeAnalysis.CommentSelection;
internal abstract class AbstractToggleBlockCommentBase :
// Value tuple to represent that there is no distinct command to be passed in.
AbstractCommentSelectionBase<ValueTuple>,
ICommandHandler<ToggleBlockCommentCommandArgs>
{
private static readonly CommentSelectionResult s_emptyCommentSelectionResult =
new([], [], Operation.Uncomment);
private readonly ITextStructureNavigatorSelectorService _navigatorSelectorService;
internal AbstractToggleBlockCommentBase(
ITextUndoHistoryRegistry undoHistoryRegistry,
IEditorOperationsFactoryService editorOperationsFactoryService,
ITextStructureNavigatorSelectorService navigatorSelectorService,
EditorOptionsService editorOptionsService)
: base(undoHistoryRegistry, editorOperationsFactoryService, editorOptionsService)
{
_navigatorSelectorService = navigatorSelectorService;
}
/// <summary>
/// Retrieves data about the commented spans near the selection.
/// </summary>
/// <param name="document">the current document.</param>
/// <param name="snapshot">the current text snapshot.</param>
/// <param name="linesContainingSelections">
/// a span that contains text from the first character of the first line in the selection(s)
/// until the last character of the last line in the selection(s)
/// </param>
/// <param name="commentInfo">the comment information for the document.</param>
/// <returns>any commented spans relevant to the selection in the document.</returns>
protected abstract ImmutableArray<TextSpan> GetBlockCommentsInDocument(Document document, ITextSnapshot snapshot,
TextSpan linesContainingSelections, CommentSelectionInfo commentInfo, CancellationToken cancellationToken);
public CommandState GetCommandState(ToggleBlockCommentCommandArgs args)
=> GetCommandState(args.SubjectBuffer);
public bool ExecuteCommand(ToggleBlockCommentCommandArgs args, CommandExecutionContext context)
=> ExecuteCommand(args.TextView, args.SubjectBuffer, ValueTuple.Create(), context);
public override string DisplayName => EditorFeaturesResources.Toggle_Block_Comment;
protected override string GetTitle(ValueTuple command) => EditorFeaturesResources.Toggle_Block_Comment;
protected override string GetMessage(ValueTuple command) => EditorFeaturesResources.Toggling_block_comment;
internal override CommentSelectionResult CollectEdits(Document document, ICommentSelectionService service,
ITextBuffer subjectBuffer, NormalizedSnapshotSpanCollection selectedSpans, ValueTuple command, CancellationToken cancellationToken)
{
using (Logger.LogBlock(FunctionId.CommandHandler_ToggleBlockComment, KeyValueLogMessage.Create(LogType.UserAction, m =>
{
m[LanguageNameString] = document.Project.Language;
m[LengthString] = subjectBuffer.CurrentSnapshot.Length;
}), cancellationToken))
{
var navigator = _navigatorSelectorService.GetTextStructureNavigator(subjectBuffer);
var commentInfo = service.GetInfo();
if (commentInfo.SupportsBlockComment)
{
return ToggleBlockComments(document, commentInfo, navigator, selectedSpans, cancellationToken);
}
return s_emptyCommentSelectionResult;
}
}
private CommentSelectionResult ToggleBlockComments(Document document, CommentSelectionInfo commentInfo,
ITextStructureNavigator navigator, NormalizedSnapshotSpanCollection selectedSpans, CancellationToken cancellationToken)
{
var firstLineAroundSelection = selectedSpans.First().Start.GetContainingLine().Start;
var lastLineAroundSelection = selectedSpans.Last().End.GetContainingLine().End;
var linesContainingSelection = TextSpan.FromBounds(firstLineAroundSelection, lastLineAroundSelection);
var blockCommentedSpans = GetBlockCommentsInDocument(
document, selectedSpans.First().Snapshot, linesContainingSelection, commentInfo, cancellationToken);
var blockCommentSelections = selectedSpans.SelectAsArray(span => new BlockCommentSelectionHelper(blockCommentedSpans, span));
var returnOperation = Operation.Uncomment;
var textChanges = ArrayBuilder<TextChange>.GetInstance();
var trackingSpans = ArrayBuilder<CommentTrackingSpan>.GetInstance();
// Try to uncomment until an already uncommented span is found.
foreach (var blockCommentSelection in blockCommentSelections)
{
// If any selection does not have comments to remove, then the operation should be comment.
if (!TryUncommentBlockComment(blockCommentedSpans, blockCommentSelection, textChanges, trackingSpans, commentInfo))
{
returnOperation = Operation.Comment;
break;
}
}
if (returnOperation == Operation.Comment)
{
textChanges.Clear();
trackingSpans.Clear();
foreach (var blockCommentSelection in blockCommentSelections)
{
BlockCommentSpan(blockCommentSelection, navigator, textChanges, trackingSpans, commentInfo);
}
}
return new CommentSelectionResult(textChanges.ToArrayAndFree(), trackingSpans.ToArrayAndFree(), returnOperation);
}
private static bool TryUncommentBlockComment(ImmutableArray<TextSpan> blockCommentedSpans,
BlockCommentSelectionHelper blockCommentSelection, ArrayBuilder<TextChange> textChanges,
ArrayBuilder<CommentTrackingSpan> trackingSpans, CommentSelectionInfo commentInfo)
{
// If the selection is just a caret, try and uncomment blocks on the same line with only whitespace on the line.
if (blockCommentSelection.SelectedSpan.IsEmpty
&& blockCommentSelection.TryGetBlockCommentOnSameLine(blockCommentedSpans, out var blockCommentOnSameLine))
{
DeleteBlockComment(blockCommentSelection, blockCommentOnSameLine, textChanges, commentInfo);
trackingSpans.Add(new CommentTrackingSpan(blockCommentOnSameLine));
return true;
}
// If the selection is entirely commented, remove any block comments that intersect.
else if (blockCommentSelection.IsEntirelyCommented())
{
var intersectingBlockComments = blockCommentSelection.IntersectingBlockComments;
foreach (var spanToRemove in intersectingBlockComments)
{
DeleteBlockComment(blockCommentSelection, spanToRemove, textChanges, commentInfo);
}
var trackingSpan = TextSpan.FromBounds(intersectingBlockComments.First().Start, intersectingBlockComments.Last().End);
trackingSpans.Add(new CommentTrackingSpan(trackingSpan));
return true;
}
else
{
return false;
}
}
private static void BlockCommentSpan(BlockCommentSelectionHelper blockCommentSelection, ITextStructureNavigator navigator,
ArrayBuilder<TextChange> textChanges, ArrayBuilder<CommentTrackingSpan> trackingSpans, CommentSelectionInfo commentInfo)
{
// Add sequential block comments if the selection contains any intersecting comments.
if (blockCommentSelection.HasIntersectingBlockComments())
{
AddBlockCommentWithIntersectingSpans(blockCommentSelection, textChanges, trackingSpans, commentInfo);
}
else
{
// Comment the selected span or caret location.
var spanToAdd = blockCommentSelection.SelectedSpan;
if (spanToAdd.IsEmpty)
{
var caretLocation = GetCaretLocationAfterToken(navigator, blockCommentSelection);
spanToAdd = TextSpan.FromBounds(caretLocation, caretLocation);
}
trackingSpans.Add(new CommentTrackingSpan(spanToAdd));
AddBlockComment(commentInfo, spanToAdd, textChanges);
}
}
/// <summary>
/// Returns a caret location of itself or the location after the token the caret is inside of.
/// </summary>
private static int GetCaretLocationAfterToken(ITextStructureNavigator navigator, BlockCommentSelectionHelper blockCommentSelection)
{
var snapshotSpan = blockCommentSelection.SnapshotSpan;
if (navigator == null)
{
return snapshotSpan.Start;
}
var extent = navigator.GetExtentOfWord(snapshotSpan.Start);
int locationAfterToken = extent.Span.End;
// Don't move to the end if it's already before the token.
if (snapshotSpan.Start == extent.Span.Start)
{
locationAfterToken = extent.Span.Start;
}
// If the 'word' is just whitespace, use the selected location.
if (blockCommentSelection.IsSpanWhitespace(TextSpan.FromBounds(extent.Span.Start, extent.Span.End)))
{
locationAfterToken = snapshotSpan.Start;
}
return locationAfterToken;
}
/// <summary>
/// Adds a block comment when the selection already contains block comment(s).
/// The result will be sequential block comments with the entire selection being commented out.
/// </summary>
private static void AddBlockCommentWithIntersectingSpans(BlockCommentSelectionHelper blockCommentSelection,
ArrayBuilder<TextChange> textChanges, ArrayBuilder<CommentTrackingSpan> trackingSpans, CommentSelectionInfo commentInfo)
{
var selectedSpan = blockCommentSelection.SelectedSpan;
var amountToAddToStart = 0;
var amountToAddToEnd = 0;
// Add comments to all uncommented spans in the selection.
foreach (var uncommentedSpan in blockCommentSelection.UncommentedSpansInSelection)
{
AddBlockComment(commentInfo, uncommentedSpan, textChanges);
}
var startsWithCommentMarker = blockCommentSelection.StartsWithAnyBlockCommentMarker(commentInfo);
var endsWithCommentMarker = blockCommentSelection.EndsWithAnyBlockCommentMarker(commentInfo);
// If the start is commented (and not a comment marker), close the current comment and open a new one.
if (blockCommentSelection.IsLocationCommented(selectedSpan.Start) && !startsWithCommentMarker)
{
InsertText(textChanges, selectedSpan.Start, commentInfo.BlockCommentEndString);
InsertText(textChanges, selectedSpan.Start, commentInfo.BlockCommentStartString);
// Shrink the tracking so the previous comment start marker is not included in selection.
amountToAddToStart = commentInfo.BlockCommentEndString.Length;
}
// If the end is commented (and not a comment marker), close the current comment and open a new one.
if (blockCommentSelection.IsLocationCommented(selectedSpan.End) && !endsWithCommentMarker)
{
InsertText(textChanges, selectedSpan.End, commentInfo.BlockCommentEndString);
InsertText(textChanges, selectedSpan.End, commentInfo.BlockCommentStartString);
// Shrink the tracking span so the next comment start marker is not included in selection.
amountToAddToEnd = -commentInfo.BlockCommentStartString.Length;
}
trackingSpans.Add(new CommentTrackingSpan(selectedSpan, amountToAddToStart, amountToAddToEnd));
}
private static void AddBlockComment(CommentSelectionInfo commentInfo, TextSpan span, ArrayBuilder<TextChange> textChanges)
{
InsertText(textChanges, span.Start, commentInfo.BlockCommentStartString);
InsertText(textChanges, span.End, commentInfo.BlockCommentEndString);
}
private static void DeleteBlockComment(BlockCommentSelectionHelper blockCommentSelection, TextSpan spanToRemove,
ArrayBuilder<TextChange> textChanges, CommentSelectionInfo commentInfo)
{
DeleteText(textChanges, new TextSpan(spanToRemove.Start, commentInfo.BlockCommentStartString.Length));
var endMarkerPosition = spanToRemove.End - commentInfo.BlockCommentEndString.Length;
// Sometimes the block comment will be missing a close marker.
if (Equals(blockCommentSelection.GetSubstringFromText(endMarkerPosition, commentInfo.BlockCommentEndString.Length),
commentInfo.BlockCommentEndString))
{
DeleteText(textChanges, new TextSpan(endMarkerPosition, commentInfo.BlockCommentEndString.Length));
}
}
private class BlockCommentSelectionHelper
{
/// <summary>
/// Trimmed text of the selection.
/// </summary>
private readonly string _trimmedText;
public SnapshotSpan SnapshotSpan { get; }
public TextSpan SelectedSpan { get; }
public ImmutableArray<TextSpan> IntersectingBlockComments { get; }
public ImmutableArray<TextSpan> UncommentedSpansInSelection { get; }
public BlockCommentSelectionHelper(ImmutableArray<TextSpan> allBlockComments, SnapshotSpan selectedSnapshotSpan)
{
_trimmedText = selectedSnapshotSpan.GetText().Trim();
SnapshotSpan = selectedSnapshotSpan;
SelectedSpan = TextSpan.FromBounds(selectedSnapshotSpan.Start, selectedSnapshotSpan.End);
IntersectingBlockComments = GetIntersectingBlockComments(allBlockComments, SelectedSpan);
UncommentedSpansInSelection = GetUncommentedSpansInSelection();
}
/// <summary>
/// Determines if the given span is entirely whitespace.
/// </summary>
public bool IsSpanWhitespace(TextSpan span)
{
for (var i = span.Start; i < span.End; i++)
{
if (!char.IsWhiteSpace(SnapshotSpan.Snapshot[i]))
{
return false;
}
}
return true;
}
/// <summary>
/// Determines if the location falls inside a commented span.
/// </summary>
public bool IsLocationCommented(int location)
=> IntersectingBlockComments.Contains(span => span.Contains(location));
/// <summary>
/// Checks if the selection already starts with a comment marker.
/// This prevents us from adding an extra marker.
/// </summary>
public bool StartsWithAnyBlockCommentMarker(CommentSelectionInfo commentInfo)
{
return _trimmedText.StartsWith(commentInfo.BlockCommentStartString, StringComparison.Ordinal)
|| _trimmedText.StartsWith(commentInfo.BlockCommentEndString, StringComparison.Ordinal);
}
/// <summary>
/// Checks if the selection already ends with a comment marker.
/// This prevents us from adding an extra marker.
/// </summary>
public bool EndsWithAnyBlockCommentMarker(CommentSelectionInfo commentInfo)
{
return _trimmedText.EndsWith(commentInfo.BlockCommentStartString, StringComparison.Ordinal)
|| _trimmedText.EndsWith(commentInfo.BlockCommentEndString, StringComparison.Ordinal);
}
/// <summary>
/// Checks if the selected span contains any uncommented non whitespace characters.
/// </summary>
public bool IsEntirelyCommented()
=> !UncommentedSpansInSelection.Any() && HasIntersectingBlockComments();
/// <summary>
/// Returns if the selection intersects with any block comments.
/// </summary>
public bool HasIntersectingBlockComments()
=> IntersectingBlockComments.Any();
public string GetSubstringFromText(int position, int length)
=> SnapshotSpan.Snapshot.GetText().Substring(position, length);
/// <summary>
/// Tries to get a block comment on the same line. There are two cases:
/// 1. The caret is preceding a block comment on the same line, with only whitespace before the comment.
/// 2. The caret is following a block comment on the same line, with only whitespace after the comment.
/// </summary>
public bool TryGetBlockCommentOnSameLine(ImmutableArray<TextSpan> allBlockComments, out TextSpan commentedSpanOnSameLine)
{
var snapshot = SnapshotSpan.Snapshot;
var selectedLine = snapshot.GetLineFromPosition(SelectedSpan.Start);
var lineStartToCaretIsWhitespace = IsSpanWhitespace(TextSpan.FromBounds(selectedLine.Start, SelectedSpan.Start));
var caretToLineEndIsWhitespace = IsSpanWhitespace(TextSpan.FromBounds(SelectedSpan.Start, selectedLine.End));
foreach (var blockComment in allBlockComments)
{
if (lineStartToCaretIsWhitespace
&& SelectedSpan.Start < blockComment.Start
&& snapshot.AreOnSameLine(SelectedSpan.Start, blockComment.Start))
{
if (IsSpanWhitespace(TextSpan.FromBounds(SelectedSpan.Start, blockComment.Start)))
{
commentedSpanOnSameLine = blockComment;
return true;
}
}
else if (caretToLineEndIsWhitespace
&& SelectedSpan.Start > blockComment.End
&& snapshot.AreOnSameLine(SelectedSpan.Start, blockComment.End))
{
if (IsSpanWhitespace(TextSpan.FromBounds(blockComment.End, SelectedSpan.Start)))
{
commentedSpanOnSameLine = blockComment;
return true;
}
}
}
commentedSpanOnSameLine = new TextSpan();
return false;
}
/// <summary>
/// Gets a list of block comments that intersect the span.
/// Spans are intersecting if 1 location is the same between them (empty spans look at the start).
/// </summary>
private static ImmutableArray<TextSpan> GetIntersectingBlockComments(ImmutableArray<TextSpan> allBlockComments, TextSpan span)
=> allBlockComments.WhereAsArray(blockCommentSpan => span.OverlapsWith(blockCommentSpan) || blockCommentSpan.Contains(span));
/// <summary>
/// Retrieves all non commented, non whitespace spans.
/// </summary>
private ImmutableArray<TextSpan> GetUncommentedSpansInSelection()
{
var uncommentedSpans = new List<TextSpan>();
// Invert the commented spans to get the uncommented spans.
var spanStart = SelectedSpan.Start;
foreach (var commentedSpan in IntersectingBlockComments)
{
if (commentedSpan.Start > spanStart)
{
// Get span up until the comment and check to make sure it is not whitespace.
var possibleUncommentedSpan = TextSpan.FromBounds(spanStart, commentedSpan.Start);
if (!IsSpanWhitespace(possibleUncommentedSpan))
{
uncommentedSpans.Add(possibleUncommentedSpan);
}
}
// The next possible uncommented span starts at the end of this commented span.
spanStart = commentedSpan.End;
}
// If part of the selection is left over, it's not commented. Add if not whitespace.
if (spanStart < SelectedSpan.End)
{
var uncommentedSpan = TextSpan.FromBounds(spanStart, SelectedSpan.End);
if (!IsSpanWhitespace(uncommentedSpan))
{
uncommentedSpans.Add(uncommentedSpan);
}
}
return [.. uncommentedSpans];
}
}
}
|