|
// 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.ComponentModel.Composition;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Commanding;
using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Editor.Commanding.Commands;
using Microsoft.VisualStudio.Text.Operations;
using Microsoft.VisualStudio.Utilities;
namespace Microsoft.CodeAnalysis.Editor.Implementation.SplitComment;
[Export(typeof(ICommandHandler))]
[ContentType(ContentTypeNames.RoslynContentType)]
[Name(nameof(SplitCommentCommandHandler))]
[Order(After = PredefinedCompletionNames.CompletionCommandHandler)]
[method: ImportingConstructor]
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
internal sealed class SplitCommentCommandHandler(
ITextUndoHistoryRegistry undoHistoryRegistry,
IEditorOperationsFactoryService editorOperationsFactoryService,
EditorOptionsService editorOptionsService,
IIndentationManagerService indentationManager,
IGlobalOptionService globalOptions) : ICommandHandler<ReturnKeyCommandArgs>
{
private readonly ITextUndoHistoryRegistry _undoHistoryRegistry = undoHistoryRegistry;
private readonly IEditorOperationsFactoryService _editorOperationsFactoryService = editorOperationsFactoryService;
private readonly EditorOptionsService _editorOptionsService = editorOptionsService;
private readonly IIndentationManagerService _indentationManager = indentationManager;
private readonly IGlobalOptionService _globalOptions = globalOptions;
public string DisplayName => EditorFeaturesResources.Split_comment;
public CommandState GetCommandState(ReturnKeyCommandArgs args)
=> CommandState.Unspecified;
public bool ExecuteCommand(ReturnKeyCommandArgs args, CommandExecutionContext context)
{
var textView = args.TextView;
var subjectBuffer = args.SubjectBuffer;
var spans = textView.Selection.GetSnapshotSpansOnBuffer(subjectBuffer);
// Don't do anything special if there is multi-selection. It's not clear what sort of semantics that should have.
if (spans.Count != 1)
return false;
var snapshot = subjectBuffer.CurrentSnapshot;
var document = snapshot.GetOpenDocumentInCurrentContextWithChanges();
if (document == null)
return false;
if (!_globalOptions.GetOption(SplitCommentOptionsStorage.Enabled, document.Project.Language))
return false;
var splitCommentService = document.GetLanguageService<ISplitCommentService>();
if (splitCommentService == null)
return false;
// If there is a selection, ensure that it's all on one-line. It's not clear what sort of semantics we
// would want if this spanned multiple lines.
var selectionSpan = spans[0].Span;
var position = selectionSpan.Start;
var line = subjectBuffer.CurrentSnapshot.GetLineFromPosition(position);
var endLine = subjectBuffer.CurrentSnapshot.GetLineFromPosition(selectionSpan.End);
if (line.LineNumber != endLine.LineNumber)
return false;
// Quick check. If the line doesn't contain a comment in it before the caret,
// then no point in doing any more expensive synchronous work.
if (!LineProbablyContainsComment(splitCommentService, new SnapshotPoint(snapshot, position)))
return false;
using (context.OperationContext.AddScope(allowCancellation: true, EditorFeaturesResources.Split_comment))
{
var cancellationToken = context.OperationContext.UserCancellationToken;
var parsedDocument = ParsedDocument.CreateSynchronously(document, cancellationToken);
var result = SplitComment(parsedDocument, textView, subjectBuffer, new SnapshotSpan(snapshot, selectionSpan));
if (result == null)
return false;
using var transaction = CaretPreservingEditTransaction.TryCreate(
EditorFeaturesResources.Split_comment, textView, _undoHistoryRegistry, _editorOperationsFactoryService);
subjectBuffer.Replace(result.Value.replacementSpan, result.Value.replacementText);
transaction?.Complete();
return true;
}
}
private static bool LineProbablyContainsComment(ISplitCommentService service, SnapshotPoint position)
{
var commentStart = service.CommentStart;
var line = position.GetContainingLine();
for (var p = line.Start.Position; p < position; p++)
{
if (MatchesCommentStart(commentStart, line, p))
return true;
}
return false;
}
private static bool MatchesCommentStart(string commentStart, SnapshotPoint point)
=> MatchesCommentStart(commentStart, point.GetContainingLine(), point.Position);
private static bool MatchesCommentStart(string commentStart, ITextSnapshotLine line, int position)
{
if (position + commentStart.Length > line.End)
return false;
var snapshot = line.Snapshot;
for (var c = 0; c < commentStart.Length; c++)
{
if (snapshot[position + c] != commentStart[c])
return false;
}
return true;
}
private (Span replacementSpan, string replacementText)? SplitComment(
ParsedDocument document,
ITextView textView,
ITextBuffer textBuffer,
SnapshotSpan selectionSpan)
{
var syntaxKinds = document.LanguageServices.GetRequiredService<ISyntaxKindsService>();
var trivia = document.Root.FindTrivia(selectionSpan.Start);
if (syntaxKinds.SingleLineCommentTrivia != trivia.RawKind)
return null;
var splitCommentService = document.LanguageServices.GetRequiredService<ISplitCommentService>();
// if the user hits enter at `/$$/` we don't want to consider this a comment continuation.
if (selectionSpan.Start < (trivia.SpanStart + splitCommentService.CommentStart.Length))
return null;
if (!splitCommentService.IsAllowed(document.Root, trivia))
return null;
// If the user hits enter at: // goo $$ // bar
//
// we don't want to consider this a comment continuation. They likely were doing some text manipulations
// that put two comments on the same line, and really just want this to act like a normal enter.
if (IsFollowedByComment(selectionSpan.End, splitCommentService))
return null;
var textSnapshot = selectionSpan.Snapshot;
var triviaLine = textSnapshot.GetLineFromPosition(trivia.SpanStart);
var options = textBuffer.GetLineFormattingOptions(_editorOptionsService, explicitFormat: false);
var replacementSpan = GetReplacementSpan(triviaLine, selectionSpan);
var replacementText = GetReplacementText(textView, options, triviaLine, trivia, selectionSpan.Start);
return (replacementSpan, replacementText);
}
private static bool IsFollowedByComment(SnapshotPoint point, ISplitCommentService splitCommentService)
{
var line = point.GetContainingLine();
// skip past following whitespace.
while (point < line.End && char.IsWhiteSpace(point.GetChar()))
point += 1;
return MatchesCommentStart(splitCommentService.CommentStart, point);
}
private static string GetReplacementText(
ITextView textView, LineFormattingOptions options, ITextSnapshotLine triviaLine, SyntaxTrivia trivia, int position)
{
// We're inside a comment. Instead of inserting just a newline here, insert
// 1. a newline
// 2. spaces up to the indentation of the current comment
// 3. the comment prefix (extended out for repeated chars).
// Then, depending on if the current comment starts with whitespace or not, we will insert those same spaces
// to match.
var commentStartColumn = triviaLine.GetColumnFromLineOffset(trivia.SpanStart - triviaLine.Start, textView.Options);
var prefix = GetCommentPrefix(triviaLine.Snapshot, trivia, position);
var replacementText = options.NewLine +
commentStartColumn.CreateIndentationString(options.UseTabs, options.TabSize) +
prefix +
GetWhitespaceAfterCommentPrefix(trivia, triviaLine, prefix, position);
return replacementText;
}
private static string GetCommentPrefix(ITextSnapshot snapshot, SyntaxTrivia trivia, int position)
{
// Consume as many of the comment start character as we can. That way if someone has something like
// `//// $$Goo` then hitting enter will respect that the next line should start with `////`.
var triviaPrefixStart = trivia.SpanStart;
var triviaPrefixEnd = triviaPrefixStart;
var triviaStartChar = snapshot[trivia.SpanStart];
while (snapshot[triviaPrefixEnd] == triviaStartChar && triviaPrefixEnd < position)
triviaPrefixEnd++;
return snapshot.GetText(Span.FromBounds(triviaPrefixStart, triviaPrefixEnd));
}
private static string GetWhitespaceAfterCommentPrefix(SyntaxTrivia trivia, ITextSnapshotLine triviaLine, string commentPrefix, int position)
{
var startIndex = trivia.SpanStart + commentPrefix.Length;
var endIndex = startIndex;
while (endIndex < position && char.IsWhiteSpace(triviaLine.Snapshot[endIndex]))
endIndex++;
return triviaLine.Snapshot.GetText(Span.FromBounds(startIndex, endIndex));
}
private static Span GetReplacementSpan(ITextSnapshotLine triviaLine, Span selectionSpan)
{
var textSnapshot = triviaLine.Snapshot;
// When hitting enter in a comment consume the whitespace around the caret. That way the previous line
// doesn't have trailing whitespace, and the text following the caret is placed at the right location.
var replacementStart = selectionSpan.Start;
var replacementEnd = selectionSpan.End;
while (replacementStart > triviaLine.Start && textSnapshot[replacementStart - 1] == ' ')
replacementStart--;
while (replacementEnd < triviaLine.End && textSnapshot[replacementEnd] == ' ')
replacementEnd++;
var replacementSpan = Span.FromBounds(replacementStart, replacementEnd);
return replacementSpan;
}
}
|