File: CSharp\RequiredAttributeParser.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.Frozen;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Language;
 
namespace Microsoft.CodeAnalysis.Razor;
 
internal static class RequiredAttributeParser
{
    private const char RequiredAttributeWildcardSuffix = '*';
 
    private static readonly FrozenDictionary<char, RequiredAttributeValueComparison> s_cssValueComparisons =
        new Dictionary<char, RequiredAttributeValueComparison>
        {
            ['='] = RequiredAttributeValueComparison.FullMatch,
            ['^'] = RequiredAttributeValueComparison.PrefixMatch,
            ['$'] = RequiredAttributeValueComparison.SuffixMatch
        }.ToFrozenDictionary();
 
    private static readonly char[] s_whitespaceCharacters = [' ', '\t'];
    private static readonly char[] s_invalidPlainAttributeNameCharacters = [.. s_whitespaceCharacters, ',', RequiredAttributeWildcardSuffix];
    private static readonly char[] s_invalidCssAttributeNameCharacters = [.. s_whitespaceCharacters, ',', ']', .. s_cssValueComparisons.Keys];
    private static readonly char[] s_invalidCssQuotelessValueCharacters = [.. s_whitespaceCharacters, ']'];
 
    public static void AddRequiredAttributes(string input, TagMatchingRuleDescriptorBuilder ruleBuilder)
    {
        using var parser = new Parser(input);
        ParseResult result;
 
        do
        {
            result = parser.TryParseNextRequiredAttribute();
 
            if (result.Success || result.HasDiagnostics)
            {
                // If we failed to parse, we still want to add the attribute with the diagnostics.
                ruleBuilder.Attribute(builder =>
                {
                    builder.Name = result.Name;
                    builder.NameComparison = result.NameComparison;
                    builder.Value = result.Value;
                    builder.ValueComparison = result.ValueComparison;
 
                    if (result.HasDiagnostics)
                    {
                        builder.Diagnostics.AddRange(result.Diagnostics);
                    }
                });
            }
        }
        while (result.Success);
    }
 
    private readonly record struct ParseResult(
        bool Success,
        string Name,
        RequiredAttributeNameComparison NameComparison,
        string? Value,
        RequiredAttributeValueComparison ValueComparison,
        ImmutableArray<RazorDiagnostic> Diagnostics)
    {
        public bool HasDiagnostics => !Diagnostics.IsDefaultOrEmpty;
 
        public static ParseResult Failed(
            string name, RequiredAttributeNameComparison nameComparison,
            string? value, RequiredAttributeValueComparison valueComparison,
            ReadOnlySpan<RazorDiagnostic> diagnostics)
            => new(Success: false, name, nameComparison, value, valueComparison, [.. diagnostics]);
 
        public static ParseResult Succeeded(
            string name, RequiredAttributeNameComparison nameComparison,
            string? value, RequiredAttributeValueComparison valueComparison)
            => new(Success: true, name, nameComparison, value, valueComparison, Diagnostics: default);
    }
 
