/
githubmirror
/
roslyn
Обзор
Документация
Войти
/
githubmirror
/
roslyn
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/EditorFeatures/CSharp/StringCopyPaste/StringCopyPasteCommandHandler.cs
352 строки
18 KB
Joey Robichaud
Reapply "Move to .NET 10 Preview 5 (#78906)"
08 июл 2025, 07:36
08 июл 2025, 07:36
1e5af11
Код
Авторство
О чём код?
// 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; 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 sealed partial class StringCopyPasteCommandHandler( IThreadingContext threadingContext, ITextUndoHistoryRegistry undoHistoryRegistry, IEditorOperationsFactoryService editorOperationsFactoryService, IGlobalOptionService globalOptions, ITextBufferFactoryService2 textBufferFactoryService, EditorOptionsService editorOptionsService) : IChainedCommandHandler<CutCommandArgs>, IChainedCommandHandler<CopyCommandArgs>, IChainedCommandHandler<PasteCommandArgs> { private readonly IThreadingContext _threadingContext = threadingContext; private readonly ITextUndoHistoryRegistry _undoHistoryRegistry = undoHistoryRegistry; private readonly IEditorOperationsFactoryService _editorOperationsFactoryService = editorOperationsFactoryService; private readonly EditorOptionsService _editorOptionsService = editorOptionsService; 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; } } }