CommentUncommentSelectionCommandHandler.cs 22.3 KB
Newer Older
S
Sam Harwell 已提交
1
// Copyright (c) Microsoft.  All Rights Reserved.  Licensed under the Apache License, Version 2.0.  See License.txt in the project root for license information.
2 3 4

using System;
using System.Collections.Generic;
5
using System.Collections.Immutable;
6 7 8
using System.ComponentModel.Composition;
using System.Linq;
using System.Threading;
9
using Microsoft.CodeAnalysis.CommentSelection;
10 11 12 13 14
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
15
using Microsoft.VisualStudio.Commanding;
16 17
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
18
using Microsoft.VisualStudio.Text.Editor.Commanding.Commands;
19
using Microsoft.VisualStudio.Text.Operations;
20
using Microsoft.VisualStudio.Utilities;
21
using Roslyn.Utilities;
22
using VSCommanding = Microsoft.VisualStudio.Commanding;
23 24 25

namespace Microsoft.CodeAnalysis.Editor.Implementation.CommentSelection
{
26 27 28
    [Export(typeof(VSCommanding.ICommandHandler))]
    [ContentType(ContentTypeNames.RoslynContentType)]
    [Name(PredefinedCommandHandlerNames.CommentSelection)]
29
    internal class CommentUncommentSelectionCommandHandler :
30 31
        VSCommanding.ICommandHandler<CommentSelectionCommandArgs>,
        VSCommanding.ICommandHandler<UncommentSelectionCommandArgs>
32
    {
33 34
        private readonly ITextUndoHistoryRegistry _undoHistoryRegistry;
        private readonly IEditorOperationsFactoryService _editorOperationsFactoryService;
35 36 37

        [ImportingConstructor]
        internal CommentUncommentSelectionCommandHandler(
38 39
            ITextUndoHistoryRegistry undoHistoryRegistry,
            IEditorOperationsFactoryService editorOperationsFactoryService)
40
        {
41 42
            Contract.ThrowIfNull(undoHistoryRegistry);
            Contract.ThrowIfNull(editorOperationsFactoryService);
43

44 45
            _undoHistoryRegistry = undoHistoryRegistry;
            _editorOperationsFactoryService = editorOperationsFactoryService;
46 47
        }

48
        public string DisplayName => EditorFeaturesResources.Comment_Uncomment_Selection;
49 50

        private static VSCommanding.CommandState GetCommandState(ITextBuffer buffer)
51 52 53
        {
            if (!buffer.CanApplyChangeDocumentToWorkspace())
            {
54
                return VSCommanding.CommandState.Unspecified;
55 56
            }

57
            return VSCommanding.CommandState.Available;
58 59
        }

60
        public VSCommanding.CommandState GetCommandState(CommentSelectionCommandArgs args)
61
        {
62
            return GetCommandState(args.SubjectBuffer);
63 64 65 66 67
        }

        /// <summary>
        /// Comment the selected spans, and reset the selection.
        /// </summary>
68
        public bool ExecuteCommand(CommentSelectionCommandArgs args, CommandExecutionContext context)
69
        {
70
            return this.ExecuteCommand(args.TextView, args.SubjectBuffer, Operation.Comment, context);
71 72
        }

73
        public VSCommanding.CommandState GetCommandState(UncommentSelectionCommandArgs args)
74
        {
75
            return GetCommandState(args.SubjectBuffer);
76 77 78 79 80
        }

        /// <summary>
        /// Uncomment the selected spans, and reset the selection.
        /// </summary>
81
        public bool ExecuteCommand(UncommentSelectionCommandArgs args, CommandExecutionContext context)
82
        {
83
            return this.ExecuteCommand(args.TextView, args.SubjectBuffer, Operation.Uncomment, context);
84 85
        }

86
        internal bool ExecuteCommand(ITextView textView, ITextBuffer subjectBuffer, Operation operation, CommandExecutionContext context)
87
        {
88 89
            var title = operation == Operation.Comment ? EditorFeaturesResources.Comment_Selection
                                                       : EditorFeaturesResources.Uncomment_Selection;
90

91 92
            var message = operation == Operation.Comment ? EditorFeaturesResources.Commenting_currently_selected_text
                                                         : EditorFeaturesResources.Uncommenting_currently_selected_text;
93

94
            using (context.OperationContext.AddScope(allowCancellation: false, message))
95 96 97 98
            {

                var document = subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
                if (document == null)
99
                {
100 101
                    return true;
                }
102

103 104 105 106 107
                var service = GetService(document);
                if (service == null)
                {
                    return true;
                }
108

109 110 111 112 113
                var trackingSpans = new List<ITrackingSpan>();
                var textChanges = new List<TextChange>();
                CollectEdits(
                    document, service, textView.Selection.GetSnapshotSpansOnBuffer(subjectBuffer),
                    textChanges, trackingSpans, operation, CancellationToken.None);
114

115 116 117 118 119
                using (var transaction = new CaretPreservingEditTransaction(title, textView, _undoHistoryRegistry, _editorOperationsFactoryService))
                {
                    document.Project.Solution.Workspace.ApplyTextChanges(document.Id, textChanges, CancellationToken.None);
                    transaction.Complete();
                }
120

121 122
                if (operation == Operation.Uncomment)
                {
123 124
                    using (var transaction = new CaretPreservingEditTransaction(title, textView, _undoHistoryRegistry, _editorOperationsFactoryService))
                    {
125
                        Format(service, subjectBuffer.CurrentSnapshot, trackingSpans, CancellationToken.None);
126 127
                        transaction.Complete();
                    }
128
                }
129

130 131 132 133 134 135
                if (trackingSpans.Any())
                {
                    // TODO, this doesn't currently handle block selection
                    textView.SetSelection(trackingSpans.First().GetSpan(subjectBuffer.CurrentSnapshot));
                }
            }
136

137
            return true;
138 139
        }

140 141 142 143 144 145 146 147 148 149 150 151 152
        private ICommentSelectionService GetService(Document document)
        {
            // First, try to get the new service for comment selection.
            var service = document.GetLanguageService<ICommentSelectionService>();
            if (service != null)
            {
                return service;
            }

            return null;
        }

        private void Format(ICommentSelectionService service, ITextSnapshot snapshot, IEnumerable<ITrackingSpan> changes, CancellationToken cancellationToken)
153 154 155 156 157 158 159
        {
            var document = snapshot.GetOpenDocumentInCurrentContextWithChanges();
            if (document == null)
            {
                return;
            }

C
CyrusNajmabadi 已提交
160
            var textSpans = changes.Select(s => s.GetSpan(snapshot).Span.ToTextSpan()).ToImmutableArray();
C
CyrusNajmabadi 已提交
161
            var newDocument = service.FormatAsync(document, textSpans, cancellationToken).WaitAndGetResult(cancellationToken);
162 163 164 165 166 167 168 169 170 171
            newDocument.Project.Solution.Workspace.ApplyDocumentChanges(newDocument, cancellationToken);
        }

        internal enum Operation { Comment, Uncomment }

        /// <summary>
        /// Add the necessary edits to the given spans. Also collect tracking spans over each span.
        ///
        /// Internal so that it can be called by unit tests.
        /// </summary>
C
CyrusNajmabadi 已提交
172
        internal void CollectEdits(
D
dotnet-bot 已提交
173
            Document document, ICommentSelectionService service, NormalizedSnapshotSpanCollection selectedSpans,
C
CyrusNajmabadi 已提交
174
            List<TextChange> textChanges, List<ITrackingSpan> trackingSpans, Operation operation, CancellationToken cancellationToken)
175 176 177 178 179
        {
            foreach (var span in selectedSpans)
            {
                if (operation == Operation.Comment)
                {
C
CyrusNajmabadi 已提交
180
                    CommentSpan(document, service, span, textChanges, trackingSpans, cancellationToken);
181 182 183
                }
                else
                {
C
CyrusNajmabadi 已提交
184
                    UncommentSpan(document, service, span, textChanges, trackingSpans, cancellationToken);
185 186 187 188 189 190 191
                }
            }
        }

        /// <summary>
        /// Add the necessary edits to comment out a single span.
        /// </summary>
C
CyrusNajmabadi 已提交
192
        private void CommentSpan(
D
dotnet-bot 已提交
193
            Document document, ICommentSelectionService service, SnapshotSpan span,
C
CyrusNajmabadi 已提交
194
            List<TextChange> textChanges, List<ITrackingSpan> trackingSpans, CancellationToken cancellationToken)
195
        {
196
            var (firstLine, lastLine) = DetermineFirstAndLastLine(span);
197

198
            if (span.IsEmpty && firstLine.IsEmptyOrWhitespace())
199
            {
200
                // No selection, and on an empty line, don't do anything.
201 202 203 204 205
                return;
            }

            if (!span.IsEmpty && string.IsNullOrWhiteSpace(span.GetText()))
            {
206
                // Just whitespace selected, don't do anything.
207 208 209
                return;
            }

210
            // Get the information from the language as to how they'd like to comment this region.
C
CyrusNajmabadi 已提交
211
            var commentInfo = service.GetInfoAsync(document, span.Span.ToTextSpan(), cancellationToken).WaitAndGetResult(cancellationToken);
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233
            if (!commentInfo.SupportsBlockComment && !commentInfo.SupportsSingleLineComment)
            {
                // Neither type of comment supported.
                return;
            }

            if (commentInfo.SupportsBlockComment && !commentInfo.SupportsSingleLineComment)
            {
                // Only block comments supported here.  If there is a span, just surround that
                // span with a block comment.  If tehre is no span then surround the entire line 
                // with a block comment.
                if (span.IsEmpty)
                {
                    var firstNonWhitespaceOnLine = firstLine.GetFirstNonWhitespacePosition();
                    var insertPosition = firstNonWhitespaceOnLine ?? firstLine.Start;

                    span = new SnapshotSpan(span.Snapshot, Span.FromBounds(insertPosition, firstLine.End));
                }

                AddBlockComment(span, textChanges, trackingSpans, commentInfo);
            }
            else if (!commentInfo.SupportsBlockComment && commentInfo.SupportsSingleLineComment)
234
            {
235 236
                // Only single line comments supported here.
                AddSingleLineComments(span, textChanges, trackingSpans, firstLine, lastLine, commentInfo);
237 238 239
            }
            else
            {
240 241 242
                // both comment forms supported.  Do a block comment only if a portion of code is
                // selected on a single line, otherwise comment out all the lines using single-line
                // comments.
D
dotnet-bot 已提交
243
                if (!span.IsEmpty &&
244
                    !SpanIncludesAllTextOnIncludedLines(span) &&
245
                    firstLine.LineNumber == lastLine.LineNumber)
246
                {
247
                    AddBlockComment(span, textChanges, trackingSpans, commentInfo);
248 249 250
                }
                else
                {
251
                    AddSingleLineComments(span, textChanges, trackingSpans, firstLine, lastLine, commentInfo);
252 253 254 255
                }
            }
        }

256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271
        private void AddSingleLineComments(SnapshotSpan span, List<TextChange> textChanges, List<ITrackingSpan> trackingSpans, ITextSnapshotLine firstLine, ITextSnapshotLine lastLine, CommentSelectionInfo commentInfo)
        {
            // Select the entirety of the lines, so that another comment operation will add more 
            // comments, not insert block comments.
            trackingSpans.Add(span.Snapshot.CreateTrackingSpan(Span.FromBounds(firstLine.Start.Position, lastLine.End.Position), SpanTrackingMode.EdgeInclusive));
            var indentToCommentAt = DetermineSmallestIndent(span, firstLine, lastLine);
            ApplySingleLineCommentToNonBlankLines(commentInfo, textChanges, firstLine, lastLine, indentToCommentAt);
        }

        private void AddBlockComment(SnapshotSpan span, List<TextChange> textChanges, List<ITrackingSpan> trackingSpans, CommentSelectionInfo commentInfo)
        {
            trackingSpans.Add(span.Snapshot.CreateTrackingSpan(span, SpanTrackingMode.EdgeInclusive));
            InsertText(textChanges, span.Start, commentInfo.BlockCommentStartString);
            InsertText(textChanges, span.End, commentInfo.BlockCommentEndString);
        }

272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290
        /// <summary>
        /// Record "Insert text" text changes.
        /// </summary>
        private void InsertText(List<TextChange> textChanges, int position, string text)
        {
            textChanges.Add(new TextChange(new TextSpan(position, 0), text));
        }

        /// <summary>
        /// Record "Delete text" text changes.
        /// </summary>
        private void DeleteText(List<TextChange> textChanges, TextSpan span)
        {
            textChanges.Add(new TextChange(span, string.Empty));
        }

        /// <summary>
        /// Add the necessary edits to uncomment out a single span.
        /// </summary>
C
CyrusNajmabadi 已提交
291
        private void UncommentSpan(
D
dotnet-bot 已提交
292
            Document document, ICommentSelectionService service, SnapshotSpan span,
C
CyrusNajmabadi 已提交
293
            List<TextChange> textChanges, List<ITrackingSpan> spansToSelect, CancellationToken cancellationToken)
294
        {
C
CyrusNajmabadi 已提交
295
            var info = service.GetInfoAsync(document, span.Span.ToTextSpan(), cancellationToken).WaitAndGetResult(cancellationToken);
296

297 298 299 300 301 302
            // If the selection is exactly a block comment, use it as priority over single line comments.
            if (info.SupportsBlockComment && TryUncommentExactlyBlockComment(info, span, textChanges, spansToSelect))
            {
                return;
            }

303 304
            if (info.SupportsSingleLineComment &&
                TryUncommentSingleLineComments(info, span, textChanges, spansToSelect))
305
            {
306 307
                return;
            }
308

309 310
            // We didn't make any single line changes.  If the language supports block comments, see 
            // if we're inside a containing block comment and uncomment that.
311 312 313 314
            if (info.SupportsBlockComment)
            {
                UncommentContainingBlockComment(info, span, textChanges, spansToSelect);
            }
315 316
        }

317 318 319 320 321
        /// <summary>
        /// Check if the selected span matches an entire block comment.
        /// If it does, uncomment it and return true.
        /// </summary>
        private bool TryUncommentExactlyBlockComment(CommentSelectionInfo info, SnapshotSpan span, List<TextChange> textChanges, List<ITrackingSpan> spansToSelect)
322 323 324 325 326
        {
            var spanText = span.GetText();
            var trimmedSpanText = spanText.Trim();

            // See if the selection includes just a block comment (plus whitespace)
327
            if (trimmedSpanText.StartsWith(info.BlockCommentStartString, StringComparison.Ordinal) && trimmedSpanText.EndsWith(info.BlockCommentEndString, StringComparison.Ordinal))
328
            {
329 330 331 332
                var positionOfStart = span.Start + spanText.IndexOf(info.BlockCommentStartString, StringComparison.Ordinal);
                var positionOfEnd = span.Start + spanText.LastIndexOf(info.BlockCommentEndString, StringComparison.Ordinal);
                UncommentPosition(info, span, textChanges, spansToSelect, positionOfStart, positionOfEnd);
                return true;
333
            }
334 335 336 337 338 339 340 341 342 343 344 345 346 347 348

            return false;
        }

        private void UncommentContainingBlockComment(CommentSelectionInfo info, SnapshotSpan span, List<TextChange> textChanges, List<ITrackingSpan> spansToSelect)
        {
            // See if we are (textually) contained in a block comment.
            // This could allow a selection that spans multiple block comments to uncomment the beginning of
            // the first and end of the last.  Oh well.
            var positionOfEnd = -1;
            var text = span.Snapshot.AsText();
            var positionOfStart = text.LastIndexOf(info.BlockCommentStartString, span.Start, caseSensitive: true);

            // If we found a start comment marker, make sure there isn't an end comment marker after it but before our span.
            if (positionOfStart >= 0)
349
            {
350 351
                var lastEnd = text.LastIndexOf(info.BlockCommentEndString, span.Start, caseSensitive: true);
                if (lastEnd < positionOfStart)
352
                {
353 354 355 356 357 358
                    positionOfEnd = text.IndexOf(info.BlockCommentEndString, span.End, caseSensitive: true);
                }
                else if (lastEnd + info.BlockCommentEndString.Length > span.End)
                {
                    // The end of the span is *inside* the end marker, so searching backwards found it.
                    positionOfEnd = lastEnd;
359
                }
360
            }
361

362 363 364 365 366
            UncommentPosition(info, span, textChanges, spansToSelect, positionOfStart, positionOfEnd);
        }

        private void UncommentPosition(CommentSelectionInfo info, SnapshotSpan span, List<TextChange> textChanges, List<ITrackingSpan> spansToSelect, int positionOfStart, int positionOfEnd)
        {
367 368
            if (positionOfStart < 0 || positionOfEnd < 0)
            {
369
                return;
370 371
            }

372 373 374
            spansToSelect.Add(span.Snapshot.CreateTrackingSpan(Span.FromBounds(positionOfStart, positionOfEnd + info.BlockCommentEndString.Length), SpanTrackingMode.EdgeExclusive));
            DeleteText(textChanges, new TextSpan(positionOfStart, info.BlockCommentStartString.Length));
            DeleteText(textChanges, new TextSpan(positionOfEnd, info.BlockCommentEndString.Length));
375 376
        }

377
        private bool TryUncommentSingleLineComments(CommentSelectionInfo info, SnapshotSpan span, List<TextChange> textChanges, List<ITrackingSpan> spansToSelect)
378 379 380
        {
            // First see if we're selecting any lines that have the single-line comment prefix.
            // If so, then we'll just remove the single-line comment prefix from those lines.
381 382 383
            var (firstLine, lastLine) = DetermineFirstAndLastLine(span);

            for (var lineNumber = firstLine.LineNumber; lineNumber <= lastLine.LineNumber; ++lineNumber)
384 385 386
            {
                var line = span.Snapshot.GetLineFromLineNumber(lineNumber);
                var lineText = line.GetText();
387
                if (lineText.Trim().StartsWith(info.SingleLineCommentString, StringComparison.Ordinal))
388
                {
389
                    DeleteText(textChanges, new TextSpan(line.Start.Position + lineText.IndexOf(info.SingleLineCommentString, StringComparison.Ordinal), info.SingleLineCommentString.Length));
390 391 392 393 394
                }
            }

            // If we made any changes, select the entirety of the lines we change, so that subsequent invocations will
            // affect the same lines.
395
            if (textChanges.Count == 0)
396
            {
397
                return false;
398
            }
399

400 401
            spansToSelect.Add(span.Snapshot.CreateTrackingSpan(Span.FromBounds(firstLine.Start.Position,
                                                                               lastLine.End.Position),
402 403
                                                               SpanTrackingMode.EdgeExclusive));
            return true;
404 405 406 407 408
        }

        /// <summary>
        /// Adds edits to comment out each non-blank line, at the given indent.
        /// </summary>
409 410
        private void ApplySingleLineCommentToNonBlankLines(
            CommentSelectionInfo info, List<TextChange> textChanges, ITextSnapshotLine firstLine, ITextSnapshotLine lastLine, int indentToCommentAt)
411
        {
412 413
            var snapshot = firstLine.Snapshot;
            for (var lineNumber = firstLine.LineNumber; lineNumber <= lastLine.LineNumber; ++lineNumber)
414
            {
415
                var line = snapshot.GetLineFromLineNumber(lineNumber);
416 417
                if (!line.IsEmptyOrWhitespace())
                {
418
                    InsertText(textChanges, line.Start + indentToCommentAt, info.SingleLineCommentString);
419 420 421 422 423
                }
            }
        }

        /// <summary> Given a set of lines, find the minimum indent of all of the non-blank, non-whitespace lines.</summary>
424 425
        private static int DetermineSmallestIndent(
            SnapshotSpan span, ITextSnapshotLine firstLine, ITextSnapshotLine lastLine)
426 427 428
        {
            // TODO: This breaks if you have mixed tabs/spaces, and/or tabsize != indentsize.
            var indentToCommentAt = int.MaxValue;
429
            for (var lineNumber = firstLine.LineNumber; lineNumber <= lastLine.LineNumber; ++lineNumber)
430 431 432 433 434 435 436 437 438 439 440 441 442
            {
                var line = span.Snapshot.GetLineFromLineNumber(lineNumber);
                var firstNonWhitespacePosition = line.GetFirstNonWhitespacePosition();
                var firstNonWhitespaceOnLine = firstNonWhitespacePosition.HasValue
                    ? firstNonWhitespacePosition.Value - line.Start
                    : int.MaxValue;
                indentToCommentAt = Math.Min(indentToCommentAt, firstNonWhitespaceOnLine);
            }

            return indentToCommentAt;
        }

        /// <summary>
443 444 445 446
        /// Given a span, find the first and last line that are part of the span.  NOTE: If the 
        /// span ends in column zero, we back up to the previous line, to handle the case where 
        /// the user used shift + down to select a bunch of lines.  They probably don't want the 
        /// last line commented in that case.
447
        /// </summary>
448
        private static (ITextSnapshotLine firstLine, ITextSnapshotLine lastLine) DetermineFirstAndLastLine(SnapshotSpan span)
449 450 451 452 453 454 455 456
        {
            var firstLine = span.Snapshot.GetLineFromPosition(span.Start.Position);
            var lastLine = span.Snapshot.GetLineFromPosition(span.End.Position);
            if (lastLine.Start == span.End.Position && !span.IsEmpty)
            {
                lastLine = lastLine.GetPreviousMatchingLine(_ => true);
            }

457
            return (firstLine, lastLine);
458 459 460 461 462 463 464 465 466
        }

        /// <summary>
        /// Returns true if the span includes all of the non-whitespace text on the first and last line.
        /// </summary>
        private static bool SpanIncludesAllTextOnIncludedLines(SnapshotSpan span)
        {
            var firstAndLastLine = DetermineFirstAndLastLine(span);

C
CyrusNajmabadi 已提交
467 468
            var firstNonWhitespacePosition = firstAndLastLine.firstLine.GetFirstNonWhitespacePosition();
            var lastNonWhitespacePosition = firstAndLastLine.lastLine.GetLastNonWhitespacePosition();
469 470 471 472 473 474 475 476 477 478

            var allOnFirst = !firstNonWhitespacePosition.HasValue ||
                              span.Start.Position <= firstNonWhitespacePosition.Value;
            var allOnLast = !lastNonWhitespacePosition.HasValue ||
                             span.End.Position > lastNonWhitespacePosition.Value;

            return allOnFirst && allOnLast;
        }
    }
}