File: Snippets\XmlSnippetParser.ExpansionTemplate.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.VisualStudio.LanguageServices.Razor\Microsoft.VisualStudio.LanguageServices.Razor.csproj (Microsoft.VisualStudio.LanguageServices.Razor)
// 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.CodeAnalysis;
using System.Linq;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Roslyn.Utilities;
 
namespace Microsoft.VisualStudio.Razor.Snippets;
 
internal partial class XmlSnippetParser
{
    /// <summary>
    /// Shamelessly adapted from https://devdiv.visualstudio.com/DevDiv/_git/VS-Platform?path=/src/Editor/VisualStudio/Impl/Snippet/ExpansionTemplate.cs
    /// with changes to parsing to store the snippet as a set of parts instead of a single string.
    /// </summary>
    private class ExpansionTemplate
    {
        private record ExpansionField(string ID, string Default, string? FunctionName, string? FunctionParam, bool IsEditable);
 
        private const string Selected = "selected";
        private const string End = "end";
        private const string Shortcut = "shortcut";
 
        private readonly List<ExpansionField> _tokens = new();
 
        private readonly string? _code;
        private readonly char _delimiter = '$';
        private readonly SnippetStringPart _delimiterPart;
 
        public ExpansionTemplate(CodeSnippet snippet)
        {
            var snippetElement = CodeSnippet.GetElementWithoutNamespace(snippet.CodeSnippetElement, "Snippet");
            var declarationsElement = CodeSnippet.GetElementWithoutNamespace(snippetElement, "Declarations");
            ReadDeclarations(declarationsElement);
            var code = CodeSnippet.GetElementWithoutNamespace(snippetElement, "Code");
            if (code == null)
            {
                throw new InvalidOperationException("snippet is missing code element.");
            }
 
            _code = Regex.Replace(code.Value, "(?<!\r)\n", "\r\n");
            var delimiterAttribute = code.Attributes().FirstOrDefault(a => a.Name.LocalName.Equals("Delimiter", StringComparison.OrdinalIgnoreCase));
            if (delimiterAttribute?.Value is string { Length: 1 } && !string.IsNullOrWhiteSpace(delimiterAttribute.Value))
            {
                _delimiter = delimiterAttribute.Value[0];
            }
 
            _delimiterPart = new(_delimiter.ToString());
        }
 
        private void ReadDeclarations(XElement? declarations)
        {
            if (declarations == null)
            {
                return;
            }
 
            foreach (var declarationElement in declarations.Elements())
            {
                var editableAttribute = declarationElement.Attribute("Editable");
                var functionElement = CodeSnippet.GetElementWithoutNamespace(declarationElement, "Function");
                TryGetSnippetFunctionInfo(functionElement?.Value, out var functionName, out var functionParam);
                _tokens.Add(new ExpansionField(
                    CodeSnippet.GetElementInnerText(declarationElement, "ID"),
                    CodeSnippet.GetElementInnerText(declarationElement, "Default") ?? " ",
                    functionName,
                    functionParam,
                    editableAttribute == null || string.Equals(editableAttribute.Value, "true", StringComparison.Ordinal) || string.Equals(editableAttribute.Value, "1", StringComparison.Ordinal)));
            }
        }
 
