|
// 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.Linq;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Collections;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Text;
using Roslyn.Utilities;
namespace Microsoft.CodeAnalysis.Editor.CSharp.StringCopyPaste;
using static StringCopyPasteHelpers;
/// <summary>
/// Paste processor responsible for determining how text should be treated if it came from a source outside of the
/// editor we're in. In that case, we don't know what any particular piece of text means. For example, <c>\t</c>
/// might be a tab or it could be the literal two characters <c>\</c> and <c>t</c>.
/// </summary>
internal sealed class UnknownSourcePasteProcessor(
string newLine,
string indentationWhitespace,
ITextSnapshot snapshotBeforePaste,
ITextSnapshot snapshotAfterPaste,
Document documentBeforePaste,
Document documentAfterPaste,
ExpressionSyntax stringExpressionBeforePaste,
bool pasteWasSuccessful) : AbstractPasteProcessor(newLine, indentationWhitespace, snapshotBeforePaste, snapshotAfterPaste, documentBeforePaste, documentAfterPaste, stringExpressionBeforePaste)
{
/// <summary>
/// Whether or not the string expression remained successfully parseable after the paste. <see
/// cref="StringCopyPasteCommandHandler.PasteWasSuccessful"/>. If it can still be successfully parsed subclasses
/// can adjust their view on which pieces of content need to be escaped or not.
/// </summary>
private readonly bool _pasteWasSuccessful = pasteWasSuccessful;
public override ImmutableArray<TextChange> GetEdits()
{
// If we have a raw-string, then we always want to check for changes to make, even if the paste was
// technically legal. This is because we may want to touch up things like indentation to make the
// pasted text look good for raw strings.
//
// Check for certain things we always think we should escape.
if (!IsAnyRawStringExpression(StringExpressionBeforePaste) && !ShouldAlwaysEscapeTextForNonRawString())
{
// If the pasting was successful, then no need to change anything.
if (_pasteWasSuccessful)
return default;
}
// Ok, the user pasted text that couldn't cleanly be added to this token without issue. Repaste the
// contents, but this time properly escaped/manipulated so that it follows the rule of the particular token
// kind.
// For pastes into non-raw strings, we can just determine how the change should be escaped in-line at that
// same location the paste originally happened at. For raw-strings things get more complex as we have to
// deal with things like indentation and potentially adding newlines to make things legal.
return IsAnyRawStringExpression(StringExpressionBeforePaste)
? GetEditsForRawString()
: GetEditsForNonRawString();
}
private string EscapeForNonRawStringLiteral(string value)
=> EscapeForNonRawStringLiteral_DoNotCallDirectly(
IsVerbatimStringExpression(StringExpressionBeforePaste),
StringExpressionBeforePaste is InterpolatedStringExpressionSyntax,
// We do not want to try skipping escapes in the 'value'. We don't know where it came from, and if it
// had some escapes in it, it's probably a good idea to remove to keep the final pasted text clean.
trySkipExistingEscapes: true,
value);
private bool ShouldAlwaysEscapeTextForNonRawString()
{
// Pasting a control character into a normal string literal is normally not desired. So even if this
// is legal, we still escape the contents to make the pasted code clear.
return !IsVerbatimStringExpression(StringExpressionBeforePaste) && ContainsControlCharacter(Changes);
}
private ImmutableArray<TextChange> GetEditsForNonRawString()
{
using var textChanges = TemporaryArray<TextChange>.Empty;
foreach (var change in Changes)
{
// We're pasting from an unknown source. If we see a viable escape in that source treat it as an escape
// instead of escaping it one more time upon paste.
textChanges.Add(new TextChange(
change.OldSpan.ToTextSpan(),
EscapeForNonRawStringLiteral(change.NewText)));
}
return textChanges.ToImmutableAndClear();
}
private ImmutableArray<TextChange> GetEditsForRawString()
{
// Can't really figure anything out if the raw string is in error.
if (NodeOrTokenContainsError(StringExpressionBeforePaste))
return default;
// If all we're going to do is insert whitespace, then don't make any adjustments to the text. We don't want
// to end up inserting nothing and having the user very confused why their paste did nothing.
if (AllWhitespace(SnapshotBeforePaste.Version.Changes))
return default;
// if the content we're going to add itself contains quotes, then figure out how many start/end quotes the
// final string literal will need (which also gives us the number of quotes to add to the start/end).
//
// note: we don't have to do this if the paste was successful. Instead, we'll just process the contents,
// adjusting whitespace below.
var quotesToAdd = _pasteWasSuccessful ? null : GetQuotesToAddToRawString();
var dollarSignsToAdd = _pasteWasSuccessful ? null : GetDollarSignsToAddToRawString();
using var _ = ArrayBuilder<TextChange>.GetInstance(out var edits);
// First, add any extra dollar signs needed.
if (dollarSignsToAdd != null)
edits.Add(new TextChange(new TextSpan(StringExpressionBeforePaste.Span.Start, 0), dollarSignsToAdd));
// Then any quotes to the start delimiter.
if (quotesToAdd != null)
edits.Add(new TextChange(new TextSpan(StringExpressionBeforePasteInfo.ContentSpans.First().Start, 0), quotesToAdd));
// Then add the actual changes in the content.
if (IsAnyMultiLineRawStringExpression(StringExpressionBeforePaste))
AdjustWhitespaceAndAddTextChangesForMultiLineRawStringLiteral(edits);
else
AdjustWhitespaceAndAddTextChangesForSingleLineRawStringLiteral(edits);
// Then any extra quotes to the end delimiter.
if (quotesToAdd != null)
edits.Add(new TextChange(new TextSpan(StringExpressionBeforePasteInfo.EndDelimiterSpanWithoutSuffix.End, 0), quotesToAdd));
return edits.ToImmutableAndClear();
}
/// <inheritdoc cref="AbstractPasteProcessor.GetQuotesToAddToRawString(SourceText, ImmutableArray{TextSpan})" />
private string? GetQuotesToAddToRawString()
=> GetQuotesToAddToRawString(TextAfterPaste, TextContentsSpansAfterPaste);
/// <inheritdoc cref="AbstractPasteProcessor.GetDollarSignsToAddToRawString(SourceText, ImmutableArray{TextSpan})" />
private string? GetDollarSignsToAddToRawString()
=> GetDollarSignsToAddToRawString(TextAfterPaste, TextContentsSpansAfterPaste);
// Pasting with single line case.
private void AdjustWhitespaceAndAddTextChangesForSingleLineRawStringLiteral(ArrayBuilder<TextChange> edits)
{
// When pasting into a single-line raw literal we will keep it a single line if we can. If the content
// we're pasting starts/ends with a quote, or contains a newline, then we have to convert to a multiline.
//
// Pasting any other content into a single-line raw literal is always legal and needs no extra work on our
// part.
var mustBeMultiLine = RawContentMustBeMultiLine(TextAfterPaste, TextContentsSpansAfterPaste);
using var _ = PooledStringBuilder.GetInstance(out var buffer);
// A newline and the indentation to start with.
if (mustBeMultiLine)
edits.Add(new TextChange(new TextSpan(StringExpressionBeforePasteInfo.StartDelimiterSpan.End, 0), NewLine + IndentationWhitespace));
SourceText? textOfCurrentChange = null;
var commonIndentationPrefix = GetCommonIndentationPrefix(Changes) ?? "";
foreach (var change in Changes)
{
// Create a text object around the change text we're making. This is a very simple way to get
// a nice view of the text lines in the change.
textOfCurrentChange = SourceText.From(change.NewText);
buffer.Clear();
for (var i = 0; i < textOfCurrentChange.Lines.Count; i++)
{
// The actual full line that was pasted in.
var currentChangeLine = textOfCurrentChange.Lines[i];
var fullChangeLineText = textOfCurrentChange.ToString(currentChangeLine.SpanIncludingLineBreak);
if (i == 0)
{
// on the first line, remove the common indentation if we can. Otherwise leave alone.
if (fullChangeLineText.StartsWith(commonIndentationPrefix, StringComparison.OrdinalIgnoreCase))
buffer.Append(fullChangeLineText[commonIndentationPrefix.Length..]);
else
buffer.Append(fullChangeLineText);
}
else
{
// on all the rest of the lines, always remove the common indentation.
buffer.Append(fullChangeLineText[commonIndentationPrefix.Length..]);
}
// if we ended with a newline, make sure the next line is indented enough.
if (HasNewLine(currentChangeLine))
buffer.Append(IndentationWhitespace);
}
edits.Add(new TextChange(change.OldSpan.ToTextSpan(), buffer.ToString()));
}
// if the last change ended at the closing delimiter *and* ended with a newline, then we don't need to add a
// final newline-space at the end because we will have already done that.
if (mustBeMultiLine && !LastPastedLineAddedNewLine())
edits.Add(new TextChange(new TextSpan(StringExpressionBeforePasteInfo.EndDelimiterSpan.Start, 0), NewLine + IndentationWhitespace));
return;
bool LastPastedLineAddedNewLine()
{
return textOfCurrentChange != null &&
Changes.Last().OldEnd == StringExpressionBeforePasteInfo.ContentSpans.Last().End &&
HasNewLine(textOfCurrentChange.Lines.Last());
}
}
// Pasting into multi line case.
private void AdjustWhitespaceAndAddTextChangesForMultiLineRawStringLiteral(
ArrayBuilder<TextChange> edits)
{
var endLine = TextBeforePaste.Lines.GetLineFromPosition(StringExpressionBeforePaste.Span.End);
// The indentation whitespace every line of the final raw string needs.
var indentationWhitespace = endLine.GetLeadingWhitespace();
using var _ = PooledStringBuilder.GetInstance(out var buffer);
var commonIndentationPrefix = GetCommonIndentationPrefix(Changes);
for (int changeIndex = 0, lastChangeIndex = Changes.Count; changeIndex < lastChangeIndex; changeIndex++)
{
var change = Changes[changeIndex];
// Create a text object around the change text we're making. This is a very simple way to get
// a nice view of the text lines in the change.
var textOfCurrentChange = SourceText.From(change.NewText);
buffer.Clear();
for (int lineIndex = 0, lastLineIndex = textOfCurrentChange.Lines.Count; lineIndex < lastLineIndex; lineIndex++)
{
var firstChange = changeIndex == 0 && lineIndex == 0;
var lastChange = (changeIndex == lastChangeIndex - 1) && (lineIndex == lastLineIndex - 1);
// The actual full line that was pasted in.
var currentChangeLine = textOfCurrentChange.Lines[lineIndex];
var fullChangeLineText = textOfCurrentChange.ToString(currentChangeLine.SpanIncludingLineBreak);
// The contents of the line, with all leading whitespace removed.
var (lineLeadingWhitespace, lineWithoutLeadingWhitespace) = ExtractWhitespace(fullChangeLineText);
// This entire if-chain is only concerned with inserting the necessary whitespace a line should have.
if (firstChange)
{
// The first line is often special. It may be copied without any whitespace (e.g. the user
// starts their selection at the start of text on that line, not the start of the line itself).
// So we use some heuristics to try to decide what to do depending on how much whitespace we see
// on that first copied line.
TextBeforePaste.GetLineAndOffset(change.OldSpan.Start, out var line, out var offset);
// First, ensure that the indentation whitespace of the *inserted* first line is sufficient.
if (line == TextBeforePaste.Lines.GetLineFromPosition(StringExpressionBeforePaste.SpanStart).LineNumber)
{
// if the first chunk was pasted into the space after the first `"""` then we need to actually
// insert a newline, then the indentation whitespace, then the first line of the change.
buffer.Append(NewLine);
buffer.Append(indentationWhitespace);
}
else if (offset < indentationWhitespace.Length)
{
// On the first line, we were pasting into the indentation whitespace. Ensure we add enough
// whitespace so that the trimmed line starts at an acceptable position.
buffer.Append(indentationWhitespace[offset..]);
}
// Now, we want to actually insert any whitespace the line itself should have *if* it's got more
// than the common indentation whitespace.
if (commonIndentationPrefix != null && lineLeadingWhitespace.StartsWith(commonIndentationPrefix, StringComparison.OrdinalIgnoreCase))
buffer.Append(lineLeadingWhitespace[commonIndentationPrefix.Length..]);
}
else if (!lastChange && lineWithoutLeadingWhitespace.Length > 0 && SyntaxFacts.IsNewLine(lineWithoutLeadingWhitespace[0]))
{
// if it's just an empty line, don't bother adding any whitespace at all. This will just end up
// inserting the blank line here. We don't do this on the last line as we want to still insert
// the right amount of indentation so that the user's caret is placed properly in the text. We
// could technically not insert the whitespace and attempt to place the caret using a virtual
// position, but this adds a lot of complexity to this system, so we avoid that for now and go
// for the simpler approach..
}
else
{
// On any other line we're adding, ensure we have enough indentation whitespace to proceed.
// Add the necessary whitespace the literal needs, then add the line contents without
// the common whitespace included.
buffer.Append(indentationWhitespace);
if (commonIndentationPrefix != null)
buffer.Append(lineLeadingWhitespace[commonIndentationPrefix.Length..]);
}
// After the necessary whitespace has been added, add the actual non-whitespace contents of the
// line.
buffer.Append(lineWithoutLeadingWhitespace);
if (lastChange)
{
// Similar to the check we do for the first-change, if the last change was pasted into the space
// before the last `"""` then we need potentially insert a newline, then enough indentation
// whitespace to keep delimiter in the right location.
TextBeforePaste.GetLineAndOffset(change.OldSpan.End, out var line, out var offset);
if (line == TextBeforePaste.Lines.GetLineFromPosition(StringExpressionBeforePaste.Span.End).LineNumber)
{
if (!HasNewLine(currentChangeLine))
buffer.Append(NewLine);
buffer.Append(TextBeforePaste.ToString(new TextSpan(TextBeforePaste.Lines[line].Start, offset)));
}
}
}
edits.Add(new TextChange(change.OldSpan.ToTextSpan(), buffer.ToString()));
}
}
}
|