File: Language\Legacy\ImplicitExpressionEditHandler.cs
Web Access
Project: src\src\Razor\src\Compiler\Microsoft.CodeAnalysis.Razor.Compiler\src\Microsoft.CodeAnalysis.Razor.Compiler.csproj (Microsoft.CodeAnalysis.Razor.Compiler)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.Extensions.Internal;
 
namespace Microsoft.AspNetCore.Razor.Language.Legacy;
 
internal class ImplicitExpressionEditHandler : SpanEditHandler
{
    public static void SetupBuilder(SpanEditHandlerBuilder builder, Func<string, IEnumerable<Syntax.InternalSyntax.SyntaxToken>> tokenizer, bool acceptTrailingDot, KeywordSet keywords)
    {
        builder.Tokenizer = tokenizer;
        builder.Factory = (acceptedCharacters, tokenizer) => new ImplicitExpressionEditHandler
        {
            AcceptedCharacters = acceptedCharacters,
            Tokenizer = tokenizer,
            AcceptTrailingDot = acceptTrailingDot,
            Keywords = keywords,
        };
    }
 
    public required bool AcceptTrailingDot { get; init; }
 
    public required KeywordSet Keywords { get; init; }
 
    public override string ToString()
    {
        return string.Format(CultureInfo.InvariantCulture, "{0};ImplicitExpression[{1}];K{2}", base.ToString(), AcceptTrailingDot ? "ATD" : "RTD", Keywords.Count);
    }
 
    public override bool Equals(object obj)
    {
        var other = obj as ImplicitExpressionEditHandler;
        return base.Equals(other) &&
            AcceptTrailingDot == other.AcceptTrailingDot;
    }
 
    public override int GetHashCode()
    {
        // Hash code should include only immutable properties and base has none.
        var hashCodeCombiner = HashCodeCombiner.Start();
        hashCodeCombiner.Add(AcceptTrailingDot);
 
        return hashCodeCombiner;
    }
 
    protected override PartialParseResultInternal CanAcceptChange(SyntaxNode target, SourceChange change)
    {
        if (AcceptedCharacters == AcceptedCharactersInternal.Any)
        {
            return PartialParseResultInternal.Rejected;
        }
 
        // In some editors intellisense insertions are handled as "dotless commits".  If an intellisense selection is confirmed
        // via something like '.' a dotless commit will append a '.' and then insert the remaining intellisense selection prior
        // to the appended '.'.  This 'if' statement attempts to accept the intermediate steps of a dotless commit via
        // intellisense.  It will accept two cases:
        //     1. '@foo.' -> '@foobaz.'.
        //     2. '@foobaz..' -> '@foobaz.bar.'. Includes Sub-cases '@foobaz()..' -> '@foobaz().bar.' etc.
        // The key distinction being the double '.' in the second case.
        if (IsDotlessCommitInsertion(target, change))
        {
            return HandleDotlessCommitInsertion(target);
        }
 
        if (IsAcceptableIdentifierReplacement(target, change))
        {
            return TryAcceptChange(target, change);
        }
 
        if (IsAcceptableReplace(target, change))
        {
            return HandleReplacement(target, change);
        }
        var changeRelativePosition = change.Span.AbsoluteIndex - target.Position;
 
        // Get the edit context
        char? lastChar = null;
        if (changeRelativePosition > 0 && target.Width > 0)
        {
            lastChar = target.GetContent()[changeRelativePosition - 1];
        }
 
        // Don't support 0->1 length edits
        if (lastChar == null)
        {
            return PartialParseResultInternal.Rejected;
        }
 
        // Accepts cases when insertions are made at the end of a span or '.' is inserted within a span.
        if (IsAcceptableInsertion(target, change))
        {
            // Handle the insertion
            return HandleInsertion(target, lastChar.Value, change);
        }
 
        if (IsAcceptableInsertionInBalancedParenthesis(target, change))
        {
            return PartialParseResultInternal.Accepted;
        }
 
        if (IsAcceptableDeletion(target, change))
        {
            return HandleDeletion(target, lastChar.Value, change);
        }
 
        if (IsAcceptableDeletionInBalancedParenthesis(target, change))
        {
            return PartialParseResultInternal.Accepted;
        }
 
        return PartialParseResultInternal.Rejected;
    }
 
