Pārlūkot izejas kodu

Route tooling: Support component routes (#44656)

James Newton-King 3 gadi atpakaļ
vecāks
revīzija
2a086e2b55
16 mainītis faili ar 752 papildinājumiem un 130 dzēšanām
  1. 8 1
      src/Framework/AspNetCoreAnalyzers/src/Analyzers/Infrastructure/EmbeddedSyntax/EmbeddedSyntaxHelpers.cs
  2. 5 14
      src/Framework/AspNetCoreAnalyzers/src/Analyzers/Infrastructure/RoutePattern/RoutePatternLexer.cs
  3. 34 0
      src/Framework/AspNetCoreAnalyzers/src/Analyzers/Infrastructure/RoutePattern/RoutePatternOptions.cs
  4. 91 13
      src/Framework/AspNetCoreAnalyzers/src/Analyzers/Infrastructure/RoutePattern/RoutePatternParser.cs
  5. 10 2
      src/Framework/AspNetCoreAnalyzers/src/Analyzers/Infrastructure/RouteUsageCache.cs
  6. 0 1
      src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/FrameworkParametersCompletionProvider.cs
  7. 0 39
      src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/Infrastructure/RouteStringSyntaxDetectorDocument.cs
  8. 23 3
      src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/Infrastructure/RouteUsageDetector.cs
  9. 1 1
      src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/RoutePatternAnalyzer.cs
  10. 1 1
      src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/RoutePatternClassifier.cs
  11. 12 13
      src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/RoutePatternCompletionProvider.cs
  12. 1 2
      src/Framework/AspNetCoreAnalyzers/src/CodeFixes/DetectMismatchedParameterOptionalityFixer.cs
  13. 18 16
      src/Framework/AspNetCoreAnalyzers/test/RouteEmbeddedLanguage/RoutePatternParserTests.cs
  14. 509 0
      src/Framework/AspNetCoreAnalyzers/test/RouteEmbeddedLanguage/RoutePatternParserTests_ComponentsTests.cs
  15. 20 18
      src/Framework/AspNetCoreAnalyzers/test/RouteEmbeddedLanguage/RoutePatternParserTests_ReplacementTests.cs
  16. 19 6
      src/Framework/AspNetCoreAnalyzers/test/TestDiagnosticAnalyzer.cs

+ 8 - 1
src/Framework/AspNetCoreAnalyzers/src/Analyzers/Infrastructure/EmbeddedSyntax/EmbeddedSyntaxHelpers.cs

@@ -10,7 +10,14 @@ namespace Microsoft.AspNetCore.Analyzers.Infrastructure.EmbeddedSyntax;
 internal static class EmbeddedSyntaxHelpers
 {
     public static TextSpan GetSpan<TSyntaxKind>(EmbeddedSyntaxToken<TSyntaxKind> token1, EmbeddedSyntaxToken<TSyntaxKind> token2) where TSyntaxKind : struct
-        => GetSpan(token1.VirtualChars[0], token2.VirtualChars.Last());
+    {
+        if (token2.VirtualChars.IsEmpty)
+        {
+            return GetSpan(token1.VirtualChars[0], token1.VirtualChars.Last());
+        }
+        
+        return GetSpan(token1.VirtualChars[0], token2.VirtualChars.Last());
+    }
 
     public static TextSpan GetSpan(VirtualCharSequence virtualChars)
         => GetSpan(virtualChars[0], virtualChars.Last());

+ 5 - 14
src/Framework/AspNetCoreAnalyzers/src/Analyzers/Infrastructure/RoutePattern/RoutePatternLexer.cs

@@ -14,13 +14,13 @@ using RoutePatternToken = EmbeddedSyntaxToken<RoutePatternKind>;
 internal struct RoutePatternLexer
 {
     public readonly VirtualCharSequence Text;
-    public readonly bool SupportTokenReplacement;
+    public readonly RoutePatternOptions RoutePatternOptions;
     public int Position;
 
-    public RoutePatternLexer(VirtualCharSequence text, bool supportTokenReplacement) : this()
+    public RoutePatternLexer(VirtualCharSequence text, RoutePatternOptions routePatternOptions) : this()
     {
         Text = text;
-        SupportTokenReplacement = supportTokenReplacement;
+        RoutePatternOptions = routePatternOptions;
     }
 
     public VirtualChar CurrentChar => Position < Text.Length ? Text[Position] : default;
@@ -119,12 +119,12 @@ internal struct RoutePatternLexer
             {
                 questionMarkPosition = Position;
             }
-            else if (ch.Value == '[' && IsUnescapedChar(ref Position, '[') && SupportTokenReplacement)
+            else if (ch.Value == '[' && IsUnescapedChar(ref Position, '[') && RoutePatternOptions.SupportTokenReplacement)
             {
                 // Literal ends at bracket start if token replacement is supported.
                 break;
             }
-            else if (IsUnescapedChar(ref Position, ']') && SupportTokenReplacement)
+            else if (IsUnescapedChar(ref Position, ']') && RoutePatternOptions.SupportTokenReplacement)
             {
                 mismatchBracketPosition = Position;
             }
@@ -169,15 +169,6 @@ internal struct RoutePatternLexer
     private const char QuestionMark = '?';
     private const char Asterisk = '*';
 
-    internal static readonly char[] InvalidParameterNameChars = new char[]
-    {
-        Separator,
-        OpenBrace,
-        CloseBrace,
-        QuestionMark,
-        Asterisk
-    };
-
     internal RoutePatternToken? TryScanParameterName()
     {
         if (Position == Text.Length)

+ 34 - 0
src/Framework/AspNetCoreAnalyzers/src/Analyzers/Infrastructure/RoutePattern/RoutePatternOptions.cs

@@ -0,0 +1,34 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Analyzers.Infrastructure.RoutePattern;
+
+internal sealed class RoutePatternOptions
+{
+    private RoutePatternOptions() { }
+    public bool SupportTokenReplacement { get; private set; }
+    public bool SupportComplexSegments { get; private set; }
+    public bool SupportDefaultValues { get; private set; }
+    public bool SupportTwoAsteriskCatchAll { get; private set; }
+    public char[]? AdditionalInvalidParameterCharacters { get; private set; }
+
+    public static readonly RoutePatternOptions DefaultRoute = new RoutePatternOptions
+    {
+        SupportComplexSegments = true,
+        SupportDefaultValues = true,
+        SupportTwoAsteriskCatchAll = true,
+    };
+
+    public static readonly RoutePatternOptions MvcAttributeRoute = new RoutePatternOptions
+    {
+        SupportComplexSegments = true,
+        SupportDefaultValues = true,
+        SupportTwoAsteriskCatchAll = true,
+        SupportTokenReplacement = true,
+    };
+
+    public static readonly RoutePatternOptions ComponentsRoute = new RoutePatternOptions
+    {
+        AdditionalInvalidParameterCharacters = new[] { '{', '}', '=', '.' }
+    };
+}

+ 91 - 13
src/Framework/AspNetCoreAnalyzers/src/Analyzers/Infrastructure/RoutePattern/RoutePatternParser.cs

@@ -20,15 +20,15 @@ internal partial struct RoutePatternParser
 {
     private RoutePatternLexer _lexer;
     private RoutePatternToken _currentToken;
-    private readonly bool _supportTokenReplacement;
+    private readonly RoutePatternOptions _routePatternOptions;
 
-    private RoutePatternParser(VirtualCharSequence text, bool supportTokenReplacement) : this()
+    private RoutePatternParser(VirtualCharSequence text, RoutePatternOptions routePatternOptions) : this()
     {
-        _lexer = new RoutePatternLexer(text, supportTokenReplacement);
+        _lexer = new RoutePatternLexer(text, routePatternOptions);
 
         // Get the first token.  It is allowed to have trivia on it.
         ConsumeCurrentToken();
-        _supportTokenReplacement = supportTokenReplacement;
+        _routePatternOptions = routePatternOptions;
     }
 
     /// <summary>
@@ -47,14 +47,14 @@ internal partial struct RoutePatternParser
     /// and list of diagnostics.  Parsing should always succeed, except in the case of the stack 
     /// overflowing.
     /// </summary>
-    public static RoutePatternTree? TryParse(VirtualCharSequence text, bool supportTokenReplacement)
+    public static RoutePatternTree? TryParse(VirtualCharSequence text, RoutePatternOptions routePatternOptions)
     {
         if (text.IsDefault)
         {
             return null;
         }
 
-        var parser = new RoutePatternParser(text, supportTokenReplacement);
+        var parser = new RoutePatternParser(text, routePatternOptions);
         return parser.ParseTree();
     }
 
@@ -77,10 +77,71 @@ internal partial struct RoutePatternParser
         ValidateNoConsecutiveSeparators(root, diagnostics);
         ValidateCatchAllParameters(root, diagnostics);
         ValidateParameterParts(root, diagnostics, routeParameters);
+        ValidateAdditionalInvalidParameterCharacters(root, diagnostics, _routePatternOptions);
+        ValidateComplexSegments(root, diagnostics, _routePatternOptions);
 
         return new RoutePatternTree(_lexer.Text, root, diagnostics.ToImmutable(), routeParameters.ToImmutable());
     }
 
+    private static void ValidateComplexSegments(RoutePatternCompilationUnit root, ImmutableArray<EmbeddedDiagnostic>.Builder diagnostics, RoutePatternOptions routePatternOptions)
+    {
+        if (routePatternOptions.SupportComplexSegments)
+        {
+            return;
+        }
+
+        foreach (var part in root)
+        {
+            if (part.TryGetNode(RoutePatternKind.Segment, out var segmentNode))
+            {
+                if (segmentNode.ChildCount > 1)
+                {
+                    var message = $"Complex segment is not supported.";
+                    diagnostics.Add(new EmbeddedDiagnostic(message, segmentNode.GetFullSpan()!.Value));
+                }
+            }
+        }
+    }
+
+    private static void ValidateAdditionalInvalidParameterCharacters(RoutePatternCompilationUnit root, ImmutableArray<EmbeddedDiagnostic>.Builder diagnostics, RoutePatternOptions routePatternOptions)
+    {
+        if (routePatternOptions.AdditionalInvalidParameterCharacters == null)
+        {
+            return;
+        }
+
+        foreach (var part in root)
+        {
+            if (part.TryGetNode(RoutePatternKind.Segment, out var segmentNode))
+            {
+                foreach (var segmentPart in segmentNode)
+                {
+                    if (segmentPart.TryGetNode(RoutePatternKind.Parameter, out var parameterNode))
+                    {
+                        foreach (var parameterPart in parameterNode)
+                        {
+                            if (parameterPart.TryGetNode(RoutePatternKind.ParameterName, out var parameterNameNode))
+                            {
+                                var parameterNameToken = ((RoutePatternNameParameterPartNode)parameterNameNode).ParameterNameToken;
+                                if (!parameterNameToken.IsMissing)
+                                {
+                                    var name = parameterNameToken.Value!.ToString();
+                                    var invalidCharacter = name.IndexOfAny(routePatternOptions.AdditionalInvalidParameterCharacters);
+
+                                    if (invalidCharacter != -1)
+                                    {
+                                        var message = $"The character '{name[invalidCharacter]}' in parameter segment '{parameterNode}' is not allowed.";
+                                        diagnostics.Add(new EmbeddedDiagnostic(message, parameterNameNode.GetSpan()));
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
     private static void ValidateStart(RoutePatternCompilationUnit root, IList<EmbeddedDiagnostic> diagnostics)
     {
         if (root.ChildCount > 1 &&
@@ -394,7 +455,7 @@ internal partial struct RoutePatternParser
                 MoveBackBeforePreviousScan();
             }
         }
-        else if (_currentToken.Kind == RoutePatternKind.OpenBracketToken && _supportTokenReplacement)
+        else if (_currentToken.Kind == RoutePatternKind.OpenBracketToken && _routePatternOptions.SupportTokenReplacement)
         {
             var openBracketToken = _currentToken;
 
@@ -436,7 +497,7 @@ internal partial struct RoutePatternParser
 
     private RoutePatternReplacementNode ParseReplacement(RoutePatternToken openBracketToken)
     {
-        Debug.Assert(_supportTokenReplacement);
+        Debug.Assert(_routePatternOptions.SupportTokenReplacement);
 
         MoveBackBeforePreviousScan();
 
@@ -502,10 +563,17 @@ internal partial struct RoutePatternParser
             // Unescaped catch-all, e.g. {**name}
             if (_currentToken.Kind == RoutePatternKind.AsteriskToken)
             {
-                parts.Add(new RoutePatternCatchAllParameterPartNode(
-                    CreateToken(
-                        RoutePatternKind.AsteriskToken,
-                        VirtualCharSequence.FromBounds(firstAsteriskToken.VirtualChars, _currentToken.VirtualChars))));
+                var asterisksToken = CreateToken(
+                    RoutePatternKind.AsteriskToken,
+                    VirtualCharSequence.FromBounds(firstAsteriskToken.VirtualChars, _currentToken.VirtualChars));
+
+                if (!_routePatternOptions.SupportTwoAsteriskCatchAll)
+                {
+                    asterisksToken = asterisksToken.AddDiagnosticIfNone(
+                        new EmbeddedDiagnostic("A catch-all parameter may only have one '*' at the beginning of the segment.", asterisksToken.GetFullSpan()!.Value));
+                }
+
+                parts.Add(new RoutePatternCatchAllParameterPartNode(asterisksToken));
                 ConsumeCurrentToken();
             }
             else
@@ -560,8 +628,18 @@ internal partial struct RoutePatternParser
     {
         var equalsToken = _currentToken;
         var defaultValue = _lexer.TryScanDefaultValue() ?? CreateMissingToken(RoutePatternKind.DefaultValueToken);
+
+        if (!_routePatternOptions.SupportDefaultValues)
+        {
+            equalsToken = equalsToken.AddDiagnosticIfNone(
+                new EmbeddedDiagnostic(
+                    "A parameter with a default value isn't supported.",
+                    EmbeddedSyntaxHelpers.GetSpan(equalsToken, defaultValue)));
+        }
+
         ConsumeCurrentToken();
-        return new(equalsToken, defaultValue);
+        var node = new RoutePatternDefaultValueParameterPartNode(equalsToken, defaultValue);
+        return node;
     }
 
     private RoutePatternPolicyParameterPartNode ParsePolicy()

+ 10 - 2
src/Framework/AspNetCoreAnalyzers/src/Analyzers/Infrastructure/RouteUsageCache.cs

@@ -46,16 +46,24 @@ internal sealed class RouteUsageCache
                 return null;
             }
 
+            var semanticModel = _compilation.GetSemanticModel(syntaxToken.SyntaxTree);
+
+            if (!RouteStringSyntaxDetector.IsRouteStringSyntaxToken(token, semanticModel, cancellationToken, out var options))
+            {
+                return null;
+            }
+
             var wellKnownTypes = WellKnownTypes.GetOrCreate(_compilation);
             var usageContext = RouteUsageDetector.BuildContext(
+                options,
                 token,
-                _compilation.GetSemanticModel(syntaxToken.SyntaxTree),
+                semanticModel,
                 wellKnownTypes,
                 cancellationToken);
 
             var virtualChars = CSharpVirtualCharService.Instance.TryConvertToVirtualChars(token);
             var isMvc = usageContext.UsageType == RouteUsageType.MvcAction || usageContext.UsageType == RouteUsageType.MvcController;
-            var tree = RoutePatternParser.TryParse(virtualChars, supportTokenReplacement: isMvc);
+            var tree = RoutePatternParser.TryParse(virtualChars, usageContext.RoutePatternOptions);
             if (tree == null)
             {
                 return null;

+ 0 - 1
src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/FrameworkParametersCompletionProvider.cs

@@ -144,7 +144,6 @@ public sealed class FrameworkParametersCompletionProvider : CompletionProvider
 
         SyntaxToken routeStringToken;
         SyntaxNode methodNode;
-
         if (container.Parent.IsKind(SyntaxKind.Argument))
         {
             // Minimal API

+ 0 - 39
src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/Infrastructure/RouteStringSyntaxDetectorDocument.cs

@@ -1,39 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.CodeAnalysis;
-
-namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure;
-
-/// <summary>
-/// This type is seperate from <see cref="RouteStringSyntaxDetector"/> to avoid RS1022 warning in analyzer.
-/// It doesn't like analyzers referencing types that might use Document.
-/// </summary>
-internal static class RouteStringSyntaxDetectorDocument
-{
-    internal static async ValueTask<(bool success, SyntaxToken token, SemanticModel? model, RouteOptions options)> TryGetStringSyntaxTokenAtPositionAsync(
-        Document document, int position, CancellationToken cancellationToken)
-    {
-        var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
-        if (root == null)
-        {
-            return default;
-        }
-        var token = root.FindToken(position);
-
-        var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
-        if (semanticModel == null)
-        {
-            return default;
-        }
-
-        if (!RouteStringSyntaxDetector.IsRouteStringSyntaxToken(token, semanticModel, cancellationToken, out var options))
-        {
-            return default;
-        }
-
-        return (true, token, semanticModel, options);
-    }
-}

+ 23 - 3
src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/Infrastructure/RouteUsageDetector.cs

@@ -5,6 +5,7 @@ using System;
 using System.Collections.Immutable;
 using System.Linq;
 using System.Threading;
+using Microsoft.AspNetCore.Analyzers.Infrastructure.RoutePattern;
 using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
 using Microsoft.CodeAnalysis;
 using Microsoft.CodeAnalysis.CSharp;
@@ -17,7 +18,8 @@ internal enum RouteUsageType
     Other,
     MinimalApi,
     MvcAction,
-    MvcController
+    MvcController,
+    Component
 }
 
 internal record struct ParameterSymbol(ISymbol Symbol, ISymbol? TopLevelSymbol = null)
@@ -30,7 +32,15 @@ internal readonly record struct RouteUsageContext(
     SyntaxNode? MethodSyntax,
     RouteUsageType UsageType,
     ImmutableArray<ISymbol> Parameters,
-    ImmutableArray<ParameterSymbol> ResolvedParameters);
+    ImmutableArray<ParameterSymbol> ResolvedParameters)
+{
+    public RoutePatternOptions RoutePatternOptions => UsageType switch
+    {
+        RouteUsageType.MvcAction or RouteUsageType.MvcController => RoutePatternOptions.MvcAttributeRoute,
+        RouteUsageType.Component => RoutePatternOptions.ComponentsRoute,
+        _ => RoutePatternOptions.DefaultRoute,
+    };
+}
 
 internal readonly record struct MapMethodParts(
     IMethodSymbol Method,
@@ -39,8 +49,18 @@ internal readonly record struct MapMethodParts(
 
 internal static class RouteUsageDetector
 {
-    public static RouteUsageContext BuildContext(SyntaxToken token, SemanticModel semanticModel, WellKnownTypes wellKnownTypes, CancellationToken cancellationToken)
+    public static RouteUsageContext BuildContext(RouteOptions routeOptions, SyntaxToken token, SemanticModel semanticModel, WellKnownTypes wellKnownTypes, CancellationToken cancellationToken)
     {
+        if (routeOptions == RouteOptions.Component)
+        {
+            return new(
+                MethodSymbol: null,
+                MethodSyntax: null,
+                UsageType: RouteUsageType.Component,
+                Parameters: ImmutableArray<ISymbol>.Empty,
+                ResolvedParameters: ImmutableArray<ParameterSymbol>.Empty);
+        }
+
         if (token.Parent is not LiteralExpressionSyntax)
         {
             return default;

+ 1 - 1
src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/RoutePatternAnalyzer.cs

@@ -54,7 +54,7 @@ public class RoutePatternAnalyzer : DiagnosticAnalyzer
             else
             {
                 var token = child.AsToken();
-                if (!RouteStringSyntaxDetector.IsRouteStringSyntaxToken(token, context.SemanticModel, cancellationToken, out var _))
+                if (!RouteStringSyntaxDetector.IsRouteStringSyntaxToken(token, context.SemanticModel, cancellationToken, out var options))
                 {
                     continue;
                 }

+ 1 - 1
src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/RoutePatternClassifier.cs

@@ -12,7 +12,7 @@ using RoutePatternToken = Microsoft.AspNetCore.Analyzers.Infrastructure.Embedded
 namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage;
 
 [ExportAspNetCoreEmbeddedLanguageClassifier(name: "Route", language: LanguageNames.CSharp)]
-internal class RoutePatternClassifier : IAspNetCoreEmbeddedLanguageClassifier
+internal sealed class RoutePatternClassifier : IAspNetCoreEmbeddedLanguageClassifier
 {
     public void RegisterClassifications(AspNetCoreEmbeddedLanguageClassificationContext context)
     {

+ 12 - 13
src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/RoutePatternCompletionProvider.cs

@@ -92,17 +92,20 @@ public class RoutePatternCompletionProvider : CompletionProvider
             return;
         }
 
-        var position = context.Position;
-        var (success, stringToken, semanticModel, options) = await RouteStringSyntaxDetectorDocument.TryGetStringSyntaxTokenAtPositionAsync(
-            context.Document, position, context.CancellationToken).ConfigureAwait(false);
+        var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+        if (root == null)
+        {
+            return;
+        }
 
-        if (!success ||
-            position <= stringToken.SpanStart ||
-            position >= stringToken.Span.End)
+        var stringToken = root.FindToken(context.Position);
+        if (context.Position <= stringToken.SpanStart ||
+            context.Position >= stringToken.Span.End)
         {
             return;
         }
 
+        var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
         if (semanticModel is null)
         {
             return;
@@ -118,8 +121,7 @@ public class RoutePatternCompletionProvider : CompletionProvider
         var routePatternCompletionContext = new EmbeddedCompletionContext(
             context,
             routeUsage,
-            stringToken,
-            options);
+            stringToken);
         ProvideCompletions(routePatternCompletionContext);
 
         if (routePatternCompletionContext.Items.Count == 0)
@@ -259,7 +261,7 @@ public class RoutePatternCompletionProvider : CompletionProvider
         context.AddIfMissing("long", suffix: null, "Matches any 64-bit integer.", WellKnownTags.Keyword, parentOpt);
 
         // The following constraints are only available for HTTP route matching.
-        if (context.Options == RouteOptions.Http)
+        if (context.RouteUsage.UsageContext.UsageType != RouteUsageType.Component)
         {
             context.AddIfMissing("minlength", suffix: null, "Matches a string with a length greater than, or equal to, the constraint argument.", WellKnownTags.Keyword, parentOpt);
             context.AddIfMissing("maxlength", suffix: null, "Matches a string with a length less than, or equal to, the constraint argument.", WellKnownTags.Keyword, parentOpt);
@@ -332,7 +334,6 @@ If there are two arguments then the string length must be greater than, or equal
         public readonly CompletionTrigger Trigger;
         public readonly List<RoutePatternItem> Items = new();
         public readonly CompletionListSpanContainer CompletionListSpan = new();
-        public readonly RouteOptions Options;
 
         internal class CompletionListSpanContainer
         {
@@ -342,13 +343,11 @@ If there are two arguments then the string length must be greater than, or equal
         public EmbeddedCompletionContext(
             CompletionContext context,
             RouteUsageModel routeUsage,
-            SyntaxToken stringToken,
-            RouteOptions options)
+            SyntaxToken stringToken)
         {
             _context = context;
             RouteUsage = routeUsage;
             StringToken = stringToken;
-            Options = options;
             Position = _context.Position;
             Trigger = _context.Trigger;
             CancellationToken = _context.CancellationToken;

+ 1 - 2
src/Framework/AspNetCoreAnalyzers/src/CodeFixes/DetectMismatchedParameterOptionalityFixer.cs

@@ -37,8 +37,7 @@ public class DetectMismatchedParameterOptionalityFixer : CodeFixProvider
     private static async Task<Document> FixMismatchedParameterOptionalityAsync(Diagnostic diagnostic, Document document, CancellationToken cancellationToken)
     {
         var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
-
-        if (root == null)
+        if (root is null)
         {
             return document;
         }

+ 18 - 16
src/Framework/AspNetCoreAnalyzers/test/RouteEmbeddedLanguage/RoutePatternParserTests.cs

@@ -44,20 +44,22 @@ public partial class RoutePatternParserTests
         string expected = null,
         bool runSubTreeTests = true,
         bool allowDiagnosticsMismatch = false,
-        bool runReplaceTokens = false)
+        RoutePatternOptions routePatternOptions = null)
     {
+        routePatternOptions ??= RoutePatternOptions.DefaultRoute;
+
         var (tree, sourceText) = TryParseTree(
             stringText,
             conversionFailureOk: false,
-            allowDiagnosticsMismatch,
-            runReplaceTokens);
+            routePatternOptions,
+            allowDiagnosticsMismatch);
 
         // Tests are allowed to not run the subtree tests.  This is because some
         // subtrees can cause the native regex parser to exhibit very bad behavior
         // (like not ever actually finishing compiling).
         if (runSubTreeTests)
         {
-            TryParseSubTrees(stringText, allowDiagnosticsMismatch, runReplaceTokens);
+            TryParseSubTrees(stringText, allowDiagnosticsMismatch, routePatternOptions);
         }
 
         const string DoubleQuoteEscaping = "\"\"";
@@ -77,14 +79,14 @@ public partial class RoutePatternParserTests
     private void TryParseSubTrees(
         string stringText,
         bool allowDiagnosticsMismatch,
-        bool runReplaceTokens = false)
+        RoutePatternOptions routePatternOptions)
     {
         // Trim the input from the right and make sure tree invariants hold
         var current = stringText;
         while (current is not "@\"\"" and not "\"\"")
         {
             current = current.Substring(0, current.Length - 2) + "\"";
-            TryParseTree(current, conversionFailureOk: true, allowDiagnosticsMismatch, runReplaceTokens);
+            TryParseTree(current, conversionFailureOk: true, routePatternOptions, allowDiagnosticsMismatch);
         }
 
         // Trim the input from the left and make sure tree invariants hold
@@ -100,7 +102,7 @@ public partial class RoutePatternParserTests
                 current = "\"" + current.Substring(2);
             }
 
-            TryParseTree(current, conversionFailureOk: true, allowDiagnosticsMismatch, runReplaceTokens);
+            TryParseTree(current, conversionFailureOk: true, routePatternOptions, allowDiagnosticsMismatch);
         }
 
         for (var start = stringText[0] == '@' ? 2 : 1; start < stringText.Length - 1; start++)
@@ -109,13 +111,13 @@ public partial class RoutePatternParserTests
                 stringText.Substring(0, start) +
                 stringText.Substring(start + 1, stringText.Length - (start + 1)),
                 conversionFailureOk: true,
-                allowDiagnosticsMismatch,
-                runReplaceTokens);
+                routePatternOptions,
+                allowDiagnosticsMismatch);
         }
     }
 
     private (SyntaxToken, RoutePatternTree, VirtualCharSequence) JustParseTree(
-        string stringText, bool conversionFailureOk, bool runReplaceTokens)
+        string stringText, bool conversionFailureOk, RoutePatternOptions routePatternOptions)
     {
         var token = GetStringToken(stringText);
         var allChars = CSharpVirtualCharService.Instance.TryConvertToVirtualChars(token);
@@ -125,17 +127,17 @@ public partial class RoutePatternParserTests
             return (token, null, allChars);
         }
 
-        var tree = RoutePatternParser.TryParse(allChars, supportTokenReplacement: runReplaceTokens);
+        var tree = RoutePatternParser.TryParse(allChars, routePatternOptions);
         return (token, tree, allChars);
     }
 
     private (RoutePatternTree, SourceText) TryParseTree(
         string stringText,
         bool conversionFailureOk,
-        bool allowDiagnosticsMismatch = false,
-        bool runReplaceTokens = false)
+        RoutePatternOptions routePatternOptions,
+        bool allowDiagnosticsMismatch = false)
     {
-        var (token, tree, allChars) = JustParseTree(stringText, conversionFailureOk, runReplaceTokens);
+        var (token, tree, allChars) = JustParseTree(stringText, conversionFailureOk, routePatternOptions);
         if (tree == null)
         {
             Assert.True(allChars.IsDefault);
@@ -153,7 +155,7 @@ public partial class RoutePatternParserTests
             routePattern = RoutePatternFactory.Parse(token.ValueText);
             parsedRoutePatterns = routePattern.Parameters;
 
-            if (runReplaceTokens)
+            if (routePatternOptions.SupportTokenReplacement)
             {
                 AttributeRouteModel.ReplaceTokens(token.ValueText, new Dictionary<string, string>
                 {
@@ -169,7 +171,7 @@ public partial class RoutePatternParserTests
             {
                 if (tree.Diagnostics.Length == 0)
                 {
-                    throw new Exception($"Parsing '{token.ValueText}' throws RoutePattern error '{ex.Message}'. No diagnostics.");
+                    throw new Exception($"Parsing '{token.ValueText}' throws RoutePattern error '{ex.Message}'. No diagnostics.", ex);
                 }
 
                 // Ensure the diagnostic we emit is the same as the .NET one. Note: we can only

+ 509 - 0
src/Framework/AspNetCoreAnalyzers/test/RouteEmbeddedLanguage/RoutePatternParserTests_ComponentsTests.cs

@@ -0,0 +1,509 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable disable
+
+using Microsoft.AspNetCore.Analyzers.Infrastructure.RoutePattern;
+
+namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage;
+
+// These tests are mirrored from component's TemplateParserTests.cs
+public partial class RoutePatternParserTests
+{
+    [Fact]
+    public void Parse_MultipleOptionalParameters()
+    {
+        Test(@"""{p1?}/{p2?}/{p3?}""", @"<Tree>
+  <CompilationUnit>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""p1"">p1</ParameterNameToken>
+        </ParameterName>
+        <Optional>
+          <QuestionMarkToken>?</QuestionMarkToken>
+        </Optional>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""p2"">p2</ParameterNameToken>
+        </ParameterName>
+        <Optional>
+          <QuestionMarkToken>?</QuestionMarkToken>
+        </Optional>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""p3"">p3</ParameterNameToken>
+        </ParameterName>
+        <Optional>
+          <QuestionMarkToken>?</QuestionMarkToken>
+        </Optional>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <EndOfFile />
+  </CompilationUnit>
+  <Parameters>
+    <Parameter Name=""p1"" IsCatchAll=""false"" IsOptional=""true"" EncodeSlashes=""true"" />
+    <Parameter Name=""p2"" IsCatchAll=""false"" IsOptional=""true"" EncodeSlashes=""true"" />
+    <Parameter Name=""p3"" IsCatchAll=""false"" IsOptional=""true"" EncodeSlashes=""true"" />
+  </Parameters>
+</Tree>", routePatternOptions: RoutePatternOptions.ComponentsRoute);
+    }
+
+    [Fact]
+    public void Parse_SingleCatchAllParameter()
+    {
+        Test(@"""{*p}""", @"<Tree>
+  <CompilationUnit>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <CatchAll>
+          <AsteriskToken>*</AsteriskToken>
+        </CatchAll>
+        <ParameterName>
+          <ParameterNameToken value=""p"">p</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <EndOfFile />
+  </CompilationUnit>
+  <Parameters>
+    <Parameter Name=""p"" IsCatchAll=""true"" IsOptional=""false"" EncodeSlashes=""true"" />
+  </Parameters>
+</Tree>", routePatternOptions: RoutePatternOptions.ComponentsRoute);
+    }
+
+    [Fact]
+    public void Parse_MixedLiteralAndCatchAllParameter()
+    {
+        Test(@"""awesome/wow/{*p}""", @"<Tree>
+  <CompilationUnit>
+    <Segment>
+      <Literal>
+        <Literal value=""awesome"">awesome</Literal>
+      </Literal>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Literal>
+        <Literal value=""wow"">wow</Literal>
+      </Literal>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <CatchAll>
+          <AsteriskToken>*</AsteriskToken>
+        </CatchAll>
+        <ParameterName>
+          <ParameterNameToken value=""p"">p</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <EndOfFile />
+  </CompilationUnit>
+  <Parameters>
+    <Parameter Name=""p"" IsCatchAll=""true"" IsOptional=""false"" EncodeSlashes=""true"" />
+  </Parameters>
+</Tree>", routePatternOptions: RoutePatternOptions.ComponentsRoute);
+    }
+
+    [Fact]
+    public void Parse_MixedLiteralParameterAndCatchAllParameter()
+    {
+        Test(@"""awesome/{p1}/{*p2}""", @"<Tree>
+  <CompilationUnit>
+    <Segment>
+      <Literal>
+        <Literal value=""awesome"">awesome</Literal>
+      </Literal>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""p1"">p1</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <CatchAll>
+          <AsteriskToken>*</AsteriskToken>
+        </CatchAll>
+        <ParameterName>
+          <ParameterNameToken value=""p2"">p2</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <EndOfFile />
+  </CompilationUnit>
+  <Parameters>
+    <Parameter Name=""p1"" IsCatchAll=""false"" IsOptional=""false"" EncodeSlashes=""true"" />
+    <Parameter Name=""p2"" IsCatchAll=""true"" IsOptional=""false"" EncodeSlashes=""true"" />
+  </Parameters>
+</Tree>", routePatternOptions: RoutePatternOptions.ComponentsRoute, allowDiagnosticsMismatch: true);
+    }
+
+    [Theory]
+    // * is only allowed at beginning for catch-all parameters
+    [InlineData("{p*}")]
+    [InlineData("{{}")]
+    [InlineData("{}}")]
+    [InlineData("{=}")]
+    [InlineData("{.}")]
+    public void Components_ParseRouteParameter_ThrowsIf_ParameterContainsSpecialCharacters(string template)
+    {
+        var tree = Test(@"""" + template + @"""", routePatternOptions: RoutePatternOptions.ComponentsRoute, allowDiagnosticsMismatch: true);
+        Assert.NotEmpty(tree.Diagnostics);
+    }
+
+    [Fact]
+    public void InvalidTemplate_LiteralAfterOptionalParam()
+    {
+        Test(@"""/test/{a?}/test""", @"<Tree>
+  <CompilationUnit>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Literal>
+        <Literal value=""test"">test</Literal>
+      </Literal>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""a"">a</ParameterNameToken>
+        </ParameterName>
+        <Optional>
+          <QuestionMarkToken>?</QuestionMarkToken>
+        </Optional>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Literal>
+        <Literal value=""test"">test</Literal>
+      </Literal>
+    </Segment>
+    <EndOfFile />
+  </CompilationUnit>
+  <Parameters>
+    <Parameter Name=""a"" IsCatchAll=""false"" IsOptional=""true"" EncodeSlashes=""true"" />
+  </Parameters>
+</Tree>", routePatternOptions: RoutePatternOptions.ComponentsRoute);
+    }
+
+    [Fact]
+    public void InvalidTemplate_NonOptionalParamAfterOptionalParam()
+    {
+        Test(@"""/test/{a?}/{b}""", @"<Tree>
+  <CompilationUnit>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Literal>
+        <Literal value=""test"">test</Literal>
+      </Literal>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""a"">a</ParameterNameToken>
+        </ParameterName>
+        <Optional>
+          <QuestionMarkToken>?</QuestionMarkToken>
+        </Optional>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""b"">b</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <EndOfFile />
+  </CompilationUnit>
+  <Parameters>
+    <Parameter Name=""a"" IsCatchAll=""false"" IsOptional=""true"" EncodeSlashes=""true"" />
+    <Parameter Name=""b"" IsCatchAll=""false"" IsOptional=""false"" EncodeSlashes=""true"" />
+  </Parameters>
+</Tree>", routePatternOptions: RoutePatternOptions.ComponentsRoute);
+    }
+
+    [Fact]
+    public void InvalidTemplate_CatchAllParamWithMultipleAsterisks()
+    {
+        Test(@"""/test/{a}/{**b}""", @"<Tree>
+  <CompilationUnit>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Literal>
+        <Literal value=""test"">test</Literal>
+      </Literal>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""a"">a</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <CatchAll>
+          <AsteriskToken>**</AsteriskToken>
+        </CatchAll>
+        <ParameterName>
+          <ParameterNameToken value=""b"">b</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <EndOfFile />
+  </CompilationUnit>
+  <Diagnostics>
+    <Diagnostic Message=""A catch-all parameter may only have one '*' at the beginning of the segment."" Span=""[20..22)"" Text=""**"" />
+  </Diagnostics>
+  <Parameters>
+    <Parameter Name=""a"" IsCatchAll=""false"" IsOptional=""false"" EncodeSlashes=""true"" />
+    <Parameter Name=""b"" IsCatchAll=""true"" IsOptional=""false"" EncodeSlashes=""false"" />
+  </Parameters>
+</Tree>", routePatternOptions: RoutePatternOptions.ComponentsRoute, allowDiagnosticsMismatch: true);
+    }
+
+    [Fact]
+    public void InvalidTemplate_CatchAllParamNotLast()
+    {
+        Test(@"""/test/{*a}/{b}""", @"<Tree>
+  <CompilationUnit>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Literal>
+        <Literal value=""test"">test</Literal>
+      </Literal>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <CatchAll>
+          <AsteriskToken>*</AsteriskToken>
+        </CatchAll>
+        <ParameterName>
+          <ParameterNameToken value=""a"">a</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""b"">b</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <EndOfFile />
+  </CompilationUnit>
+  <Diagnostics>
+    <Diagnostic Message=""A catch-all parameter can only appear as the last segment of the route template."" Span=""[15..19)"" Text=""{*a}"" />
+  </Diagnostics>
+  <Parameters>
+    <Parameter Name=""a"" IsCatchAll=""true"" IsOptional=""false"" EncodeSlashes=""true"" />
+    <Parameter Name=""b"" IsCatchAll=""false"" IsOptional=""false"" EncodeSlashes=""true"" />
+  </Parameters>
+</Tree>", routePatternOptions: RoutePatternOptions.ComponentsRoute);
+    }
+
+    [Fact]
+    public void InvalidTemplate_BadOptionalCharacterPosition()
+    {
+        Test(@"""/test/{a?bc}/{b}""", @"<Tree>
+  <CompilationUnit>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Literal>
+        <Literal value=""test"">test</Literal>
+      </Literal>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""a?bc"">a?bc</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <Seperator>
+      <SlashToken>/</SlashToken>
+    </Seperator>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""b"">b</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <EndOfFile />
+  </CompilationUnit>
+  <Diagnostics>
+    <Diagnostic Message=""The route parameter name 'a?bc' is invalid. Route parameter names must be non-empty and cannot contain these characters: '{', '}', '/'. The '?' character marks a parameter as optional, and can occur only at the end of the parameter. The '*' character marks a parameter as catch-all, and can occur only at the start of the parameter."" Span=""[16..20)"" Text=""a?bc"" />
+  </Diagnostics>
+  <Parameters>
+    <Parameter Name=""a?bc"" IsCatchAll=""false"" IsOptional=""false"" EncodeSlashes=""true"" />
+    <Parameter Name=""b"" IsCatchAll=""false"" IsOptional=""false"" EncodeSlashes=""true"" />
+  </Parameters>
+</Tree>", routePatternOptions: RoutePatternOptions.ComponentsRoute);
+    }
+
+    [Fact]
+    public void Components_TestParameterWithDefault()
+    {
+        Test(@"""{id=Home}""", @"<Tree>
+  <CompilationUnit>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""id"">id</ParameterNameToken>
+        </ParameterName>
+        <DefaultValue>
+          <EqualsToken>=</EqualsToken>
+          <DefaultValueToken value=""Home"">Home</DefaultValueToken>
+        </DefaultValue>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <EndOfFile />
+  </CompilationUnit>
+  <Diagnostics>
+    <Diagnostic Message=""A parameter with a default value isn't supported."" Span=""[12..17)"" Text=""=Home"" />
+  </Diagnostics>
+  <Parameters>
+    <Parameter Name=""id"" IsCatchAll=""false"" IsOptional=""false"" EncodeSlashes=""true"" DefaultValue=""Home"" />
+  </Parameters>
+</Tree>", routePatternOptions: RoutePatternOptions.ComponentsRoute, allowDiagnosticsMismatch: true);
+    }
+
+    [Fact]
+    public void Components_Parse_ComplexSegment_OptionalParameterFollowingPeriod()
+    {
+        Test(@"""{p1}.{p2?}""", @"<Tree>
+  <CompilationUnit>
+    <Segment>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""p1"">p1</ParameterNameToken>
+        </ParameterName>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+      <Literal>
+        <Literal value=""."">.</Literal>
+      </Literal>
+      <Parameter>
+        <OpenBraceToken>{</OpenBraceToken>
+        <ParameterName>
+          <ParameterNameToken value=""p2"">p2</ParameterNameToken>
+        </ParameterName>
+        <Optional>
+          <QuestionMarkToken>?</QuestionMarkToken>
+        </Optional>
+        <CloseBraceToken>}</CloseBraceToken>
+      </Parameter>
+    </Segment>
+    <EndOfFile />
+  </CompilationUnit>
+  <Diagnostics>
+    <Diagnostic Message=""Complex segment is not supported."" Span=""[9..19)"" Text=""{p1}.{p2?}"" />
+  </Diagnostics>
+  <Parameters>
+    <Parameter Name=""p1"" IsCatchAll=""false"" IsOptional=""false"" EncodeSlashes=""true"" />
+    <Parameter Name=""p2"" IsCatchAll=""false"" IsOptional=""true"" EncodeSlashes=""true"" />
+  </Parameters>
+</Tree>", routePatternOptions: RoutePatternOptions.ComponentsRoute, allowDiagnosticsMismatch: true);
+    }
+
+}

+ 20 - 18
src/Framework/AspNetCoreAnalyzers/test/RouteEmbeddedLanguage/RoutePatternParserTests_ReplacementTests.cs

@@ -3,6 +3,8 @@
 
 #nullable disable
 
+using Microsoft.AspNetCore.Analyzers.Infrastructure.RoutePattern;
+
 namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage;
 
 // These tests are mirrored from routing's AttributeRouteModelTests.cs
@@ -23,7 +25,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute);
     }
 
     [Fact]
@@ -39,7 +41,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute);
     }
 
     [Fact]
@@ -60,7 +62,7 @@ public partial class RoutePatternParserTests
     <Diagnostic Message=""A replacement token is not closed."" Span=""[20..20)"" Text="""" />
   </Diagnostics>
   <Parameters />
-</Tree>", runReplaceTokens: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute);
     }
 
     [Fact]
@@ -81,7 +83,7 @@ public partial class RoutePatternParserTests
     <Diagnostic Message=""An unescaped '[' token is not allowed inside of a replacement token. Use '[[' to escape."" Span=""[10..25)"" Text=""cont[controller"" />
   </Diagnostics>
   <Parameters />
-</Tree>", runReplaceTokens: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute);
     }
 
     [Fact]
@@ -102,7 +104,7 @@ public partial class RoutePatternParserTests
     <Diagnostic Message=""An empty replacement token ('[]') is not allowed."" Span=""[10..11)"" Text=""]"" />
   </Diagnostics>
   <Parameters />
-</Tree>", runReplaceTokens: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute);
     }
 
     [Fact]
@@ -121,7 +123,7 @@ public partial class RoutePatternParserTests
     <Diagnostic Message=""Token delimiters ('[', ']') are imbalanced."" Span=""[9..10)"" Text=""]"" />
   </Diagnostics>
   <Parameters />
-</Tree>", runReplaceTokens: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute);
     }
 
     [Fact]
@@ -144,7 +146,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute);
     }
 
     [Fact]
@@ -172,7 +174,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute);
     }
 
     [Fact]
@@ -193,7 +195,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute);
     }
 
     [Fact]
@@ -214,7 +216,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute);
     }
 
     [Fact]
@@ -238,7 +240,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true, allowDiagnosticsMismatch: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute, allowDiagnosticsMismatch: true);
     }
 
     [Fact]
@@ -266,7 +268,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true, allowDiagnosticsMismatch: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute, allowDiagnosticsMismatch: true);
     }
 
     [Fact]
@@ -308,7 +310,7 @@ public partial class RoutePatternParserTests
   <Parameters>
     <Parameter Name=""id"" IsCatchAll=""false"" IsOptional=""false"" EncodeSlashes=""true"" />
   </Parameters>
-</Tree>", runReplaceTokens: true, allowDiagnosticsMismatch: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute, allowDiagnosticsMismatch: true);
     }
 
     [Fact]
@@ -350,7 +352,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true, allowDiagnosticsMismatch: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute, allowDiagnosticsMismatch: true);
     }
 
     [Fact]
@@ -392,7 +394,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true, allowDiagnosticsMismatch: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute, allowDiagnosticsMismatch: true);
     }
 
     [Fact]
@@ -434,7 +436,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true, allowDiagnosticsMismatch: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute, allowDiagnosticsMismatch: true);
     }
 
     [Fact]
@@ -476,7 +478,7 @@ public partial class RoutePatternParserTests
     <EndOfFile />
   </CompilationUnit>
   <Parameters />
-</Tree>", runReplaceTokens: true, allowDiagnosticsMismatch: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute, allowDiagnosticsMismatch: true);
     }
 
     [Fact]
@@ -495,6 +497,6 @@ public partial class RoutePatternParserTests
     <Diagnostic Message=""Token delimiters ('[', ']') are imbalanced."" Span=""[9..20)"" Text=""controller]"" />
   </Diagnostics>
   <Parameters />
-</Tree>", runReplaceTokens: true);
+</Tree>", routePatternOptions: RoutePatternOptions.MvcAttributeRoute);
     }
 }

+ 19 - 6
src/Framework/AspNetCoreAnalyzers/test/TestDiagnosticAnalyzer.cs

@@ -14,7 +14,6 @@ using Microsoft.CodeAnalysis.Host.Mef;
 using Microsoft.CodeAnalysis.Testing;
 using Microsoft.CodeAnalysis.Text;
 using Microsoft.VisualStudio.Composition;
-using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Resources;
 
 namespace Microsoft.AspNetCore.Analyzers;
 
@@ -66,17 +65,31 @@ internal class TestDiagnosticAnalyzerRunner : DiagnosticAnalyzerRunner
         }
     }
 
-    private async Task<(bool success, SyntaxToken token, SemanticModel model, RouteOptions options)> TryGetStringSyntaxTokenAtPositionAsync(int caretPosition, params string[] sources)
+    private async Task<(SyntaxToken token, SemanticModel model)> TryGetStringSyntaxTokenAtPositionAsync(int caretPosition, params string[] sources)
     {
         var project = CreateProjectWithReferencesInBinDir(GetType().Assembly, sources);
-        var doc = project.Solution.GetDocument(project.Documents.First().Id);
+        var document = project.Solution.GetDocument(project.Documents.First().Id);
+
+        var semanticModel = await document.GetSemanticModelAsync(CancellationToken.None).ConfigureAwait(false);
+        if (semanticModel == null)
+        {
+            return default;
+        }
+
+        var root = await document.GetSyntaxRootAsync(CancellationToken.None).ConfigureAwait(false);
+        if (root == null)
+        {
+            return default;
+        }
+
+        var stringToken = root.FindToken(caretPosition);
 
-        return await RouteStringSyntaxDetectorDocument.TryGetStringSyntaxTokenAtPositionAsync(doc, caretPosition, CancellationToken.None);
+        return (token: stringToken, model: semanticModel);
     }
 
     public async Task<AspNetCoreBraceMatchingResult?> GetBraceMatchesAsync(int caretPosition, params string[] sources)
     {
-        var (_, token, model, _) = await TryGetStringSyntaxTokenAtPositionAsync(caretPosition, sources);
+        var (token, model) = await TryGetStringSyntaxTokenAtPositionAsync(caretPosition, sources);
         var braceMatcher = new RoutePatternBraceMatcher();
 
         return braceMatcher.FindBraces(model, token, caretPosition, CancellationToken.None);
@@ -84,7 +97,7 @@ internal class TestDiagnosticAnalyzerRunner : DiagnosticAnalyzerRunner
 
     public async Task<List<AspNetCoreHighlightSpan>> GetHighlightingAsync(int caretPosition, params string[] sources)
     {
-        var (_, token, model, _) = await TryGetStringSyntaxTokenAtPositionAsync(caretPosition, sources);
+        var (token, model) = await TryGetStringSyntaxTokenAtPositionAsync(caretPosition, sources);
         var highlighter = new RoutePatternHighlighter();
 
         var highlights = highlighter.GetDocumentHighlights(model, token, caretPosition, CancellationToken.None);