        internal ParsedXmlSnippet Parse()
        {
            int iTokenLen;
            var currentCharIndex = 0;
            var currentTokenCharIndex = 0;
            var parserState = SnippetParseState.Code;
 
            // Associate the field id to the index of the field in the snippet.
            using var fieldNameToSnippetIndex = new PooledDictionaryBuilder<string, int>();
            var currentTabStopIndex = 1;
 
            using var snippetParts = new PooledArrayBuilder<SnippetPart>();
 
            // Mechanically ported from env/msenv/textmgr/ExpansionTemplate.cpp
            while (currentCharIndex < _code!.Length)
            {
                iTokenLen = currentCharIndex - currentTokenCharIndex;
 
                switch (parserState)
                {
                    case SnippetParseState.Code:
                        if (_code[currentCharIndex] == _delimiter)
                        {
                            // we just hit a $, denoting a literal
                            parserState = SnippetParseState.Literal;
 
                            // copy anything from the previous token into our string
                            if (currentCharIndex > currentTokenCharIndex)
                            {
                                // append the token into our buffer
                                var token = _code.Substring(currentTokenCharIndex, iTokenLen);
                                snippetParts.Add(new SnippetStringPart(token));
                            }
 
                            // start the new token at the next character
                            currentTokenCharIndex = currentCharIndex;
                            currentTokenCharIndex++;
                        }
 
                        break;
 
                    case SnippetParseState.Literal:
                        if (_code[currentCharIndex] == _delimiter)
                        {
                            // we just hit the $, ending the literal
                            parserState = SnippetParseState.Code;
 
                            // if we have any token, it's a literal, otherwise it's an escaped '$'
                            if (iTokenLen > 0)
                            {
                                var fieldName = _code[currentTokenCharIndex..currentCharIndex];
 
                                // first check to see if this is a "special" literal
                                if (string.Equals(fieldName, Selected, StringComparison.Ordinal))
                                {
                                    // LSP client currently only invokes on typing (tab) so there is no way to have a selection as part of a snippet request.
                                    // Additionally, TM_SELECTED_TEXT is not supported in the VS LSP client, so we can't set the selection even if we wanted to.
                                    // Since there's no way for the user to ask for a selection replacement, we can ignore it.
                                }
                                else if (string.Equals(fieldName, End, StringComparison.Ordinal))
                                {
                                    snippetParts.Add(SnippetCursorPart.Instance);
                                }
                                else if (string.Equals(fieldName, Shortcut, StringComparison.Ordinal))
                                {
                                    snippetParts.Add(SnippetShortcutPart.Instance);
                                }
                                else
                                {
                                    var field = FindField(fieldName);
                                    if (field != null)
                                    {
                                        // If we have an editable field we need to know its order in the snippet so we can place the appropriate tab stop indices.
                                        int? fieldIndex = field.IsEditable ? fieldNameToSnippetIndex.GetOrAdd(field.ID, (key) => currentTabStopIndex++) : null;
                                        var fieldPart = string.IsNullOrEmpty(field.FunctionName)
                                            ? new SnippetFieldPart(field.ID, field.Default, fieldIndex)
                                            : new SnippetFunctionPart(field.ID, field.Default, fieldIndex, field.FunctionName!, field.FunctionParam);
                                        snippetParts.Add(fieldPart);
                                    }
                                }
                            }
                            else
                            {
                                // simply append a '$'    
                                snippetParts.Add(_delimiterPart);
                            }
 
                            // start the new token at the next character
                            currentTokenCharIndex = currentCharIndex;
                            currentTokenCharIndex++;
                        }
 
                        break;
                }
 
                currentCharIndex++;
            }
 
            // do we have any remaining text to be copied?
            if (parserState == SnippetParseState.Code && (currentCharIndex > currentTokenCharIndex))
            {
                var remaining = _code[currentTokenCharIndex..currentCharIndex];
                snippetParts.Add(new SnippetStringPart(remaining));
            }
 
            return new ParsedXmlSnippet(snippetParts.ToImmutable());
        }
 
        private ExpansionField? FindField(string fieldName)
        {
            return _tokens.FirstOrDefault(t => string.Equals(t.ID, fieldName, StringComparison.Ordinal));
        }
 
        private enum SnippetParseState
        {
            Code,
            Literal
        }
 
        /// <summary>
        /// Parse the XML snippet function attribute to determine the function name and parameter.
        /// </summary>
        private static bool TryGetSnippetFunctionInfo(
            string? xmlFunctionText,
            [NotNullWhen(true)] out string? snippetFunctionName,
            [NotNullWhen(true)] out string? param)
        {
            if (string.IsNullOrEmpty(xmlFunctionText))
            {
                snippetFunctionName = null;
                param = null;
                return false;
            }
 
            xmlFunctionText.AssumeNotNull();
 
            if (!xmlFunctionText.Contains('(') ||
                !xmlFunctionText.Contains(')') ||
                xmlFunctionText.IndexOf(')') < xmlFunctionText.IndexOf('('))
            {
                snippetFunctionName = null;
                param = null;
                return false;
            }
 
            snippetFunctionName = xmlFunctionText[..xmlFunctionText.IndexOf('(')];
 
            var paramStart = xmlFunctionText.IndexOf('(') + 1;
            var paramLength = xmlFunctionText.LastIndexOf(')') - xmlFunctionText.IndexOf('(') - 1;
            param = xmlFunctionText.Substring(paramStart, paramLength);
            return true;
        }
    }
}