    // A dotless commit is the process of inserting a '.' with an intellisense selection.
    private static bool IsDotlessCommitInsertion(SyntaxNode target, SourceChange change)
    {
        return IsNewDotlessCommitInsertion(target, change) || IsSecondaryDotlessCommitInsertion(target, change);
    }
 
    // Completing 'DateTime' in intellisense with a '.' could result in: '@DateT' -> '@DateT.' -> '@DateTime.' which is accepted.
    private static bool IsNewDotlessCommitInsertion(SyntaxNode target, SourceChange change)
    {
        return !IsAtEndOfSpan(target, change) &&
               change.Span.AbsoluteIndex > 0 &&
               change.NewText.Length > 0 &&
               target.GetContent().Last() == '.' &&
               ParserHelpers.IsIdentifier(change.NewText, requireIdentifierStart: false) &&
               (change.Span.Length == 0 || ParserHelpers.IsIdentifier(change.GetOriginalText(target), requireIdentifierStart: false));
    }
 
    // Once a dotless commit has been performed you then have something like '@DateTime.'.  This scenario is used to detect the
    // situation when you try to perform another dotless commit resulting in a textchange with '..'.  Completing 'DateTime.Now'
    // in intellisense with a '.' could result in: '@DateTime.' -> '@DateTime..' -> '@DateTime.Now.' which is accepted.
    private static bool IsSecondaryDotlessCommitInsertion(SyntaxNode target, SourceChange change)
    {
        // Do not need to worry about other punctuation, just looking for double '.' (after change)
        return change.NewText.Length == 1 &&
               change.NewText == "." &&
               !string.IsNullOrEmpty(target.GetContent()) &&
               target.GetContent().Last() == '.' &&
               change.Span.Length == 0;
    }
 
    private static bool IsAcceptableReplace(SyntaxNode target, SourceChange change)
    {
        return IsEndReplace(target, change) ||
               (change.IsReplace && RemainingIsWhitespace(target, change));
    }
 
    private bool IsAcceptableIdentifierReplacement(SyntaxNode target, SourceChange change)
    {
        if (!change.IsReplace)
        {
            return false;
        }
 
        foreach (var token in target.DescendantTokens())
        {
            if (token.Kind == SyntaxKind.None)
            {
                break;
            }
 
            var tokenStartIndex = token.Position;
            var tokenEndIndex = token.EndPosition;
 
            // We're looking for the first token that contains the SourceChange.
            if (tokenEndIndex > change.Span.AbsoluteIndex)
            {
                if (tokenEndIndex >= change.Span.AbsoluteIndex + change.Span.Length && token.Kind == SyntaxKind.Identifier)
                {
                    // The token we're changing happens to be an identifier. Need to check if its transformed state is also one.
                    // We do this transformation logic to capture the case that the new text change happens to not be an identifier;
                    // i.e. "5". Alone, it's numeric, within an identifier it's classified as identifier.
                    var transformedContent = change.GetEditedContent(token.Content, change.Span.AbsoluteIndex - tokenStartIndex);
                    var newTokens = Tokenizer(transformedContent);
 
                    if (newTokens.Count() != 1)
                    {
                        // The transformed content resulted in more than one token; we can only replace a single identifier with
                        // another single identifier.
                        break;
                    }
 
                    var newToken = newTokens.First();
                    if (newToken.Kind == SyntaxKind.Identifier)
                    {
                        return true;
                    }
                }
 
                // Change is touching a non-identifier token or spans multiple tokens.
 
                break;
            }
        }
 
        return false;
    }
 
    private static bool IsAcceptableDeletion(SyntaxNode target, SourceChange change)
    {
        return IsEndDeletion(target, change) ||
               (change.IsDelete && RemainingIsWhitespace(target, change));
    }
 
    // Acceptable insertions can occur at the end of a span or when a '.' is inserted within a span.
    private static bool IsAcceptableInsertion(SyntaxNode target, SourceChange change)
    {
        return change.IsInsert &&
            (IsAcceptableEndInsertion(target, change) ||
            IsAcceptableInnerInsertion(target, change));
    }
 
