File: CSharp\RequiredAttributeParser.cs
Web Access
Project: src\src\roslyn\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;
        }
    }
}