Skip to content

Commit 0c6d00d

Browse files
Convert single-line raw strings into multi-line on pressing <enter> (#75648)
2 parents c9dd27c + c7b24ec commit 0c6d00d

File tree

2 files changed

+415
-89
lines changed

2 files changed

+415
-89
lines changed

src/EditorFeatures/CSharp/RawStringLiteral/RawStringLiteralCommandHandler_Return.cs

Lines changed: 176 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Linq;
66
using System.Threading;
77
using Microsoft.CodeAnalysis.CSharp;
8+
using Microsoft.CodeAnalysis.CSharp.Syntax;
89
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
910
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
1011
using Microsoft.CodeAnalysis.Indentation;
@@ -50,74 +51,197 @@ public bool ExecuteCommand(ReturnKeyCommandArgs args, CommandExecutionContext co
5051
if (position >= currentSnapshot.Length)
5152
return false;
5253

53-
if (currentSnapshot[position] != '"')
54+
var cancellationToken = context.OperationContext.UserCancellationToken;
55+
56+
var document = currentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
57+
if (document == null)
5458
return false;
5559

56-
var quotesBefore = 0;
57-
var quotesAfter = 0;
60+
return currentSnapshot[position] == '"'
61+
? ExecuteReturnCommandBeforeQuoteCharacter()
62+
: ExecuteReturnCommandNotBeforeQuoteCharacter();
5863

59-
for (int i = position, n = currentSnapshot.Length; i < n; i++)
64+
bool ExecuteReturnCommandBeforeQuoteCharacter()
6065
{
61-
if (currentSnapshot[i] != '"')
62-
break;
63-
64-
quotesAfter++;
66+
var quotesBefore = 0;
67+
var quotesAfter = 0;
68+
69+
// Ensure we're in between a balanced set of quotes, with at least 3 quotes on each side.
70+
71+
var currentSnapshot = subjectBuffer.CurrentSnapshot;
72+
for (int i = position, n = currentSnapshot.Length; i < n; i++)
73+
{
74+
if (currentSnapshot[i] != '"')
75+
break;
76+
77+
quotesAfter++;
78+
}
79+
80+
for (var i = position - 1; i >= 0; i--)
81+
{
82+
if (currentSnapshot[i] != '"')
83+
break;
84+
85+
quotesBefore++;
86+
}
87+
88+
// We support two cases here. Something simple like `"""$$"""`. In this case, we have to be hitting enter
89+
// inside balanced quotes. But we also support `"""goo$$"""`. In this case it's ok if quotes are not
90+
// balanced. We're going to go through the non-empty path involving adding multiple newlines to the final
91+
// text.
92+
93+
var isEmpty = quotesBefore > 0;
94+
if (isEmpty && quotesAfter != quotesBefore)
95+
return false;
96+
97+
if (quotesAfter < 3)
98+
return false;
99+
100+
// Looks promising based on text alone. Now ensure we're actually on a raw string token/expression.
101+
var parsedDocument = ParsedDocument.CreateSynchronously(document, cancellationToken);
102+
103+
var token = parsedDocument.Root.FindToken(position);
104+
if (token.Kind() is not (SyntaxKind.SingleLineRawStringLiteralToken or
105+
SyntaxKind.MultiLineRawStringLiteralToken or
106+
SyntaxKind.InterpolatedSingleLineRawStringStartToken or
107+
SyntaxKind.InterpolatedMultiLineRawStringStartToken) ||
108+
token.Parent is not ExpressionSyntax expression)
109+
{
110+
return false;
111+
}
112+
113+
return MakeEdit(parsedDocument, expression, isEmpty);
65114
}
66115

67-
for (var i = position - 1; i >= 0; i--)
116+
bool ExecuteReturnCommandNotBeforeQuoteCharacter()
68117
{
69-
if (currentSnapshot[i] != '"')
70-
break;
71-
72-
quotesBefore++;
118+
// If the caret is not on a quote, we need to find whether we are within the contents of a single-line raw
119+
// string literal but not inside an interpolation. If we are inside a raw string literal and the caret is not on
120+
// top of a quote, it is part of the literal's text. Here we try to ensure that the literal's closing quotes are
121+
// properly placed in their own line We could reach this point after pressing enter within a single-line raw
122+
// string
123+
124+
var parsedDocument = ParsedDocument.CreateSynchronously(document, cancellationToken);
125+
126+
var token = parsedDocument.Root.FindToken(position);
127+
ExpressionSyntax expression;
128+
switch (token.Kind())
129+
{
130+
case SyntaxKind.SingleLineRawStringLiteralToken when token.Parent is ExpressionSyntax parentExpression:
131+
expression = parentExpression;
132+
break;
133+
134+
case SyntaxKind.InterpolatedStringTextToken:
135+
case SyntaxKind.OpenBraceToken:
136+
if (token is not
137+
{
138+
Parent.Parent: InterpolatedStringExpressionSyntax
139+
{
140+
StringStartToken.RawKind: (int)SyntaxKind.InterpolatedSingleLineRawStringStartToken,
141+
} interpolatedStringExpression,
142+
})
143+
{
144+
return false;
145+
}
146+
147+
if (token.Kind() is SyntaxKind.OpenBraceToken && position != token.SpanStart)
148+
return false;
149+
150+
expression = interpolatedStringExpression;
151+
break;
152+
153+
default:
154+
return false;
155+
}
156+
157+
return MakeEdit(parsedDocument, expression, isEmpty: false);
73158
}
74159

75-
if (quotesAfter != quotesBefore)
76-
return false;
77-
78-
if (quotesAfter < 3)
79-
return false;
80-
81-
return SplitRawString(textView, subjectBuffer, span.Start.Position, CancellationToken.None);
82-
}
83-
84-
private bool SplitRawString(ITextView textView, ITextBuffer subjectBuffer, int position, CancellationToken cancellationToken)
85-
{
86-
var document = subjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges();
87-
if (document == null)
88-
return false;
89-
90-
var parsedDocument = ParsedDocument.CreateSynchronously(document, cancellationToken);
91-
92-
var token = parsedDocument.Root.FindToken(position);
93-
if (token.Kind() is not (SyntaxKind.SingleLineRawStringLiteralToken or
94-
SyntaxKind.MultiLineRawStringLiteralToken or
95-
SyntaxKind.InterpolatedSingleLineRawStringStartToken or
96-
SyntaxKind.InterpolatedMultiLineRawStringStartToken))
160+
bool MakeEdit(
161+
ParsedDocument parsedDocument,
162+
ExpressionSyntax expression,
163+
bool isEmpty)
97164
{
98-
return false;
165+
var project = document.Project;
166+
var indentationOptions = subjectBuffer.GetIndentationOptions(_editorOptionsService, project.GetFallbackAnalyzerOptions(), project.Services, explicitFormat: false);
167+
var indentation = expression.GetFirstToken().GetPreferredIndentation(parsedDocument, indentationOptions, cancellationToken);
168+
169+
var newLine = indentationOptions.FormattingOptions.NewLine;
170+
171+
using var transaction = CaretPreservingEditTransaction.TryCreate(
172+
CSharpEditorResources.Split_raw_string, textView, _undoHistoryRegistry, _editorOperationsFactoryService);
173+
var edit = subjectBuffer.CreateEdit();
174+
175+
if (isEmpty)
176+
{
177+
// If the literal is empty, we just want to help the user transform it into a multiline raw string
178+
// literal with the extra empty newline between the delimiters to place the caret at
179+
edit.Insert(position, newLine + newLine + indentation);
180+
181+
var finalSnapshot = edit.Apply();
182+
183+
// move caret to the right location in virtual space for the blank line we added.
184+
var lineInNewSnapshot = finalSnapshot.GetLineFromPosition(position);
185+
var nextLine = finalSnapshot.GetLineFromLineNumber(lineInNewSnapshot.LineNumber + 1);
186+
textView.Caret.MoveTo(new VirtualSnapshotPoint(nextLine, indentation.Length));
187+
}
188+
else
189+
{
190+
// Otherwise, we're starting with a raw string that has content in it. That's something like:
191+
// """GooBar""". If we hit enter at the `G` we only want to insert a single new line before the caret.
192+
// However, if we were to hit enter anywhere after that, we want two new lines inserted. One after the
193+
// `"""` and one at the caret itself.
194+
var newLineAndIndentation = newLine + indentation;
195+
196+
// Add a newline at the position of the end literal
197+
var closingStart = GetStartPositionOfClosingDelimiter(expression);
198+
edit.Insert(closingStart, newLineAndIndentation);
199+
200+
// Add a newline at the caret's position, to insert the newline that the user requested
201+
edit.Insert(position, newLineAndIndentation);
202+
203+
// Also add a newline at the start of the text, only if there is text before the caret's position
204+
var insertedLinesBeforeCaret = 1;
205+
var openingEnd = GetEndPositionOfOpeningDelimiter(expression);
206+
if (openingEnd != position)
207+
{
208+
insertedLinesBeforeCaret = 2;
209+
edit.Insert(openingEnd, newLineAndIndentation);
210+
}
211+
212+
var finalSnapshot = edit.Apply();
213+
214+
var lineInNewSnapshot = finalSnapshot.GetLineFromPosition(openingEnd);
215+
var nextLine = finalSnapshot.GetLineFromLineNumber(lineInNewSnapshot.LineNumber + insertedLinesBeforeCaret);
216+
textView.Caret.MoveTo(new VirtualSnapshotPoint(nextLine, indentation.Length));
217+
}
218+
219+
transaction?.Complete();
220+
return true;
99221
}
100222

101-
var indentationOptions = subjectBuffer.GetIndentationOptions(_editorOptionsService, document.Project.GetFallbackAnalyzerOptions(), document.Project.Services, explicitFormat: false);
102-
var indentation = token.GetPreferredIndentation(parsedDocument, indentationOptions, cancellationToken);
103-
104-
var newLine = indentationOptions.FormattingOptions.NewLine;
223+
int GetStartPositionOfClosingDelimiter(ExpressionSyntax expression)
224+
{
225+
if (expression is InterpolatedStringExpressionSyntax interpolatedStringExpression)
226+
return interpolatedStringExpression.StringEndToken.Span.Start;
105227

106-
using var transaction = CaretPreservingEditTransaction.TryCreate(
107-
CSharpEditorResources.Split_string, textView, _undoHistoryRegistry, _editorOperationsFactoryService);
228+
var index = expression.Span.End;
229+
while (currentSnapshot[index - 1] == '"')
230+
index--;
108231

109-
var edit = subjectBuffer.CreateEdit();
232+
return index;
233+
}
110234

111-
// apply the change:
112-
edit.Insert(position, newLine + newLine + indentation);
113-
var snapshot = edit.Apply();
235+
int GetEndPositionOfOpeningDelimiter(ExpressionSyntax expression)
236+
{
237+
if (expression is InterpolatedStringExpressionSyntax interpolatedStringExpression)
238+
return interpolatedStringExpression.StringStartToken.Span.End;
114239

115-
// move caret:
116-
var lineInNewSnapshot = snapshot.GetLineFromPosition(position);
117-
var nextLine = snapshot.GetLineFromLineNumber(lineInNewSnapshot.LineNumber + 1);
118-
textView.Caret.MoveTo(new VirtualSnapshotPoint(nextLine, indentation.Length));
240+
var index = expression.Span.Start;
241+
while (currentSnapshot[index] == '"')
242+
index++;
119243

120-
transaction?.Complete();
121-
return true;
244+
return index;
245+
}
122246
}
123247
}

0 commit comments

Comments
 (0)