|
// 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.Immutable;
using System.ComponentModel.Composition;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Editor.StringCopyPaste;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Indentation;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.StringCopyPaste;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Commanding;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Editor.Commanding.Commands;
using Microsoft.VisualStudio.Text.Editor.OptionsExtensionMethods;
using Microsoft.VisualStudio.Text.Operations;
using Microsoft.VisualStudio.Utilities;
using Roslyn.Utilities;
namespace Microsoft.CodeAnalysis.Editor.CSharp.StringCopyPaste;
using static StringCopyPasteHelpers;
/// <summary>
/// Command handler that both tracks 'copy' commands within VS to see what text the user copied (and from where),
/// but also then handles pasting that text back in a sensible fashion (e.g. escaping/unescaping/wrapping/indenting)
/// inside a string-literal. Can also handle pasting code from unknown sources as well, though heuristics must be
/// applied in that case to make a best effort guess as to what the original text meant and how to preserve that
/// in the final context.
/// </summary>
/// <remarks>
/// Because we are revising what the normal editor does, we follow the standard behavior of first allowing the
/// editor to process paste commands, and then adding our own changes as an edit after that. That way if the user
/// doesn't want the change we made, they can always undo to get the prior paste behavior.
/// </remarks>
[Export(typeof(ICommandHandler))]
[ContentType(ContentTypeNames.CSharpContentType)]
[Name(PredefinedCommandHandlerNames.StringCopyPaste)]
[Order(After = PredefinedCommandHandlerNames.FormatDocument)]
[method: ImportingConstructor]
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
internal partial class StringCopyPasteCommandHandler(
IThreadingContext threadingContext,
ITextUndoHistoryRegistry undoHistoryRegistry,
IEditorOperationsFactoryService editorOperationsFactoryService,
IGlobalOptionService globalOptions,
ITextBufferFactoryService2 textBufferFactoryService,
EditorOptionsService editorOptionsService,
IIndentationManagerService indentationManager) :
IChainedCommandHandler<CutCommandArgs>,
IChainedCommandHandler<CopyCommandArgs>,
IChainedCommandHandler<PasteCommandArgs>
{
private const string CopyId = "RoslynStringCopyPasteId";
private readonly IThreadingContext _threadingContext = threadingContext;
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;
private readonly ITextBufferFactoryService2 _textBufferFactoryService = textBufferFactoryService;
public string DisplayName => nameof(StringCopyPasteCommandHandler);
public CommandState GetCommandState(PasteCommandArgs args, Func<CommandState> nextCommandHandler)
=> nextCommandHandler();
public void ExecuteCommand(PasteCommandArgs args, Action nextCommandHandler, CommandExecutionContext executionContext)
{
Contract.ThrowIfFalse(_threadingContext.HasMainThread);
var textView = args.TextView;
var subjectBuffer = args.SubjectBuffer;
var selectionsBeforePaste = textView.Selection.GetSnapshotSpansOnBuffer(subjectBuffer);
var snapshotBeforePaste = subjectBuffer.CurrentSnapshot;
// Always let the real paste go through. That way we always have a version of the document that doesn't
// include our changes that we can undo back to.
nextCommandHandler();
// If we don't even see any changes from the paste, there's nothing we can do.
if (snapshotBeforePaste.Version.Changes is null)
return;
// If the user has the option off, then don't bother doing anything once we've sent the paste through.
if (!_globalOptions.GetOption(StringCopyPasteOptionsStorage.AutomaticallyFixStringContentsOnPaste, LanguageNames.CSharp))
return;
// if we're not even sure where the user caret/selection is on this buffer, we can't proceed.
if (selectionsBeforePaste.Count == 0)
return;
var snapshotAfterPaste = subjectBuffer.CurrentSnapshot;
// If there were multiple changes that already happened, then don't make any changes. Some other component
// already did something advanced.
if (snapshotAfterPaste.Version != snapshotBeforePaste.Version.Next)
return;
// Have to even be in a C# doc to be able to have special space processing here.
var documentBeforePaste = snapshotBeforePaste.GetOpenDocumentInCurrentContextWithChanges();
var documentAfterPaste = snapshotAfterPaste.GetOpenDocumentInCurrentContextWithChanges();
if (documentBeforePaste == null || documentAfterPaste == null)
return;
var cancellationToken = executionContext.OperationContext.UserCancellationToken;
var parsedDocumentBeforePaste = ParsedDocument.CreateSynchronously(documentBeforePaste, cancellationToken);
// When pasting, only do anything special if the user selections were entirely inside a single string
// token/expression. Otherwise, we have a multi-selection across token kinds which will be extremely
// complex to try to reconcile.
var stringExpressionBeforePaste = TryGetCompatibleContainingStringExpression(parsedDocumentBeforePaste, selectionsBeforePaste);
if (stringExpressionBeforePaste == null)
return;
// Also ensure that all the changes the editor actually applied were inside a single string
// token/expression. If the editor decided to make changes outside of the string, we definitely do not want
// to do anything here.
var stringExpressionBeforePasteFromChanges = TryGetCompatibleContainingStringExpression(
parsedDocumentBeforePaste, new NormalizedSnapshotSpanCollection(snapshotBeforePaste, snapshotBeforePaste.Version.Changes.Select(c => c.OldSpan)));
if (stringExpressionBeforePaste != stringExpressionBeforePasteFromChanges)
return;
var textChanges = GetEdits(cancellationToken);
// If we didn't get any viable changes back, don't do anything.
if (textChanges.IsDefaultOrEmpty)
return;
var newTextAfterChanges = snapshotBeforePaste.AsText().WithChanges(textChanges);
// If we end up making the same changes as what the paste did, then no need to proceed.
if (ContentsAreSame(snapshotBeforePaste, snapshotAfterPaste, stringExpressionBeforePaste, newTextAfterChanges))
return;
// Create two edits to make the change. The first restores the buffer to the original snapshot (effectively
// undoing the first set of changes). Then the second actually applies the change.
//
// Do this as direct edits, passing 'EditOptions.None' for the options, as we want to control the edits
// precisely and don't want any strange interpretation of where the caret should end up. Other options
// (like DefaultMinimalChange) will attempt to diff/merge edits oddly sometimes which can lead the caret
// ending up before/after some merged change, which will no longer match the behavior of precise pastes.
//
// Wrap this all as a transaction so that these two edits appear to be one single change. This also allows
// the user to do a single 'undo' that gets them back to the original paste made at the start of this
// method.
using var transaction = new CaretPreservingEditTransaction(
CSharpEditorResources.Fixing_string_literal_after_paste,
textView, _undoHistoryRegistry, _editorOperationsFactoryService);
{
var edit = subjectBuffer.CreateEdit(EditOptions.None, reiteratedVersionNumber: null, editTag: null);
foreach (var change in snapshotBeforePaste.Version.Changes)
edit.Replace(change.NewSpan, change.OldText);
edit.Apply();
}
{
var edit = subjectBuffer.CreateEdit(EditOptions.None, reiteratedVersionNumber: null, editTag: null);
foreach (var selection in selectionsBeforePaste)
edit.Replace(selection.Span, "");
foreach (var change in textChanges)
edit.Replace(change.Span.ToSpan(), change.NewText);
edit.Apply();
}
transaction.Complete();
return;
ImmutableArray<TextChange> GetEdits(CancellationToken cancellationToken)
{
var newLine = textView.Options.GetNewLineCharacter();
var indentationWhitespace = DetermineIndentationWhitespace(
parsedDocumentBeforePaste, subjectBuffer, snapshotBeforePaste.AsText(), stringExpressionBeforePaste, documentBeforePaste.Project.GetFallbackAnalyzerOptions(), cancellationToken);
// See if this is a paste of the last copy that we heard about.
var edits = TryGetEditsFromKnownCopySource(newLine, indentationWhitespace);
if (!edits.IsDefaultOrEmpty)
return edits;
var pasteWasSuccessful = PasteWasSuccessful(
snapshotBeforePaste, snapshotAfterPaste, documentAfterPaste, stringExpressionBeforePaste, cancellationToken);
// If not, then just go through the fallback code path that applies more heuristics.
var unknownPasteProcessor = new UnknownSourcePasteProcessor(
newLine, indentationWhitespace,
snapshotBeforePaste, snapshotAfterPaste,
documentBeforePaste, documentAfterPaste,
stringExpressionBeforePaste, pasteWasSuccessful);
return unknownPasteProcessor.GetEdits();
}
ImmutableArray<TextChange> TryGetEditsFromKnownCopySource(
string newLine, string indentationWhitespace)
{
// For simplicity, we only support smart copy/paste when we are pasting into a single contiguous region.
if (selectionsBeforePaste.Count != 1)
return default;
var copyPasteService = documentBeforePaste.Project.Solution.Services.GetRequiredService<IStringCopyPasteService>();
var clipboardData = copyPasteService.TryGetClipboardData(KeyAndVersion);
var copyPasteData = StringCopyPasteData.FromJson(clipboardData);
if (copyPasteData == null)
return default;
var knownProcessor = new KnownSourcePasteProcessor(
newLine, indentationWhitespace,
snapshotBeforePaste, snapshotAfterPaste,
documentBeforePaste, documentAfterPaste,
stringExpressionBeforePaste,
selectionsBeforePaste[0].Span.ToTextSpan(),
copyPasteData, _textBufferFactoryService);
return knownProcessor.GetEdits();
}
}
private string DetermineIndentationWhitespace(
ParsedDocument documentBeforePaste,
ITextBuffer textBuffer,
SourceText textBeforePaste,
ExpressionSyntax stringExpressionBeforePaste,
StructuredAnalyzerConfigOptions fallbackOptions,
CancellationToken cancellationToken)
{
// Only raw strings care about indentation. Don't bother computing if we don't need it.
if (!IsAnyRawStringExpression(stringExpressionBeforePaste))
return "";
if (IsAnyMultiLineRawStringExpression(stringExpressionBeforePaste))
{
// already have a multi-line raw string. The indentation of it's end delimiter is the indentation all
// lines within it should have.
var lastLine = textBeforePaste.Lines.GetLineFromPosition(stringExpressionBeforePaste.Span.End);
var quotePosition = lastLine.GetFirstNonWhitespacePosition()!.Value;
return textBeforePaste.ToString(TextSpan.FromBounds(lastLine.Span.Start, quotePosition));
}
// Otherwise, we have a single-line raw string. Determine the default indentation desired here.
// We'll use that if we have to convert this single-line raw string to a multi-line one.
var indentationOptions = textBuffer.GetIndentationOptions(_editorOptionsService, fallbackOptions, documentBeforePaste.LanguageServices, explicitFormat: false);
return stringExpressionBeforePaste.GetFirstToken().GetPreferredIndentation(documentBeforePaste, indentationOptions, cancellationToken);
}
/// <summary>
/// Returns true if the paste resulted in legal code for the string literal. The string literal is
/// considered legal if it has the same span as the original string (adjusted as per the edit) and that
/// there are no errors in it. For this purposes of this check, errors in interpolation holes are not
/// considered. We only care about the textual content of the string.
/// </summary>
internal static bool PasteWasSuccessful(
ITextSnapshot snapshotBeforePaste,
ITextSnapshot snapshotAfterPaste,
Document documentAfterPaste,
ExpressionSyntax stringExpressionBeforePaste,
CancellationToken cancellationToken)
{
var rootAfterPaste = documentAfterPaste.GetRequiredSyntaxRootSynchronously(cancellationToken);
var stringExpressionAfterPaste = FindContainingSupportedStringExpression(rootAfterPaste, stringExpressionBeforePaste.SpanStart);
if (stringExpressionAfterPaste == null)
return false;
if (ContainsError(stringExpressionAfterPaste))
return false;
var spanAfterPaste = MapSpan(stringExpressionBeforePaste.Span, snapshotBeforePaste, snapshotAfterPaste);
return spanAfterPaste == stringExpressionAfterPaste.Span;
}
/// <summary>
/// Given the snapshots before/after pasting, and the source-text our manual fixup edits produced, see if our
/// manual application actually produced the same results as the paste. If so, we don't need to actually do
/// anything. To optimize this check, we pass in the original string expression as that's all we have to check
/// (adjusting for where it now ends up) in both the 'after' documents.
/// </summary>
private static bool ContentsAreSame(
ITextSnapshot snapshotBeforePaste,
ITextSnapshot snapshotAfterPaste,
ExpressionSyntax stringExpressionBeforePaste,
SourceText newTextAfterChanges)
{
// We ended up with documents of different length after we escaped/manipulated the pasted text. So the
// contents are definitely not the same.
if (newTextAfterChanges.Length != snapshotAfterPaste.Length)
return false;
var spanAfterPaste = MapSpan(stringExpressionBeforePaste.Span, snapshotBeforePaste, snapshotAfterPaste);
var originalStringContentsAfterPaste = snapshotAfterPaste.AsText().GetSubText(spanAfterPaste);
var newStringContentsAfterEdit = newTextAfterChanges.GetSubText(spanAfterPaste);
return originalStringContentsAfterPaste.ContentEquals(newStringContentsAfterEdit);
}
/// <summary>
/// Returns the <see cref="LiteralExpressionSyntax"/> or <see cref="InterpolatedStringExpressionSyntax"/> if the
/// selections were all contained within a single literal in a compatible fashion. This means all the
/// selections have to start/end in a content-span portion of the literal. For example, if we paste into an
/// interpolated string and have half of the selection outside an interpolation and half inside, we don't do
/// anything special as trying to correct in this scenario is too difficult.
/// </summary>
private static ExpressionSyntax? TryGetCompatibleContainingStringExpression(
ParsedDocument document, NormalizedSnapshotSpanCollection spans)
{
if (spans.Count == 0)
return null;
var snapshot = spans[0].Snapshot;
// First, try to see if all the selections are at least contained within a single string literal expression.
var stringExpression = FindCommonContainingStringExpression(document.Root, spans);
if (stringExpression == null)
return null;
// Now, given that string expression, find the inside 'text' spans of the expression. These are the parts
// of the literal between the quotes. It does not include the interpolation holes in an interpolated
// string. These spans may be empty (for an empty string, or empty text gap between interpolations).
var contentSpans = StringInfo.GetStringInfo(snapshot.AsText(), stringExpression).ContentSpans;
foreach (var snapshotSpan in spans)
{
var startIndex = contentSpans.BinarySearch(snapshotSpan.Span.Start, FindIndex);
var endIndex = contentSpans.BinarySearch(snapshotSpan.Span.End, FindIndex);
if (startIndex < 0 || endIndex < 0)
return null;
}
return stringExpression;
static int FindIndex(TextSpan span, int position)
{
if (span.IntersectsWith(position))
return 0;
if (span.End < position)
return -1;
return 1;
}
}
}
|