    private ref struct Parser(string input)
    {
        private readonly string _input = input;
        private ReadOnlySpan<char> _span = input.AsSpan();
 
        private MemoryBuilder<RazorDiagnostic> _diagnostics = new();
        public void Dispose()
        {
            _diagnostics.Dispose();
        }
 
        private readonly bool AtEnd
            => _span.IsEmpty;
 
        private readonly bool At(char c)
            => _span is [var ch, ..] && ch == c;
 
        private readonly char Current
            => _span[0];
 
        private void MoveNext()
        {
            Debug.Assert(!AtEnd, "Cannot move past the end of the input.");
            _span = _span[1..];
        }
 
        private void SkipWhitespace()
        {
            while (_span is [' ' or '\t', .. var remaining])
            {
                _span = remaining;
            }
        }
 
        private bool EnsureNotAtEnd()
        {
            if (!AtEnd)
            {
                return true;
            }
 
            _diagnostics.Append(
                RazorDiagnosticFactory.CreateTagHelper_CouldNotFindMatchingEndBrace(_input));
 
            return false;
        }
 
        public ParseResult TryParseNextRequiredAttribute()
        {
            if (AtEnd)
            {
                return default;
            }
 
            SkipWhitespace();
 
            string? name, value = null;
            RequiredAttributeNameComparison nameComparison = default;
            RequiredAttributeValueComparison valueComparison = default;
 
            _diagnostics.Length = 0;
 
            if (At('['))
            {
                if (!TryParseCssSelector(out name, out value, out valueComparison))
                {
                    return ParseResult.Failed(name, nameComparison, value, valueComparison, _diagnostics.AsMemory().Span);
                }
            }
            else
            {
                (name, nameComparison) = ParsePlainSelector();
            }
 
            SkipWhitespace();
 
            if (At(','))
            {
                // Move past the comma
                MoveNext();
 
                if (!EnsureNotAtEnd())
                {
                    return ParseResult.Failed(name, nameComparison, value, valueComparison, _diagnostics.AsMemory().Span);
                }
            }
            else if (!AtEnd)
            {
                _diagnostics.Append(
                    RazorDiagnosticFactory.CreateTagHelper_InvalidRequiredAttributeCharacter(Current, _input));
 
                return ParseResult.Failed(name, nameComparison, value, valueComparison, _diagnostics.AsMemory().Span);
            }
 
            Debug.Assert(_diagnostics.Length == 0, "Diagnostics should only be added if we fail to parse.");
            return ParseResult.Succeeded(name, nameComparison, value, valueComparison);
        }
 
        private (string name, RequiredAttributeNameComparison nameComparison) ParsePlainSelector()
        {
            string name;
            var nameComparison = RequiredAttributeNameComparison.FullMatch;
 
            var nameEndIndex = _span.IndexOfAny(s_invalidPlainAttributeNameCharacters);
 
            if (nameEndIndex == -1)
            {
                name = _span.ToString();
                _span = [];
 
                return (name, nameComparison);
            }
 
            name = _span[..nameEndIndex].ToString();
            _span = _span[nameEndIndex..];
 
            if (Current == RequiredAttributeWildcardSuffix)
            {
                nameComparison = RequiredAttributeNameComparison.PrefixMatch;
 
                // Move past wild card
                MoveNext();
            }
 
            return (name, nameComparison);
        }
 
        private bool TryParseCssValue(out string? value)
        {
            if (At('\'') || At('"'))
            {
                var quote = Current;
 
                // Move past the quote
                MoveNext();
 
                // Find the next quote
                var nextQuoteIndex = _span.IndexOf(quote);
 
                if (nextQuoteIndex == -1)
                {
                    _diagnostics.Append(
                        RazorDiagnosticFactory.CreateTagHelper_InvalidRequiredAttributeMismatchedQuotes(quote, _input));
 
                    value = null;
                    return false;
                }
 
                value = _span[..nextQuoteIndex].ToString();
                _span = _span[(nextQuoteIndex + 1)..];
                return true;
            }
 
            var valueEndIndex = _span.IndexOfAny(s_invalidCssQuotelessValueCharacters);
 
            if (valueEndIndex == -1)
            {
                valueEndIndex = _span.Length;
            }
 
            value = _span[..valueEndIndex].ToString();
            _span = _span[valueEndIndex..];
 
            return true;
        }
 
        private bool TryParseCssSelector(out string name, out string? value, out RequiredAttributeValueComparison valueComparison)
        {
            Debug.Assert(At('['));
 
            // Move past '['.
            MoveNext();
            SkipWhitespace();
 
            name = ParseCssAttributeName();
 
            value = null;
            valueComparison = default;
 
            SkipWhitespace();
 
            if (!EnsureNotAtEnd())
            {
                return false;
            }
 
            if (!TryParseCssValueComparison(out valueComparison))
            {
                return false;
            }
 
            SkipWhitespace();
 
            if (!EnsureNotAtEnd())
            {
                return false;
            }
 
            if (valueComparison != RequiredAttributeValueComparison.None &&
                !TryParseCssValue(out value))
            {
                return false;
            }
 
            SkipWhitespace();
 
            if (At(']'))
            {
                // Move past the ending bracket.
                MoveNext();
                return true;
            }
 
            else if (AtEnd)
            {
                _diagnostics.Append(
                    RazorDiagnosticFactory.CreateTagHelper_CouldNotFindMatchingEndBrace(_input));
            }
            else
            {
                _diagnostics.Append(
                    RazorDiagnosticFactory.CreateTagHelper_InvalidRequiredAttributeCharacter(Current, _input));
            }
 
            return false;
        }
 
        private string ParseCssAttributeName()
        {
            var nameEndIndex = _span.IndexOfAny(s_invalidCssAttributeNameCharacters);
 
            if (nameEndIndex == -1)
            {
                nameEndIndex = _span.Length;
            }
 
            var result = _span[..nameEndIndex].ToString();
            _span = _span[nameEndIndex..];
 
            return result;
        }
 
        /// <summary>
        ///  Parse ^=, $=, or just = as a required attribute value comparison.
        /// </summary>
        private bool TryParseCssValueComparison(out RequiredAttributeValueComparison valueComparison)
        {
            Debug.Assert(!AtEnd);
 
            var ch = Current;
 
            if (s_cssValueComparisons.TryGetValue(ch, out valueComparison))
            {
                MoveNext();
 
                // If the character was an '=', we're done.
                if (ch == '=')
                {
                    return true;
                }
 
                // If the character was an '^' or '$' and the second character is an '=',
                // then we have a two-character operator (ex: ^= or $=).
                if (At('='))
                {
                    MoveNext();
                    return true;
                }
 
                // We're at an incomplete operator (ex: [foo^]
                _diagnostics.Append(
                    RazorDiagnosticFactory.CreateTagHelper_PartialRequiredAttributeOperator(ch, _input));
 
                valueComparison = default;
                return false;
            }
 
            if (!At(']'))
            {
                _diagnostics.Append(
                    RazorDiagnosticFactory.CreateTagHelper_InvalidRequiredAttributeOperator(ch, _input));
 
                valueComparison = default;
                return false;
            }
 
            valueComparison = RequiredAttributeValueComparison.None;
            return true;
        }
    }
}