From 32e864a69b9d3dea2b1392e8bba6018a62242c05 Mon Sep 17 00:00:00 2001 From: SparkyTD <45818400+SparkyTD@users.noreply.github.com> Date: Mon, 21 Aug 2023 20:47:20 +0300 Subject: [PATCH 1/5] Introduced a new Warning diagnostic "RZ10021" This diagnostic will be added to Razor component attributes that cannot be mapped to a Property in the underlying Component --- .../src/Components/ComponentDiagnosticFactory.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) 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( From c8754b61ea44e433f58d16591448779101441cc5 Mon Sep 17 00:00:00 2001 From: SparkyTD <45818400+SparkyTD@users.noreply.github.com> Date: Mon, 21 Aug 2023 20:48:55 +0300 Subject: [PATCH 2/5] Introduced ComponentUnknownAttributeDiagnosticPass This new intermediate pass will check for any component attributes that cannot be mapped to a valid property in the underlying component type, for the purpose of issuing a warning diagnostic. --- ...ComponentUnknownAttributeDiagnosticPass.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentUnknownAttributeDiagnosticPass.cs 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..5f6a6a8bd9d --- /dev/null +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentUnknownAttributeDiagnosticPass.cs @@ -0,0 +1,47 @@ +// 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.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 boundComponentAttributes = component.BoundAttributes; + for (var i = 0; i < boundComponentAttributes.Count; i++) + { + var attribute = boundComponentAttributes[i]; + // [HELP NEEDED] I would need to access component Type information here in order to check for CaptureUnmatchedValues + } + + + // If no arbitrary attributes can be accepted by the component, check if all + // the user-specified attribute names map to an underlying property + 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); + } + } +} From 6fa6f0b86d2a1010878b15647cfed1a632ab73f8 Mon Sep 17 00:00:00 2001 From: SparkyTD <45818400+SparkyTD@users.noreply.github.com> Date: Mon, 21 Aug 2023 20:49:05 +0300 Subject: [PATCH 3/5] Added test cases for ComponentUnknownAttributeDiagnosticPass --- ...onentUnknownAttributeDiagnosticPassTest.cs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/ComponentUnknownAttributeDiagnosticPassTest.cs 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..18fa682350d --- /dev/null +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/ComponentUnknownAttributeDiagnosticPassTest.cs @@ -0,0 +1,148 @@ +// 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.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language.Components; +using Microsoft.AspNetCore.Razor.Language.IntegrationTests; +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_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()); + } + + 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; + } +} From 47e0d3a96e327ac6f3f58974c13fa73dd1eb08bb Mon Sep 17 00:00:00 2001 From: SparkyTD <45818400+SparkyTD@users.noreply.github.com> Date: Mon, 21 Aug 2023 22:00:37 +0300 Subject: [PATCH 4/5] Completed the integration of ComponentUnknownAttributeDiagnosticPass into the Razor Compiler --- .../src/RazorProjectEngine.cs | 1 + ...onentUnknownAttributeDiagnosticPassTest.cs | 37 +++++++++++++++++-- .../test/RazorProjectEngineTest.cs | 1 + 3 files changed, 35 insertions(+), 4 deletions(-) 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 index 18fa682350d..1bbf5392612 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/ComponentUnknownAttributeDiagnosticPassTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/ComponentUnknownAttributeDiagnosticPassTest.cs @@ -2,12 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language.Components; -using Microsoft.AspNetCore.Razor.Language.IntegrationTests; using Microsoft.AspNetCore.Razor.Language.Intermediate; using Xunit; @@ -66,6 +62,39 @@ public class MyComponent : ComponentBase 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() { 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), From 179eeeef87c65c7baefd28654b4266cf89a29d0a Mon Sep 17 00:00:00 2001 From: SparkyTD <45818400+SparkyTD@users.noreply.github.com> Date: Mon, 11 Sep 2023 18:38:57 +0300 Subject: [PATCH 5/5] Added support for CaptureUnmatchedValues This commit attempts to solve the issues regarding the handling of 'CaptureUnmatchedValues' in the diagnostic pass. It does this by storing the value of this attribute parameter in 'BoundAttributeDescriptor.Metadata', where it can be later retrieved by the diagnostic analyzer. --- .../src/Components/ComponentMetadata.cs | 1 + ...ComponentUnknownAttributeDiagnosticPass.cs | 22 +++++-- ...onentUnknownAttributeDiagnosticPassTest.cs | 65 +++++++++++++++++++ .../ComponentTagHelperDescriptorProvider.cs | 13 ++++ 4 files changed, 95 insertions(+), 6 deletions(-) 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 index 5f6a6a8bd9d..50ac15319e9 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentUnknownAttributeDiagnosticPass.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/src/Components/ComponentUnknownAttributeDiagnosticPass.cs @@ -1,6 +1,7 @@ // 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; @@ -19,24 +20,33 @@ 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]; - // [HELP NEEDED] I would need to access component Type information here in order to check for CaptureUnmatchedValues + 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 - for (var i = 0; i < node.Children.Count; i++) + if (!hasCaptureUnmatchedValues) { - if (node.Children[i] is ComponentAttributeIntermediateNode attribute && attribute.AttributeName != null) + for (var i = 0; i < node.Children.Count; i++) { - if (attribute.BoundAttribute == null) + if (node.Children[i] is ComponentAttributeIntermediateNode attribute && + attribute.AttributeName != null) { - attribute.Diagnostics.Add(ComponentDiagnosticFactory.Create_UnknownMarkupAttribute( - attribute.AttributeName, attribute.Source)); + if (attribute.BoundAttribute == null) + { + attribute.Diagnostics.Add(ComponentDiagnosticFactory.Create_UnknownMarkupAttribute( + attribute.AttributeName, attribute.Source)); + } } } } diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/ComponentUnknownAttributeDiagnosticPassTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/ComponentUnknownAttributeDiagnosticPassTest.cs index 1bbf5392612..b859ec94598 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/ComponentUnknownAttributeDiagnosticPassTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/ComponentUnknownAttributeDiagnosticPassTest.cs @@ -157,6 +157,71 @@ public class MyComponent : ComponentBase 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++) 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)));