    // Internal for testing
    internal static bool IsAcceptableDeletionInBalancedParenthesis(SyntaxNode target, SourceChange change)
    {
        if (!change.IsDelete)
        {
            return false;
        }
 
        var changeStart = change.Span.AbsoluteIndex;
        var changeLength = change.Span.Length;
        var changeEnd = changeStart + changeLength;
        var tokens = target.DescendantTokens().ToImmutableArray();
        if (!IsInsideParenthesis(changeStart, tokens) || !IsInsideParenthesis(changeEnd, tokens))
        {
            // Either the start or end of the delete does not fall inside of parenthesis, unacceptable inner deletion.
            return false;
        }
 
        var relativePosition = changeStart - target.Position;
        var deletionContent = target.GetContent().AsSpan(relativePosition, changeLength);
 
        if (deletionContent.IndexOfAny('(', ')') >= 0)
        {
            // Change deleted some parenthesis
            return false;
        }
 
        return true;
    }
 
    // Internal for testing
    internal static bool IsAcceptableInsertionInBalancedParenthesis(SyntaxNode target, SourceChange change)
    {
        if (!change.IsInsert)
        {
            return false;
        }
 
        if (change.NewText.IndexOfAny(new[] { '(', ')' }) >= 0)
        {
            // Insertions of parenthesis aren't handled by us. If someone else wants to accept it, they can.
            return false;
        }
 
        var tokens = target.DescendantTokens().ToImmutableArray();
        if (IsInsideParenthesis(change.Span.AbsoluteIndex, tokens))
        {
            return true;
        }
 
        return false;
    }
 
    // Internal for testing
    internal static bool IsInsideParenthesis<TList>(int position, TList tokens)
        where TList : struct, IReadOnlyList<SyntaxToken>
    {
        var balanceCount = 0;
        var foundInsertionPoint = false;
        for (var i = 0; i < tokens.Count; i++)
        {
            var currentToken = tokens[i];
            if (ContainsPosition(position, currentToken))
            {
                if (balanceCount == 0)
                {
                    // Insertion point is outside of parenthesis, i.e. inserting at the pipe: @Foo|Baz()
                    return false;
                }
 
                foundInsertionPoint = true;
            }
 
            if (!TryUpdateBalanceCount(currentToken, ref balanceCount))
            {
                // Couldn't update the count. This usually occurrs when we run into a ')' outside of any parenthesis.
                return false;
            }
 
            if (foundInsertionPoint && balanceCount == 0)
            {
                // Once parenthesis become balanced after the insertion point we return true, no need to go further.
                // If they get unbalanced down the line the expression was already unbalanced to begin with and this
                // change happens prior to any ambiguity.
                return true;
            }
        }
 
        // Unbalanced parenthesis
        return false;
    }
 
    // Internal for testing
    internal static bool ContainsPosition(int position, SyntaxToken currentToken)
    {
        var tokenStart = currentToken.Position;
        if (tokenStart == position)
        {
            // Token is exactly at the insertion point.
            return true;
        }
 
        var tokenEnd = tokenStart + currentToken.Content.Length;
        if (tokenStart < position && tokenEnd > position)
        {
            // Insertion point falls in the middle of the current token.
            return true;
        }
 
        return false;
    }
 
    // Internal for testing
    internal static bool TryUpdateBalanceCount(SyntaxToken token, ref int count)
    {
        var updatedCount = count;
        if (token.Kind == SyntaxKind.LeftParenthesis)
        {
            updatedCount++;
        }
        else if (token.Kind == SyntaxKind.RightParenthesis)
        {
            if (updatedCount == 0)
            {
                return false;
            }
 
            updatedCount--;
        }
        else if (token.Kind == SyntaxKind.StringLiteral)
        {
            var content = token.Content;
            if (content.Length > 0 && content[content.Length - 1] != '"')
            {
                // Incomplete string literal may have consumed some of our parenthesis and usually occurr during auto-completion of '"' => '""'.
                if (!TryUpdateCountFromContent(content, ref updatedCount))
                {
                    return false;
                }
            }
        }
        else if (token.Kind == SyntaxKind.CharacterLiteral)
        {
            var content = token.Content;
            if (content.Length > 0 && content[content.Length - 1] != '\'')
            {
                // Incomplete character literal may have consumed some of our parenthesis and usually occurr during auto-completion of "'" => "''".
                if (!TryUpdateCountFromContent(content, ref updatedCount))
                {
                    return false;
                }
            }
        }
 
        if (updatedCount < 0)
        {
            return false;
        }
 
        count = updatedCount;
        return true;
    }
 
