Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Compiler Warning for Unknown Attributes in Razor Components #9144

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, object>'
// 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<RazorExtension> extensions)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ComponentMarkupDiagnosticPass>().Single());
b.Features.Remove(b.Features.OfType<ComponentMarkupBlockPass>().Single());
b.Features.Remove(b.Features.OfType<ComponentMarkupEncodingPass>().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(@"<MyComponent Value=""123"" />");
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<int> ValueChanged { get; set; }
}
}
"));
var result = CompileToCSharp(@"
<MyComponent @bind-Value=""@_value"" />
@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(@"<MyComponent InvalidAttribute=""123"" />");
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<ComponentAttributeIntermediateNode>().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<string, object> AdditionalAttributes { get; set; }
}
}
"));
var result = CompileToCSharp(@"<MyComponent InvalidAttribute=""123"" />");
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<string, object> AdditionalAttributes { get; set; }
}
}
"));
var result = CompileToCSharp(@"<MyComponent InvalidAttribute=""123"" />");
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<string, object> AdditionalAttributes { get; set; }
}
}
"));
var result = CompileToCSharp(@"<MyComponent InvalidAttribute=""123"" />");
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<ComponentDocumentClassifierPass>().Single().Execute(codeDocument, document);
return document;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ private static void AssertDefaultFeatures(RazorProjectEngine engine)
feature => Assert.IsType<ComponentReferenceCaptureLoweringPass>(feature),
feature => Assert.IsType<ComponentSplatLoweringPass>(feature),
feature => Assert.IsType<ComponentTemplateDiagnosticPass>(feature),
feature => Assert.IsType<ComponentUnknownAttributeDiagnosticPass>(feature),
feature => Assert.IsType<ComponentWhitespacePass>(feature),
feature => Assert.IsType<DefaultDirectiveSyntaxTreePass>(feature),
feature => Assert.IsType<DefaultDocumentClassifierPass>(feature),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)));

Expand Down
Loading