File: ConvertToRawString\ConvertInterpolatedStringToRawStringCodeRefactoringProvider.cs
Web Access
Project: src\src\Features\CSharp\Portable\Microsoft.CodeAnalysis.CSharp.Features.csproj (Microsoft.CodeAnalysis.CSharp.Features)
// 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.Linq;
using System.Text;
using System.Threading;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.Collections;
using Microsoft.CodeAnalysis.CSharp.EmbeddedLanguages.VirtualChars;
using Microsoft.CodeAnalysis.CSharp.Extensions;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.EmbeddedLanguages.VirtualChars;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Indentation;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Collections;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.CSharp.ConvertToRawString;
 
using static ConvertToRawStringHelpers;
using static SyntaxFactory;
 
internal sealed partial class ConvertInterpolatedStringToRawStringProvider
    : AbstractConvertStringProvider<InterpolatedStringExpressionSyntax>
{
    public static readonly IConvertStringProvider Instance = new ConvertInterpolatedStringToRawStringProvider();
 
    private ConvertInterpolatedStringToRawStringProvider()
    {
    }
 
    protected override bool CheckSyntax(InterpolatedStringExpressionSyntax stringExpression)
        => stringExpression is
        {
            StringStartToken: (kind: SyntaxKind.InterpolatedStringStartToken or SyntaxKind.InterpolatedVerbatimStringStartToken),
            // TODO(cyrusn): Should we offer this on empty strings... seems undesirable as you'd end with a gigantic
            // three line alternative over just $""
            Contents.Count: > 0,
        };
 
    private static VirtualCharSequence ConvertToVirtualChars(InterpolatedStringTextSyntax textSyntax)
    {
        var result = TryConvertToVirtualChars(textSyntax.TextToken);
        Contract.ThrowIfTrue(result.IsDefault);
        return result;
    }
 
    private static VirtualCharSequence TryConvertToVirtualChars(InterpolatedStringTextSyntax textSyntax)
        => TryConvertToVirtualChars(textSyntax.TextToken);
 
    private static VirtualCharSequence TryConvertToVirtualChars(SyntaxToken token)
        => CSharpVirtualCharService.Instance.TryConvertToVirtualChars(token);
 
    protected override bool CanConvert(
        ParsedDocument document,
        InterpolatedStringExpressionSyntax stringExpression,
        SyntaxFormattingOptions formattingOptions,
        out CanConvertParams convertParams,
        CancellationToken cancellationToken)
    {
        convertParams = default;
 
        if (stringExpression.StringStartToken.Kind() is not SyntaxKind.InterpolatedStringStartToken and not SyntaxKind.InterpolatedVerbatimStringStartToken)
            return false;
 
        // Check up front for syntax errors.  Knowing there are none means we don't have a sanity checks later on.
        if (stringExpression.GetDiagnostics().Any(static d => d.Severity == DiagnosticSeverity.Error))
            return false;
 
        var firstContent = stringExpression.Contents.First();
        var lastContent = stringExpression.Contents.Last();
 
        var priority = CodeActionPriority.Low;
        var canBeSingleLine = true;
        var containsEscapedEndOfLineCharacter = false;
        foreach (var content in stringExpression.Contents)
        {
            if (content is InterpolationSyntax interpolation)
            {
                if (interpolation.FormatClause != null)
                {
                    var characters = TryConvertToVirtualChars(interpolation.FormatClause.FormatStringToken);
 
                    // Ensure that all characters in the string are those we can convert.
                    if (!ConvertToRawStringHelpers.CanConvert(characters, out var chunkContainsEscapedEndOfLineCharacter))
                        return false;
 
                    containsEscapedEndOfLineCharacter |= chunkContainsEscapedEndOfLineCharacter;
                }
 
                if (canBeSingleLine && !document.Text.AreOnSameLine(interpolation.OpenBraceToken, interpolation.CloseBraceToken))
                    canBeSingleLine = false;
            }
            else if (content is InterpolatedStringTextSyntax interpolatedStringText)
            {
                var characters = TryConvertToVirtualChars(interpolatedStringText);
 
                // Ensure that all characters in the string are those we can convert.
                if (!ConvertToRawStringHelpers.CanConvert(characters, out var chunkContainsEscapedEndOfLineCharacter))
                    return false;
 
                containsEscapedEndOfLineCharacter |= chunkContainsEscapedEndOfLineCharacter;
 
                if (canBeSingleLine)
                {
                    // a single line raw string cannot contain a newline.
                    // Single line raw strings cannot start/end with quote.
                    if (characters.Any(static ch => IsCSharpNewLine(ch)))
                    {
                        canBeSingleLine = false;
                    }
                    else if (interpolatedStringText == firstContent &&
                        characters.First().Rune.Value == '"')
                    {
                        canBeSingleLine = false;
                    }
                    else if (interpolatedStringText == lastContent &&
                        characters.Last().Rune.Value == '"')
                    {
                        canBeSingleLine = false;
                    }
                }
 
                // If we have escaped quotes or braces in the string, then this is a good option to bubble up as
                // something to convert to a raw string. Otherwise, still offer this refactoring, but at low priority as
                // the user may be invoking this on lots of strings that they have no interest in converting.
                if (priority == CodeActionPriority.Low &&
                    AllEscapesAre(characters,
                        static c => c.Utf16SequenceLength == 1 && (char)c.Value is '"' or '{' or '}'))
                {
                    priority = CodeActionPriority.Default;
                }
            }
        }
 
        // Users sometimes write verbatim string literals with a extra starting newline (or indentation) purely
        // for aesthetic reasons.  For example:
        //
        //      var v = @"
        //          SELECT column1, column2, ...
        //          FROM table_name";
        //
        // Converting this directly to a raw string will produce:
        //
        //      var v = """
        //
        //                  SELECT column1, column2, ...
        //                  FROM table_name";
        //          """
        //
        // Check for this and offer instead to generate:
        //
        //      var v = """
        //          SELECT column1, column2, ...
        //          FROM table_name";
        //          """
        //
        // This changes the contents of the literal, but that can be fine for the domain the user is working in.
        // Offer this, but let the user know that this will change runtime semantics.
        var canBeMultiLineWithoutLeadingWhiteSpaces = false;
        if (!canBeSingleLine &&
            stringExpression.StringStartToken.Kind() == SyntaxKind.InterpolatedVerbatimStringStartToken)
        {
            var converted = GetInitialMultiLineRawInterpolatedString(stringExpression, formattingOptions);
            var cleaned = CleanInterpolatedString(converted, cancellationToken);
 
            canBeMultiLineWithoutLeadingWhiteSpaces = !cleaned.IsEquivalentTo(converted);
        }
 
        convertParams = new CanConvertParams(
            priority, canBeSingleLine, canBeMultiLineWithoutLeadingWhiteSpaces, containsEscapedEndOfLineCharacter);
        return true;
    }
 
    protected override InterpolatedStringExpressionSyntax Convert(
        ParsedDocument document,
        InterpolatedStringExpressionSyntax stringExpression,
        ConvertToRawKind kind,
        SyntaxFormattingOptions formattingOptions,
        CancellationToken cancellationToken)
    {
        if ((kind & ConvertToRawKind.SingleLine) == ConvertToRawKind.SingleLine)
            return ConvertToSingleLineRawString();
 
        var indentationOptions = new IndentationOptions(formattingOptions);
 
        var token = stringExpression.StringStartToken;
        var tokenLine = document.Text.Lines.GetLineFromPosition(token.SpanStart);
        if (token.SpanStart == tokenLine.Start)
        {
            // Special case.  string token starting at the start of the line.  This is a common pattern used for
            // multi-line strings that don't want any indentation and have the start/end of the string at the same
            // level (like unit tests).
            //
            // In this case, figure out what indentation we're normally like to put this string.  Update *both* the
            // contents *and* the starting quotes of the raw string.
            var indenter = document.LanguageServices.GetRequiredService<IIndentationService>();
            var indentationVal = indenter.GetIndentation(document, tokenLine.LineNumber, indentationOptions, cancellationToken);
 
            var indentation = indentationVal.GetIndentationString(document.Text, indentationOptions);
            var newNode = ConvertToMultiLineRawIndentedString(document, indentation);
            newNode = newNode.WithLeadingTrivia(newNode.GetLeadingTrivia().Add(Whitespace(indentation)));
            return newNode;
        }
        else
        {
            // otherwise this was a string literal on a line that already contains contents.  Or it's a string
            // literal on its own line, but indented some amount.  Figure out the indentation of the contents from
            // this, but leave the string literal starting at whatever position it's at.
            var indentation = token.GetPreferredIndentation(document, indentationOptions, cancellationToken);
            return ConvertToMultiLineRawIndentedString(document, indentation);
        }
 
        InterpolatedStringExpressionSyntax ConvertToSingleLineRawString()
        {
            var (startDelimiter, endDelimiter, openBraceString, closeBraceString) = GetDelimiters(stringExpression);
 
            return stringExpression
                .WithStringStartToken(UpdateToken(
                    stringExpression.StringStartToken,
                    startDelimiter,
                    kind: SyntaxKind.InterpolatedSingleLineRawStringStartToken))
                .WithContents(ConvertContents(stringExpression, openBraceString, closeBraceString))
                .WithStringEndToken(UpdateToken(
                    stringExpression.StringEndToken,
                    endDelimiter,
                    kind: SyntaxKind.InterpolatedRawStringEndToken));
        }
 
        InterpolatedStringExpressionSyntax ConvertToMultiLineRawIndentedString(ParsedDocument document, string indentation)
        {
            var rawStringExpression = GetInitialMultiLineRawInterpolatedString(stringExpression, formattingOptions);
 
            // If requested, cleanup the whitespace in the expression.
            var cleanedExpression = (kind & ConvertToRawKind.MultiLineWithoutLeadingWhitespace) == ConvertToRawKind.MultiLineWithoutLeadingWhitespace
                ? CleanInterpolatedString(rawStringExpression, cancellationToken)
                : rawStringExpression;
 
            var startLine = document.Text.Lines.GetLineFromPosition(GetAnchorNode(document, stringExpression).SpanStart);
            var rootAnchorIndentation = GetIndentationStringForToken(
                document.Text, formattingOptions, document.Root.FindToken(startLine.Start));
 
            // Now that the expression is cleaned, ensure every non-blank line gets the necessary indentation.
            var indentedText = Indent(
                cleanedExpression, formattingOptions, indentation, rootAnchorIndentation, cancellationToken);
 
            // Finally, parse the text back into an interpolated string so that all the contents are correct.
            var parsed = (InterpolatedStringExpressionSyntax)ParseExpression(indentedText.ToString(), options: stringExpression.SyntaxTree.Options);
            return parsed.WithTriviaFrom(stringExpression);
        }
 
        static SyntaxNode GetAnchorNode(ParsedDocument parsedDocument, SyntaxNode node)
        {
            // we're starting with something either like:
            //
            //      {some_expr +
            //          cont};
            //
            // or
            //
            //      {
            //          some_expr +
            //              cont};
            //
            // In the first, we want to consider the `some_expr + cont` to actually start where `{` starts so
            // that we can accurately determine where the preferred indentation should move all of it.
            //
            // Otherwise, default to the indentation of the line the expression is on.
            var firstToken = node.GetFirstToken();
            if (parsedDocument.Text.AreOnSameLine(firstToken.GetPreviousToken(), firstToken))
            {
                for (var current = node; current != null; current = current.Parent)
                {
                    if (current is StatementSyntax or MemberDeclarationSyntax)
                        return current;
                }
            }
 
            return node;
        }
 
        static string Indent(
            InterpolatedStringExpressionSyntax stringExpression,
            SyntaxFormattingOptions formattingOptions,
            string indentation,
            string rootAnchorIndentation,
            CancellationToken cancellationToken)
        {
            var text = stringExpression.GetText();
 
            using var _1 = PooledStringBuilder.GetInstance(out var builder);
 
            var (interpolationInteriorSpans, restrictedSpans) = GetInterpolationSpans(stringExpression, cancellationToken);
 
            AppendFullLine(builder, text.Lines[0]);
 
            for (int i = 1, n = text.Lines.Count; i < n; i++)
            {
                var line = text.Lines[i];
 
                if (restrictedSpans.Algorithms.HasIntervalThatIntersectsWith(line.Start, new TextSpanIntervalIntrospector()))
                {
                    // Inside something we must not touch.  Include the line verbatim.
                    AppendFullLine(builder, line);
                    continue;
                }
 
                if (line.IsEmptyOrWhitespace())
                {
                    // append the original newline.
                    builder.Append(text.ToString(TextSpan.FromBounds(line.End, line.EndIncludingLineBreak)));
                    continue;
                }
 
                // line with content on it.  It's either content of the string expression, or it's
                // interpolation code.
                if (interpolationInteriorSpans.Any(s => s.Contains(line.Start)))
                {
                    // inside an interpolation.  Figure out the original indentation against the appropriate anchor, and
                    // preserve that indentation on top of whatever indentation is being added.
                    var firstNonWhitespacePos = line.GetFirstNonWhitespacePosition()!.Value;
 
                    var anchorIndentation = DetermineAnchorIndentation(
                        rootAnchorIndentation, text, formattingOptions, stringExpression, line.Start);
 
                    var positionIndentation = GetIndentationStringForPosition(text, formattingOptions, firstNonWhitespacePos);
                    var preferredIndentation = positionIndentation.StartsWith(anchorIndentation)
                        ? indentation + positionIndentation[anchorIndentation.Length..]
                        : indentation;
                    builder.Append(preferredIndentation);
                    builder.Append(text.ToString(TextSpan.FromBounds(firstNonWhitespacePos, line.EndIncludingLineBreak)));
                }
                else
                {
                    // Indent any content the right amount.
                    builder.Append(indentation);
                    AppendFullLine(builder, line);
                }
            }
 
            return builder.ToString();
        }
    }
 
    private static string DetermineAnchorIndentation(
        string rootAnchorIndentation,
        SourceText text,
        SyntaxFormattingOptions formattingOptions,
        InterpolatedStringExpressionSyntax stringExpression,
        int start)
    {
        var interpolation = stringExpression.Contents.OfType<InterpolationSyntax>().Single(i => i.Span.Contains(start));
        var interpolationLine = text.Lines.GetLineFromPosition(interpolation.SpanStart);
 
        // We only want to do this past the first line.  The first line actually contains the contents following the
        // `"""` after we inserted the newline.  So if we have an interpolation starting there, we actually want to use
        // the indentation of string expression itself.
        if (interpolationLine.LineNumber > 1 &&
            interpolationLine.GetFirstNonWhitespacePosition() == interpolation.SpanStart)
        {
            // interpolation itself was on its own line.  We want to preserve code indentation relative to that.
            return GetIndentationStringForPosition(text, formattingOptions, interpolation.SpanStart);
        }
 
        // Otherwise, determine indentation based on this part of the interpolation expression relative to the string
        // literal itself.
        return rootAnchorIndentation;
    }
 
    private static InterpolatedStringExpressionSyntax GetInitialMultiLineRawInterpolatedString(
        InterpolatedStringExpressionSyntax stringExpression,
        SyntaxFormattingOptions formattingOptions)
    {
        // If the user asked to remove whitespace then do so now.
 
        // First, do the trivial conversion, just updating the start/end delimiters.  Adding the requisite newlines
        // at the start/end, and updating quotes/braces.
        var (startDelimiter, endDelimiter, openBraceString, closeBraceString) = GetDelimiters(stringExpression);
 
        // Once we have this, convert the node to text as it is much easier to process in string form.
        var rawStringExpression = stringExpression
            .WithStringStartToken(UpdateToken(
                stringExpression.StringStartToken,
                startDelimiter + formattingOptions.NewLine,
                kind: SyntaxKind.InterpolatedMultiLineRawStringStartToken))
            .WithContents(ConvertContents(stringExpression, openBraceString, closeBraceString))
            .WithStringEndToken(UpdateToken(
                stringExpression.StringEndToken,
                formattingOptions.NewLine + endDelimiter,
                kind: SyntaxKind.InterpolatedRawStringEndToken));
 
        return rawStringExpression;
    }
 
    private static (string startDelimiter, string endDelimiter, string openBraceString, string closeBraceString) GetDelimiters(
        InterpolatedStringExpressionSyntax stringExpression)
    {
        var (longestQuoteSequence, longestBraceSequence) = GetLongestSequences(stringExpression);
 
        // Have to make sure we have a delimiter longer than any quote sequence in the string.
        var quoteDelimiterCount = Math.Max(3, longestQuoteSequence + 1);
        var dollarCount = longestBraceSequence + 1;
 
        var quoteString = new string('"', quoteDelimiterCount);
        var startDelimiter = $"{new string('$', dollarCount)}{quoteString}";
        var openBraceString = new string('{', dollarCount);
        var closeBraceString = new string('}', dollarCount);
 
        return (startDelimiter, quoteString, openBraceString, closeBraceString);
    }
 
    private static (int longestQuoteSequence, int longestBraceSequence) GetLongestSequences(InterpolatedStringExpressionSyntax stringExpression)
    {
        var longestQuoteSequence = 0;
        var longestBraceSequence = 0;
        foreach (var content in stringExpression.Contents)
        {
            if (content is InterpolatedStringTextSyntax stringText)
            {
                var characters = ConvertToVirtualChars(stringText);
                longestQuoteSequence = Math.Max(longestQuoteSequence, GetLongestQuoteSequence(characters));
                longestBraceSequence = Math.Max(longestBraceSequence, GetLongestBraceSequence(characters));
            }
        }
 
        return (longestQuoteSequence, longestBraceSequence);
    }
 
    private static SyntaxList<InterpolatedStringContentSyntax> ConvertContents(
        InterpolatedStringExpressionSyntax stringExpression,
        string openBraceString,
        string closeBraceString)
    {
        using var _ = ArrayBuilder<InterpolatedStringContentSyntax>.GetInstance(out var contents);
 
        foreach (var content in stringExpression.Contents)
        {
            if (content is InterpolationSyntax interpolation)
            {
                contents.Add(interpolation
                    .WithOpenBraceToken(UpdateToken(interpolation.OpenBraceToken, openBraceString))
                    .WithFormatClause(RewriteFormatClause(interpolation.FormatClause))
                    .WithCloseBraceToken(UpdateToken(interpolation.CloseBraceToken, closeBraceString)));
            }
            else if (content is InterpolatedStringTextSyntax stringText)
            {
                var characters = ConvertToVirtualChars(stringText);
                contents.Add(stringText.WithTextToken(UpdateToken(
                    stringText.TextToken, characters.CreateString())));
            }
        }
 
        return [.. contents];
 
        static InterpolationFormatClauseSyntax? RewriteFormatClause(InterpolationFormatClauseSyntax? formatClause)
        {
            if (formatClause is null)
                return null;
 
            var characters = TryConvertToVirtualChars(formatClause.FormatStringToken);
            return formatClause.WithFormatStringToken(UpdateToken(formatClause.FormatStringToken, characters.CreateString()));
        }
    }
 
    private static string GetIndentationStringForToken(SourceText text, SyntaxFormattingOptions options, SyntaxToken token)
        => GetIndentationStringForPosition(text, options, token.SpanStart);
 
    private static string GetIndentationStringForPosition(SourceText text, SyntaxFormattingOptions options, int position)
    {
        var lineContainingPosition = text.Lines.GetLineFromPosition(position);
        var lineText = lineContainingPosition.ToString();
        var indentation = lineText.ConvertTabToSpace(options.TabSize, initialColumn: 0, endPosition: position - lineContainingPosition.Start);
        return indentation.CreateIndentationString(options.UseTabs, options.TabSize);
    }
 
    private static void AppendFullLine(StringBuilder builder, TextLine line)
        => builder.Append(line.Text!.ToString(line.SpanIncludingLineBreak));
 
    private static (ImmutableIntervalTree<TextSpan> interpolationInteriorSpans, ImmutableIntervalTree<TextSpan> restrictedSpans) GetInterpolationSpans(
        InterpolatedStringExpressionSyntax stringExpression, CancellationToken cancellationToken)
    {
        var interpolationInteriorSpans = new SegmentedList<TextSpan>();
        var restrictedSpans = new SegmentedList<TextSpan>();
 
        SourceText? text = null;
        foreach (var content in stringExpression.Contents)
        {
            if (content is InterpolationSyntax interpolation)
            {
                interpolationInteriorSpans.Add(TextSpan.FromBounds(interpolation.OpenBraceToken.Span.End, interpolation.CloseBraceToken.Span.Start));
 
                // We don't want to touch any nested strings within us, mark them as off limits.  note, we only care if
                // the nested strings actually span multiple lines.  A nested string on a single line is safe to move
                // forward/back on that line without affecting runtime semantics.
                foreach (var descendant in interpolation.DescendantNodes().OfType<ExpressionSyntax>())
                {
                    if (descendant is LiteralExpressionSyntax(kind: SyntaxKind.StringLiteralExpression) ||
                        descendant is InterpolatedStringExpressionSyntax)
                    {
                        var descendantSpan = descendant.Span;
 
                        text ??= stringExpression.SyntaxTree.GetText(cancellationToken);
                        var startLine = text.Lines.GetLineFromPosition(descendantSpan.Start);
                        if (startLine != text.Lines.GetLineFromPosition(descendantSpan.End))
                        {
                            // If the string is the first thing on this line, then expand the restricted span to the
                            // start of the line.  We don't want to move it around at all.
                            var start = startLine.GetFirstNonWhitespacePosition() == descendantSpan.Start
                                ? startLine.Start
                                : descendantSpan.Start;
                            restrictedSpans.Add(TextSpan.FromBounds(start, descendantSpan.End));
                        }
                    }
                }
            }
        }
 
        return (
            ImmutableIntervalTree<TextSpan>.CreateFromUnsorted(new TextSpanIntervalIntrospector(), interpolationInteriorSpans),
            ImmutableIntervalTree<TextSpan>.CreateFromUnsorted(new TextSpanIntervalIntrospector(), restrictedSpans));
    }
 
    private static InterpolatedStringExpressionSyntax CleanInterpolatedString(
        InterpolatedStringExpressionSyntax stringExpression, CancellationToken cancellationToken)
    {
        var text = stringExpression.GetText();
 
        var (interpolationInteriorSpans, restrictedSpans) = GetInterpolationSpans(stringExpression, cancellationToken);
 
        // Get all the lines of the string expression.  Note that the first/last lines will be the ones containing
        // the delimiters.  So they can be ignored in all further processing.
        using var _3 = ArrayBuilder<TextLine>.GetInstance(out var lines);
        lines.AddRange(text.Lines);
 
        // Remove the leading and trailing lines if they are all whitespace.
        while (lines[1].IsEmptyOrWhitespace() &&
            !interpolationInteriorSpans.Any(s => s.Contains(lines[1].Start)))
        {
            lines.RemoveAt(1);
        }
 
        while (lines[^2].IsEmptyOrWhitespace() &&
            !interpolationInteriorSpans.Any(s => s.Contains(lines[^2].Start)))
        {
            lines.RemoveAt(lines.Count - 2);
        }
 
        // If we removed all the lines, don't do anything.
        if (lines.Count == 2)
            return stringExpression;
 
        // Use the remaining lines to figure out what common whitespace we have.
        var commonWhitespacePrefix = ComputeCommonWhitespacePrefix(lines, interpolationInteriorSpans);
 
        using var _1 = PooledStringBuilder.GetInstance(out var builder);
 
        // Add the line with the starting delimiter
        AppendFullLine(builder, lines[0]);
 
        // Add the content lines
        for (int i = 1, n = lines.Count - 1; i < n; i++)
        {
            // ignore any blank lines we see.
            var line = lines[i];
 
            if (restrictedSpans.Algorithms.HasIntervalThatIntersectsWith(line.Start, new TextSpanIntervalIntrospector()))
            {
                // Inside something we must not touch.  Include the line verbatim.
                AppendFullLine(builder, line);
                continue;
            }
 
            if (line.IsEmptyOrWhitespace())
            {
                // append the original newline.
                builder.Append(text.ToString(TextSpan.FromBounds(line.End, line.EndIncludingLineBreak)));
                continue;
            }
 
            // line with content on it.  It's either content of the string expression, or it's
            // interpolation code.
            if (interpolationInteriorSpans.Any(s => s.Contains(line.Start)))
            {
                // Interpolation content.  Trim the prefix if present on that line, otherwise leave alone. Don't do this
                // though for restricted content as we never want to touch that.
                if (line.GetFirstNonWhitespacePosition() is int pos)
                {
                    var currentLineLeadingWhitespace = line.Text!.ToString(TextSpan.FromBounds(line.Start, pos));
                    if (currentLineLeadingWhitespace.StartsWith(commonWhitespacePrefix))
                    {
                        builder.Append(text.ToString(TextSpan.FromBounds(line.Start + commonWhitespacePrefix.Length, line.EndIncludingLineBreak)));
                        continue;
                    }
                }
 
                AppendFullLine(builder, line);
            }
            else if (line == text.Lines[1])
            {
                // If this is the first line, then we got this line by adding a newline at the start of the
                // interpolated string, moving the contents after the quote to the next line.  In that case, the
                // next line will start at the zero-column and should not contribute to the common whitespace
                // trimming.
                AppendFullLine(builder, line);
            }
            else
            {
                // normal content. trim off of the common prefix.
                builder.Append(text.ToString(TextSpan.FromBounds(line.Start + commonWhitespacePrefix.Length, line.EndIncludingLineBreak)));
            }
        }
 
        // For the line before the delimiter line, trim off any trailing whitespace if present.
        var lastIndex = builder.Length;
        var beforeNewLines = lastIndex;
        while (SyntaxFacts.IsNewLine(builder[beforeNewLines - 1]))
            beforeNewLines--;
 
        var beforeSpaces = beforeNewLines;
        while (SyntaxFacts.IsWhitespace(builder[beforeSpaces - 1]))
            beforeSpaces--;
 
        builder.Remove(beforeSpaces, beforeNewLines - beforeSpaces);
 
        // Add the line with the final delimiter
        AppendFullLine(builder, lines[^1]);
 
        var parsed = (InterpolatedStringExpressionSyntax)ParseExpression(builder.ToString(), options: stringExpression.SyntaxTree.Options);
        return parsed.WithTriviaFrom(stringExpression);
    }
 
    private static string ComputeCommonWhitespacePrefix(
        ArrayBuilder<TextLine> lines,
        ImmutableIntervalTree<TextSpan> interpolationInteriorSpans)
    {
        string? commonLeadingWhitespace = null;
 
        // Walk all the lines between the delimiters.
        for (int i = 1, n = lines.Count - 1; i < n; i++)
        {
            if (commonLeadingWhitespace is "")
                return commonLeadingWhitespace;
 
            var line = lines[i];
 
            // If this is the first line, then we got this line by adding a newline at the start of the interpolated
            // string, moving the contents after the quote to the next line.  In that case, the next line will start at
            // the zero-column and should not contribute to the computation of the common whitespace prefix.
            if (line == line.Text!.Lines[1])
                continue;
 
            if (interpolationInteriorSpans.Any(s => s.Contains(line.Start)) ||
                interpolationInteriorSpans.Any(s => s.Start - 1 == line.Start))
            {
                // ignore any lines where we're inside the interpolation, or the interpolation starts at the beginning
                // of the line.
                continue;
            }
 
            if (line.GetFirstNonWhitespacePosition() is not int pos)
                continue;
 
            var currentLineLeadingWhitespace = line.Text!.ToString(TextSpan.FromBounds(line.Start, pos));
            commonLeadingWhitespace = ComputeCommonWhitespacePrefix(commonLeadingWhitespace, currentLineLeadingWhitespace);
        }
 
        return commonLeadingWhitespace ?? "";
    }
 
    private static string ComputeCommonWhitespacePrefix(
        string? leadingWhitespace1, string leadingWhitespace2)
    {
        if (leadingWhitespace1 is null)
            return leadingWhitespace2;
 
        var length = Math.Min(leadingWhitespace1.Length, leadingWhitespace2.Length);
 
        var current = 0;
        while (current < length && SyntaxFacts.IsWhitespace(leadingWhitespace1[current]) && leadingWhitespace1[current] == leadingWhitespace2[current])
            current++;
 
        return leadingWhitespace1[..current];
    }
 
    public static SyntaxToken UpdateToken(SyntaxToken token, string text, string valueText = "", SyntaxKind? kind = null)
        => Token(
            token.LeadingTrivia,
            kind ?? token.Kind(),
            text,
            valueText == "" ? text : valueText,
            token.TrailingTrivia);
}