    // Internal for testing
    internal static bool TryUpdateCountFromContent(string content, ref int count)
    {
        var updatedCount = count;
        for (var i = 0; i < content.Length; i++)
        {
            if (content[i] == '(')
            {
                updatedCount++;
            }
            else if (content[i] == ')')
            {
                if (updatedCount == 0)
                {
                    // Unbalanced parenthesis, i.e. @Foo)
                    return false;
                }
 
                updatedCount--;
            }
        }
 
        count = updatedCount;
        return true;
    }
 
    // Accepts character insertions at the end of spans.  AKA: '@foo' -> '@fooo' or '@foo' -> '@foo   ' etc.
    private static bool IsAcceptableEndInsertion(SyntaxNode target, SourceChange change)
    {
        Debug.Assert(change.IsInsert);
 
        return IsAtEndOfSpan(target, change) ||
               RemainingIsWhitespace(target, change);
    }
 
    // Accepts '.' insertions in the middle of spans. Ex: '@foo.baz.bar' -> '@foo..baz.bar'
    // This is meant to allow intellisense when editing a span.
    private static bool IsAcceptableInnerInsertion(SyntaxNode target, SourceChange change)
    {
        Debug.Assert(change.IsInsert);
 
        // Ensure that we're actually inserting in the middle of a span and not at the end.
        // This case will fail if the IsAcceptableEndInsertion does not capture an end insertion correctly.
        Debug.Assert(!IsAtEndOfSpan(target, change));
 
        return change.Span.AbsoluteIndex > 0 &&
               change.NewText == ".";
    }
 
    private static bool RemainingIsWhitespace(SyntaxNode target, SourceChange change)
    {
        var offset = (change.Span.AbsoluteIndex - target.Position) + change.Span.Length;
        return string.IsNullOrWhiteSpace(target.GetContent().Substring(offset));
    }
 
    private PartialParseResultInternal HandleDotlessCommitInsertion(SyntaxNode target)
    {
        var result = PartialParseResultInternal.Accepted;
        if (!AcceptTrailingDot && target.GetContent().LastOrDefault() == '.')
        {
            result |= PartialParseResultInternal.Provisional;
        }
        return result;
    }
 
    private PartialParseResultInternal HandleReplacement(SyntaxNode target, SourceChange change)
    {
        // Special Case for IntelliSense commits.
        //  When IntelliSense commits, we get two changes (for example user typed "Date", then committed "DateTime" by pressing ".")
        //  1. Insert "." at the end of this span
        //  2. Replace the "Date." at the end of the span with "DateTime."
        //  We need partial parsing to accept case #2.
        var oldText = change.GetOriginalText(target);
 
        var result = PartialParseResultInternal.Rejected;
        if (EndsWithDot(oldText) && EndsWithDot(change.NewText))
        {
            result = PartialParseResultInternal.Accepted;
            if (!AcceptTrailingDot)
            {
                result |= PartialParseResultInternal.Provisional;
            }
        }
        return result;
    }
 
    private PartialParseResultInternal HandleDeletion(SyntaxNode target, char previousChar, SourceChange change)
    {
        // What's left after deleting?
        if (previousChar == '.')
        {
            return TryAcceptChange(target, change, PartialParseResultInternal.Accepted | PartialParseResultInternal.Provisional);
        }
        else if (SyntaxFacts.IsIdentifierPartCharacter(previousChar))
        {
            return TryAcceptChange(target, change);
        }
        else if (previousChar == '(')
        {
            var changeRelativePosition = change.Span.AbsoluteIndex - target.Position;
            if (target.GetContent()[changeRelativePosition] == ')')
            {
                return PartialParseResultInternal.Accepted | PartialParseResultInternal.Provisional;
            }
        }
 
        return PartialParseResultInternal.Rejected;
    }
 
