diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentDiagnosticFactory.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentDiagnosticFactory.cs index 3e1297b7d65..79da0b886b6 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentDiagnosticFactory.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentDiagnosticFactory.cs @@ -541,6 +541,17 @@ public static RazorDiagnostic CreateBindAttributeParameter_InvalidSyntaxBindSetA () => "Attribute '{0}' can only be used with RazorLanguageVersion 7.0 or higher.", RazorDiagnosticSeverity.Error); + public static readonly RazorDiagnosticDescriptor UnknownMarkupAttribute = + new RazorDiagnosticDescriptor( + $"{DiagnosticPrefix}10021", + () => "The attribute '{0}' does not correspond to any of the parent component's parameters.", + RazorDiagnosticSeverity.Warning); + + public static RazorDiagnostic Create_UnknownMarkupAttribute(string attributeName, SourceSpan? source = null) + { + return RazorDiagnostic.Create(UnknownMarkupAttribute, source ?? SourceSpan.Undefined, attributeName); + } + public static RazorDiagnostic CreateBindAttributeParameter_UnsupportedSyntaxBindGetSet(SourceSpan? source, string attribute) { var diagnostic = RazorDiagnostic.Create( diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentMetadata.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentMetadata.cs index 7af781bc1f6..eedda5e363c 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentMetadata.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentMetadata.cs @@ -124,6 +124,7 @@ public static class Component public const string FullyQualifiedNameMatch = "Components.FullyQualifiedNameMatch"; public const string InitOnlyProperty = "Components.InitOnlyProperty"; + public const string CaptureUnmatchedValues = "Components.CaptureUnmatchedValues"; } public static class EventHandler diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentUnknownAttributeDiagnosticPass.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentUnknownAttributeDiagnosticPass.cs new file mode 100644 index 00000000000..50ac15319e9 --- /dev/null +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentUnknownAttributeDiagnosticPass.cs @@ -0,0 +1,57 @@ +// 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 Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Razor.Language.Components; +internal sealed class ComponentUnknownAttributeDiagnosticPass : ComponentIntermediateNodePassBase, IRazorOptimizationPass +{ + protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) + { + var visitor = new Visitor(); + visitor.Visit(documentNode); + } + + private class Visitor : IntermediateNodeWalker + { + public override void VisitComponent(ComponentIntermediateNode node) + { + // First, check if there is a property of type 'IDictionary' + // with 'CaptureUnmatchedValues' set to 'true' + var component = node.Component; + var hasCaptureUnmatchedValues = false; + var boundComponentAttributes = component.BoundAttributes; + for (var i = 0; i < boundComponentAttributes.Count; i++) + { + var attribute = boundComponentAttributes[i]; + if (attribute.Metadata.TryGetValue(ComponentMetadata.Component.CaptureUnmatchedValues, out var captureUnmatchedValues)) + { + hasCaptureUnmatchedValues = captureUnmatchedValues == "True"; + break; + } + } + + + // If no arbitrary attributes can be accepted by the component, check if all + // the user-specified attribute names map to an underlying property + if (!hasCaptureUnmatchedValues) + { + for (var i = 0; i < node.Children.Count; i++) + { + if (node.Children[i] is ComponentAttributeIntermediateNode attribute && + attribute.AttributeName != null) + { + if (attribute.BoundAttribute == null) + { + attribute.Diagnostics.Add(ComponentDiagnosticFactory.Create_UnknownMarkupAttribute( + attribute.AttributeName, attribute.Source)); + } + } + } + } + + base.VisitComponent(node); + } + } +} diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/RazorProjectEngine.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/RazorProjectEngine.cs index de07b93c67f..3978aeae1d4 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/RazorProjectEngine.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/RazorProjectEngine.cs @@ -262,6 +262,7 @@ private static void AddComponentFeatures(RazorProjectEngineBuilder builder, Razo builder.Features.Add(new ComponentMarkupDiagnosticPass()); builder.Features.Add(new ComponentMarkupBlockPass()); builder.Features.Add(new ComponentMarkupEncodingPass()); + builder.Features.Add(new ComponentUnknownAttributeDiagnosticPass()); } private static void LoadExtensions(RazorProjectEngineBuilder builder, IReadOnlyList extensions) diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/ComponentUnknownAttributeDiagnosticPassTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/ComponentUnknownAttributeDiagnosticPassTest.cs new file mode 100644 index 00000000000..b859ec94598 --- /dev/null +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/ComponentUnknownAttributeDiagnosticPassTest.cs @@ -0,0 +1,242 @@ +// 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.Linq; +using Microsoft.AspNetCore.Razor.Language.Components; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Language.IntegrationTests; + +public class ComponentUnknownAttributeDiagnosticPassTest : RazorIntegrationTestBase +{ + public ComponentUnknownAttributeDiagnosticPassTest() + { + Pass = new ComponentUnknownAttributeDiagnosticPass(); + ProjectEngine = (DefaultRazorProjectEngine)RazorProjectEngine.Create( + RazorConfiguration.Default, + RazorProjectFileSystem.Create(Environment.CurrentDirectory), + b => + { + // Don't run the markup mutating passes. + b.Features.Remove(b.Features.OfType().Single()); + b.Features.Remove(b.Features.OfType().Single()); + b.Features.Remove(b.Features.OfType().Single()); + }); + Engine = ProjectEngine.Engine; + + Pass.Engine = Engine; + } + + private DefaultRazorProjectEngine ProjectEngine { get; } + private RazorEngine Engine { get; } + private ComponentUnknownAttributeDiagnosticPass Pass { get; set; } + internal override string FileKind => FileKinds.Component; + internal override bool UseTwoPhaseCompilation => true; + + [Fact] + public void Execute_NoInvalidAttributes() + { + // Arrange + AdditionalSyntaxTrees.Add(Parse(@" +using System; +using Microsoft.AspNetCore.Components; + +namespace Test +{ + public class MyComponent : ComponentBase + { + [Parameter] public int Value { get; set; } + } +} +")); + var result = CompileToCSharp(@""); + var document = result.CodeDocument; + var documentNode = Lower(document); + + // Act + Pass.Execute(document, documentNode); + + // Assert + Assert.Empty(documentNode.GetAllDiagnostics()); + } + + [Fact] + public void Execute_AttributeBinding() + { + // Arrange + AdditionalSyntaxTrees.Add(Parse(@" +using System; +using Microsoft.AspNetCore.Components; + +namespace Test +{ + public class MyComponent : ComponentBase + { + [Parameter] public int Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + } +} +")); + var result = CompileToCSharp(@" + +@code { + private int _value = 0; +} +"); + var document = result.CodeDocument; + var documentNode = Lower(document); + + // Act + Pass.Execute(document, documentNode); + + // Assert + Assert.Empty(documentNode.GetAllDiagnostics()); + } + + [Fact] + public void Execute_OneInvalidAttribute() + { + // Arrange + AdditionalSyntaxTrees.Add(Parse(@" +using System; +using Microsoft.AspNetCore.Components; + +namespace Test +{ + public class MyComponent : ComponentBase + { + [Parameter] public int Value { get; set; } + } +} +")); + var result = CompileToCSharp(@""); + var document = result.CodeDocument; + var documentNode = Lower(document); + + // Act + Pass.Execute(document, documentNode); + + // Assert + var diagnostic = Assert.Single(documentNode.GetAllDiagnostics()); + Assert.Equal(ComponentDiagnosticFactory.UnknownMarkupAttribute.Id, diagnostic.Id); + + var node = documentNode.FindDescendantNodes().Where(n => n.HasDiagnostics).Single(); + Assert.Equal("InvalidAttribute", node.AttributeName); + } + + [Fact] + public void Execute_CaptureAdditionalAttributes() + { + // Arrange + AdditionalSyntaxTrees.Add(Parse(@" +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Components; + +namespace Test +{ + public class MyComponent : ComponentBase + { + [Parameter] public int Value { get; set; } + + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary AdditionalAttributes { get; set; } + } +} +")); + var result = CompileToCSharp(@""); + var document = result.CodeDocument; + var documentNode = Lower(document); + + // Act + Pass.Execute(document, documentNode); + + // Assert + Assert.Empty(documentNode.GetAllDiagnostics()); + } + + [Fact] + public void Execute_DoNotCaptureAdditionalAttributes() + { + // Arrange + AdditionalSyntaxTrees.Add(Parse(@" +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Components; + +namespace Test +{ + public class MyComponent : ComponentBase + { + [Parameter] public int Value { get; set; } + + [Parameter(CaptureUnmatchedValues = false)] + public IDictionary AdditionalAttributes { get; set; } + } +} +")); + var result = CompileToCSharp(@""); + var document = result.CodeDocument; + var documentNode = Lower(document); + + // Act + Pass.Execute(document, documentNode); + + // Assert + Assert.NotEmpty(documentNode.GetAllDiagnostics()); + } + + [Fact] + public void Execute_CaptureAdditionalAttributes_PartialComponentClass() + { + // Arrange + AdditionalSyntaxTrees.Add(Parse(@" +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Components; + +namespace Test +{ + public partial class MyComponent : ComponentBase + { + [Parameter] public int Value { get; set; } + } + + public partial class MyComponent + { + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary AdditionalAttributes { get; set; } + } +} +")); + var result = CompileToCSharp(@""); + var document = result.CodeDocument; + var documentNode = Lower(document); + + // Act + Pass.Execute(document, documentNode); + + // Assert + Assert.Empty(documentNode.GetAllDiagnostics()); + } + + private DocumentIntermediateNode Lower(RazorCodeDocument codeDocument) + { + for (var i = 0; i < Engine.Phases.Count; i++) + { + var phase = Engine.Phases[i]; + if (phase is IRazorCSharpLoweringPhase) + { + break; + } + + phase.Execute(codeDocument); + } + + var document = codeDocument.GetDocumentIntermediateNode(); + Engine.Features.OfType().Single().Execute(codeDocument, document); + return document; + } +} diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs index e026ddad4db..6138b3bb78d 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs @@ -67,6 +67,7 @@ private static void AssertDefaultFeatures(RazorProjectEngine engine) feature => Assert.IsType(feature), feature => Assert.IsType(feature), feature => Assert.IsType(feature), + feature => Assert.IsType(feature), feature => Assert.IsType(feature), feature => Assert.IsType(feature), feature => Assert.IsType(feature), diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor/src/ComponentTagHelperDescriptorProvider.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor/src/ComponentTagHelperDescriptorProvider.cs index 4623c84d474..061c1e5604e 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor/src/ComponentTagHelperDescriptorProvider.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor/src/ComponentTagHelperDescriptorProvider.cs @@ -203,6 +203,19 @@ private static void CreateProperty(TagHelperDescriptorBuilder builder, IProperty pb.IsEditorRequired = property.GetAttributes().Any( static a => a.AttributeClass.HasFullName("Microsoft.AspNetCore.Components.EditorRequiredAttribute")); + // Check if the parameter sets 'CaptureUnmatchedValues' to 'true' + var propertyAttribute = property.GetAttributes() + .FirstOrDefault(a => a.AttributeClass.HasFullName("Microsoft.AspNetCore.Components.ParameterAttribute")); + if (propertyAttribute != null) + { + var captureUnmatchedValuesParameter = propertyAttribute.NamedArguments + .FirstOrDefault(a => a.Key == "CaptureUnmatchedValues"); + if (captureUnmatchedValuesParameter is { Value.Value: true }) + { + metadata.Add(MakeTrue(ComponentMetadata.Component.CaptureUnmatchedValues)); + } + } + metadata.Add(PropertyName(property.Name)); metadata.Add(GloballyQualifiedTypeName(property.Type.ToDisplayString(GloballyQualifiedFullNameTypeDisplayFormat)));