diff --git a/src/Components/Analyzers/src/ComponentSymbols.cs b/src/Components/Analyzers/src/ComponentSymbols.cs index 52b9c175ea27..2a6f0a740fcf 100644 --- a/src/Components/Analyzers/src/ComponentSymbols.cs +++ b/src/Components/Analyzers/src/ComponentSymbols.cs @@ -51,6 +51,8 @@ public static bool TryCreate(Compilation compilation, out ComponentSymbols symbo var supplyParameterFromFormAttribute = compilation.GetTypeByMetadataName(ComponentsApi.SupplyParameterFromFormAttribute.MetadataName); var persistentStateAttribute = compilation.GetTypeByMetadataName(ComponentsApi.PersistentStateAttribute.MetadataName); var componentBaseType = compilation.GetTypeByMetadataName(ComponentsApi.ComponentBase.MetadataName); + var layoutAttribute = compilation.GetTypeByMetadataName(ComponentsApi.LayoutAttribute.MetadataName); + var layoutComponentBase = compilation.GetTypeByMetadataName(ComponentsApi.LayoutComponentBase.MetadataName); symbols = new ComponentSymbols( parameterAttribute, @@ -59,7 +61,9 @@ public static bool TryCreate(Compilation compilation, out ComponentSymbols symbo persistentStateAttribute, componentBaseType, parameterCaptureUnmatchedValuesRuntimeType, - icomponentType); + icomponentType, + layoutAttribute, + layoutComponentBase); return true; } @@ -70,7 +74,9 @@ private ComponentSymbols( INamedTypeSymbol persistentStateAttribute, INamedTypeSymbol componentBaseType, INamedTypeSymbol parameterCaptureUnmatchedValuesRuntimeType, - INamedTypeSymbol icomponentType) + INamedTypeSymbol icomponentType, + INamedTypeSymbol layoutAttribute, + INamedTypeSymbol layoutComponentBase) { ParameterAttribute = parameterAttribute; CascadingParameterAttribute = cascadingParameterAttribute; @@ -79,6 +85,8 @@ private ComponentSymbols( ComponentBaseType = componentBaseType; // Can be null ParameterCaptureUnmatchedValuesRuntimeType = parameterCaptureUnmatchedValuesRuntimeType; IComponentType = icomponentType; + LayoutAttribute = layoutAttribute; // Can be null + LayoutComponentBase = layoutComponentBase; // Can be null } public INamedTypeSymbol ParameterAttribute { get; } @@ -95,4 +103,8 @@ private ComponentSymbols( public INamedTypeSymbol ComponentBaseType { get; } // Can be null if not available public INamedTypeSymbol IComponentType { get; } + + public INamedTypeSymbol LayoutAttribute { get; } // Can be null if not available + + public INamedTypeSymbol LayoutComponentBase { get; } // Can be null if not available } diff --git a/src/Components/Analyzers/src/ComponentsApi.cs b/src/Components/Analyzers/src/ComponentsApi.cs index a2dc5b852c6d..146603736817 100644 --- a/src/Components/Analyzers/src/ComponentsApi.cs +++ b/src/Components/Analyzers/src/ComponentsApi.cs @@ -46,4 +46,17 @@ public static class IComponent public const string FullTypeName = "Microsoft.AspNetCore.Components.IComponent"; public const string MetadataName = FullTypeName; } + + public static class LayoutAttribute + { + public const string FullTypeName = "Microsoft.AspNetCore.Components.LayoutAttribute"; + public const string MetadataName = FullTypeName; + public const string LayoutType = "LayoutType"; + } + + public static class LayoutComponentBase + { + public const string FullTypeName = "Microsoft.AspNetCore.Components.LayoutComponentBase"; + public const string MetadataName = FullTypeName; + } } diff --git a/src/Components/Analyzers/src/DiagnosticDescriptors.cs b/src/Components/Analyzers/src/DiagnosticDescriptors.cs index 5f67edaf8447..2d7663ce4319 100644 --- a/src/Components/Analyzers/src/DiagnosticDescriptors.cs +++ b/src/Components/Analyzers/src/DiagnosticDescriptors.cs @@ -92,4 +92,13 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Warning, isEnabledByDefault: true, description: CreateLocalizableResourceString(nameof(Resources.PersistentStateShouldNotHavePropertyInitializer_Description))); + + public static readonly DiagnosticDescriptor LayoutComponentCannotReferenceItself = new( + "BL0010", + CreateLocalizableResourceString(nameof(Resources.LayoutComponentCannotReferenceItself_Title)), + CreateLocalizableResourceString(nameof(Resources.LayoutComponentCannotReferenceItself_Format)), + Usage, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: CreateLocalizableResourceString(nameof(Resources.LayoutComponentCannotReferenceItself_Description))); } diff --git a/src/Components/Analyzers/src/LayoutCycleAnalyzer.cs b/src/Components/Analyzers/src/LayoutCycleAnalyzer.cs new file mode 100644 index 000000000000..5dc06a0d7b67 --- /dev/null +++ b/src/Components/Analyzers/src/LayoutCycleAnalyzer.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.AspNetCore.Components.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class LayoutCycleAnalyzer : DiagnosticAnalyzer +{ + public LayoutCycleAnalyzer() + { + SupportedDiagnostics = ImmutableArray.Create(new[] + { + DiagnosticDescriptors.LayoutComponentCannotReferenceItself, + }); + } + + public override ImmutableArray SupportedDiagnostics { get; } + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.RegisterCompilationStartAction(context => + { + if (!ComponentSymbols.TryCreate(context.Compilation, out var symbols)) + { + // Types we need are not defined. + return; + } + + // Check if LayoutAttribute and LayoutComponentBase are available + if (symbols.LayoutAttribute == null || symbols.LayoutComponentBase == null) + { + return; + } + + context.RegisterSymbolAction(context => + { + var namedType = (INamedTypeSymbol)context.Symbol; + + // Check if the type inherits from LayoutComponentBase (directly or indirectly) + if (!InheritsFromLayoutComponentBase(namedType, symbols.LayoutComponentBase)) + { + return; + } + + // Check if the type has a LayoutAttribute + var layoutAttribute = namedType.GetAttributes() + .FirstOrDefault(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, symbols.LayoutAttribute)); + + if (layoutAttribute == null) + { + return; + } + + // Get the LayoutType from the attribute constructor argument + if (layoutAttribute.ConstructorArguments.Length > 0) + { + var layoutType = layoutAttribute.ConstructorArguments[0].Value as INamedTypeSymbol; + + // Check if the layout type is the same as the current type (self-reference) + if (layoutType != null && SymbolEqualityComparer.Default.Equals(namedType, layoutType)) + { + var location = namedType.Locations.FirstOrDefault() ?? Location.None; + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.LayoutComponentCannotReferenceItself, + location, + namedType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + } + } + }, SymbolKind.NamedType); + }); + } + + private static bool InheritsFromLayoutComponentBase(INamedTypeSymbol type, INamedTypeSymbol layoutComponentBase) + { + var current = type.BaseType; + while (current != null) + { + if (SymbolEqualityComparer.Default.Equals(current, layoutComponentBase)) + { + return true; + } + current = current.BaseType; + } + return false; + } +} \ No newline at end of file diff --git a/src/Components/Analyzers/src/Resources.resx b/src/Components/Analyzers/src/Resources.resx index 6a23211094aa..28c8e3183992 100644 --- a/src/Components/Analyzers/src/Resources.resx +++ b/src/Components/Analyzers/src/Resources.resx @@ -198,4 +198,13 @@ Property with [PersistentState] should not have initializer + + Layout components cannot reference themselves as their layout, as this would create an infinite rendering loop. + + + Layout component '{0}' has a [Layout] attribute that references itself, which will cause an infinite rendering loop. + + + Layout component cannot reference itself + \ No newline at end of file diff --git a/src/Components/Analyzers/test/LayoutCycleAnalyzerTest.cs b/src/Components/Analyzers/test/LayoutCycleAnalyzerTest.cs new file mode 100644 index 000000000000..8dd8e9e9d478 --- /dev/null +++ b/src/Components/Analyzers/test/LayoutCycleAnalyzerTest.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Analyzer.Testing; + +namespace Microsoft.AspNetCore.Components.Analyzers; + +public class LayoutCycleAnalyzerTest : AnalyzerTestBase +{ + public LayoutCycleAnalyzerTest() + { + Analyzer = new LayoutCycleAnalyzer(); + Runner = new ComponentAnalyzerDiagnosticAnalyzerRunner(Analyzer); + } + + private LayoutCycleAnalyzer Analyzer { get; } + private ComponentAnalyzerDiagnosticAnalyzerRunner Runner { get; } + + [Fact] + public async Task LayoutComponentReferencesSelf_ReportsDiagnostic() + { + // Arrange + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; + +[Layout(typeof(MyLayout))] +public class /*MM*/MyLayout : LayoutComponentBase +{ +}"); + + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + // Assert + var diagnostic = Assert.Single(diagnostics); + Assert.Same(DiagnosticDescriptors.LayoutComponentCannotReferenceItself, diagnostic.Descriptor); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, diagnostic.Location); + } + + [Fact] + public async Task LayoutComponentDoesNotReferenceSelf_NoDiagnostic() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Components; + +public class MyLayout : LayoutComponentBase +{ +}"; + + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + // Assert + Assert.Empty(diagnostics); + } + + [Fact] + public async Task LayoutComponentReferencesOtherLayout_NoDiagnostic() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Components; + +public class MainLayout : LayoutComponentBase +{ +} + +[Layout(typeof(MainLayout))] +public class MyLayout : LayoutComponentBase +{ +}"; + + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + // Assert + Assert.Empty(diagnostics); + } + + [Fact] + public async Task NonLayoutComponent_NoDiagnostic() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Components; + +[Layout(typeof(MyComponent))] +public class MyComponent : ComponentBase +{ +}"; + + // Act + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + // Assert + Assert.Empty(diagnostics); + } +} \ No newline at end of file