    private PartialParseResultInternal HandleInsertion(SyntaxNode target, char previousChar, SourceChange change)
    {
        // What are we inserting after?
        if (previousChar == '.')
        {
            return HandleInsertionAfterDot(target, change);
        }
        else if (SyntaxFacts.IsIdentifierPartCharacter(previousChar) || previousChar == ')' || previousChar == ']')
        {
            return HandleInsertionAfterIdPart(target, change);
        }
        else if (previousChar == '(')
        {
            return HandleInsertionAfterOpenParenthesis(target, change);
        }
        else
        {
            return PartialParseResultInternal.Rejected;
        }
    }
 
    private PartialParseResultInternal HandleInsertionAfterIdPart(SyntaxNode target, SourceChange change)
    {
        // If the insertion is a full identifier part, accept it
        if (ParserHelpers.IsIdentifier(change.NewText, requireIdentifierStart: false))
        {
            return TryAcceptChange(target, change);
        }
        else if (IsDoubleParenthesisInsertion(change) || IsOpenParenthesisInsertion(change))
        {
            // Allow inserting parens after an identifier - this is needed to support signature
            // help intellisense in VS.
            return TryAcceptChange(target, change);
        }
        else if (EndsWithDot(change.NewText))
        {
            // Accept it, possibly provisionally
            var result = PartialParseResultInternal.Accepted;
            if (!AcceptTrailingDot)
            {
                result |= PartialParseResultInternal.Provisional;
            }
            return TryAcceptChange(target, change, result);
        }
        else
        {
            return PartialParseResultInternal.Rejected;
        }
    }
 
    private PartialParseResultInternal HandleInsertionAfterOpenParenthesis(SyntaxNode target, SourceChange change)
    {
        if (IsCloseParenthesisInsertion(change))
        {
            return TryAcceptChange(target, change);
        }
 
        return PartialParseResultInternal.Rejected;
    }
 
    private PartialParseResultInternal HandleInsertionAfterDot(SyntaxNode target, SourceChange change)
    {
        // If the insertion is a full identifier or another dot, accept it
        if (SyntaxFacts.IsValidIdentifier(change.NewText) || change.NewText == ".")
        {
            return TryAcceptChange(target, change);
        }
        return PartialParseResultInternal.Rejected;
    }
 
    private PartialParseResultInternal TryAcceptChange(SyntaxNode target, SourceChange change, PartialParseResultInternal acceptResult = PartialParseResultInternal.Accepted)
    {
        var content = change.GetEditedContent(target);
        if (StartsWithKeyword(content))
        {
            return PartialParseResultInternal.Rejected | PartialParseResultInternal.SpanContextChanged;
        }
 
        return acceptResult;
    }
 
    private static bool IsDoubleParenthesisInsertion(SourceChange change)
    {
        return
            change.IsInsert &&
            change.NewText.Length == 2 &&
            change.NewText == "()";
    }
 
    private static bool IsOpenParenthesisInsertion(SourceChange change)
    {
        return
            change.IsInsert &&
            change.NewText.Length == 1 &&
            change.NewText == "(";
    }
 
    private static bool IsCloseParenthesisInsertion(SourceChange change)
    {
        return
            change.IsInsert &&
            change.NewText.Length == 1 &&
            change.NewText == ")";
    }
 
    private static bool EndsWithDot(string content)
    {
        return (content.Length == 1 && content[0] == '.') ||
               (content[content.Length - 1] == '.' &&
                content.Take(content.Length - 1).All(SyntaxFacts.IsIdentifierPartCharacter));
    }
 
    private bool StartsWithKeyword(string newContent)
    {
        using (var reader = new StringReader(newContent))
        {
            return Keywords.Contains(reader.ReadWhile(SyntaxFacts.IsIdentifierPartCharacter));
        }
    }
}