File: CSharpAddParenthesesAroundConditionalExpressionInInterpolatedStringCodeFixProvider.cs
Web Access
Project: ..\..\..\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.Collections.Immutable;
using System.Composition;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.CSharp.ConditionalExpressionInStringInterpolation
{
    [ExportCodeFixProvider(LanguageNames.CSharp, Name = PredefinedCodeFixProviderNames.AddParenthesesAroundConditionalExpressionInInterpolatedString), Shared]
    internal class CSharpAddParenthesesAroundConditionalExpressionInInterpolatedStringCodeFixProvider : CodeFixProvider
    {
        private const string CS8361 = nameof(CS8361); //A conditional expression cannot be used directly in a string interpolation because the ':' ends the interpolation.Parenthesize the conditional expression.
 
        [ImportingConstructor]
        [SuppressMessage("RoslynDiagnosticsReliability", "RS0033:Importing constructor should be [Obsolete]", Justification = "Used in test code: https://github.com/dotnet/roslyn/issues/42814")]
        public CSharpAddParenthesesAroundConditionalExpressionInInterpolatedStringCodeFixProvider()
        {
        }
 
        // CS8361 is a syntax error and it is unlikely that there is more than one CS8361 at a time.
        public override FixAllProvider? GetFixAllProvider() => null;
 
        public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(CS8361);
 
        public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
        {
            var root = await context.Document.GetRequiredSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
            var diagnostic = context.Diagnostics.First();
            var diagnosticSpan = diagnostic.Location.SourceSpan;
            var token = root.FindToken(diagnosticSpan.Start);
            var conditionalExpression = token.GetAncestor<ConditionalExpressionSyntax>();
            if (conditionalExpression != null)
            {
                var documentChangeAction = CodeAction.Create(
                    CSharpCodeFixesResources.Add_parentheses_around_conditional_expression_in_interpolated_string,
                    c => GetChangedDocumentAsync(context.Document, conditionalExpression.SpanStart, c),
                    nameof(CSharpCodeFixesResources.Add_parentheses_around_conditional_expression_in_interpolated_string));
                context.RegisterCodeFix(documentChangeAction, diagnostic);
            }
        }
 
        private static async Task<Document> GetChangedDocumentAsync(Document document, int conditionalExpressionSyntaxStartPosition, CancellationToken cancellationToken)
        {
            // The usual SyntaxTree transformations are complicated if string literals are present in the false part as in
            // $"{ condition ? "Success": "Failure" }"
            // The colon starts a FormatClause and the double quote left to 'F' therefore ends the interpolated string.
            // The text starting with 'F' is parsed as code and the resulting syntax tree is impractical.
            // The same problem arises if a } is present in the false part.
            // To circumvent these problems this solution
            // 1. Inserts an opening parenthesis
            // 2. Re-parses the resulting document (now the colon isn't treated as starting a FormatClause anymore)
            // 3. Replaces the missing CloseParenToken with a new one
            var text = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
            var openParenthesisPosition = conditionalExpressionSyntaxStartPosition;
            var textWithOpenParenthesis = text.Replace(openParenthesisPosition, 0, "(");
            var documentWithOpenParenthesis = document.WithText(textWithOpenParenthesis);
 
            var syntaxRoot = await documentWithOpenParenthesis.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
            var nodeAtInsertPosition = syntaxRoot.FindNode(new TextSpan(openParenthesisPosition, 0));
 
            if (nodeAtInsertPosition is not ParenthesizedExpressionSyntax parenthesizedExpression ||
                !parenthesizedExpression.CloseParenToken.IsMissing)
            {
                return documentWithOpenParenthesis;
            }
 
            return await InsertCloseParenthesisAsync(
                documentWithOpenParenthesis, parenthesizedExpression, cancellationToken).ConfigureAwait(false);
        }
 
        private static async Task<Document> InsertCloseParenthesisAsync(
            Document document,
            ParenthesizedExpressionSyntax parenthesizedExpression,
            CancellationToken cancellationToken)
        {
            var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
            if (parenthesizedExpression.Expression is ConditionalExpressionSyntax conditional &&
                parenthesizedExpression.GetAncestor<InterpolatedStringExpressionSyntax>()?.StringStartToken.Kind() == SyntaxKind.InterpolatedStringStartToken)
            {
                // If they have something like:
                //
                // var s3 = $""Text1 { true ? ""Text2""[|:|]
                // NextLineOfCode();
                //
                // We will update this initially to:
                //
                // var s3 = $""Text1 { (true ? ""Text2""[|:|]
                // NextLineOfCode();
                //
                // And we have to decide where the close paren should go.  Based on the parse tree, the
                // 'NextLineOfCode()' expression will be pulled into the WhenFalse portion of the conditional.
                // So placing the close paren after the conditional woudl result in: 'NextLineOfCode())'.
                //
                // However, the user intent is likely that NextLineOfCode is not part of the conditional
                // So instead find the colon and place the close paren after that, producing:
                //
                // var s3 = $""Text1 { (true ? ""Text2"":)
                // NextLineOfCode();
 
                var endToken = sourceText.AreOnSameLine(conditional.ColonToken, conditional.WhenFalse.GetFirstToken())
                    ? conditional.WhenFalse.GetLastToken()
                    : conditional.ColonToken;
 
                var closeParenPosition = endToken.Span.End;
                var textWithCloseParenthesis = sourceText.Replace(closeParenPosition, 0, ")");
                return document.WithText(textWithCloseParenthesis);
            }
 
            var root = await document.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
            var newCloseParen = SyntaxFactory.Token(SyntaxKind.CloseParenToken).WithTriviaFrom(parenthesizedExpression.CloseParenToken);
            var parenthesizedExpressionWithClosingParen = parenthesizedExpression.WithCloseParenToken(newCloseParen);
            var newRoot = root.ReplaceNode(parenthesizedExpression, parenthesizedExpressionWithClosingParen);
            return document.WithSyntaxRoot(newRoot);
        }
    }
}