From 601e7914f7db43f7fc6cc77ab1b50315fc22670f Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Thu, 1 Mar 2018 21:22:57 -0800 Subject: [PATCH] Implement components as tag helpers Implements Component code generation and tooling support end to end udditionally adds some default `@addTagHelper` directives to make programming in Blazor a little nicer. Components are discovered as Tag Helpers using Razor's extensibility during the build/IDE process. This drives the code generation during build and lights up a bunch of editor features. Add --- .../targets/RazorCompilation.targets | 4 +- .../BlazorApi.cs | 7 +- .../BlazorCodeTarget.cs | 4 +- .../BlazorDesignTimeNodeWriter.cs | 499 ++++++++++++++++++ .../BlazorDiagnosticFactory.cs | 37 ++ .../BlazorExtensionInitializer.cs | 1 + .../BlazorImportProjectFeature.cs | 43 +- .../BlazorNodeWriter.cs | 31 ++ ...deWriter.cs => BlazorRuntimeNodeWriter.cs} | 186 ++++++- .../ComponentAttributeExtensionNode.cs | 84 +++ .../ComponentBodyExtensionNode.cs | 69 +++ .../ComponentCloseExtensionNode.cs | 40 ++ .../ComponentLoweringPass.cs | 135 +++++ .../ComponentOpenExtensionNode.cs | 42 ++ .../ComponentTagHelperDescriptorProvider.cs | 23 +- .../ScopeStack.cs | 2 +- ...elperBoundAttributeDescriptorExtensions.cs | 24 + .../RenderTree/RenderTreeFrame.cs | 31 +- .../ComponentDiscoveryRazorIntegrationTest.cs | 28 +- .../ComponentRenderingRazorIntegrationTest.cs | 329 +++++++++--- .../DeclarationRazorIntegrationTest.cs | 1 - ...nTimeCodeGenerationRazorIntegrationTest.cs | 431 +++++++++++++++ .../RazorIntegrationTestBase.cs | 177 +++++-- .../RenderingRazorIntegrationTest.cs | 28 +- ...ntimeCodeGenerationRazorIntegrationTest.cs | 374 +++++++++++++ ...omponentTagHelperDescriptorProviderTest.cs | 47 ++ test/shared/AssertFrame.cs | 14 +- ...rosoft.VisualStudio.BlazorExtension.csproj | 37 +- ...isualStudio.LanguageServices.Blazor.csproj | 6 + .../RazorTextViewListener.cs | 175 ++++++ 30 files changed, 2729 insertions(+), 180 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDesignTimeNodeWriter.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorNodeWriter.cs rename src/Microsoft.AspNetCore.Blazor.Razor.Extensions/{BlazorIntermediateNodeWriter.cs => BlazorRuntimeNodeWriter.cs} (75%) create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentAttributeExtensionNode.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentBodyExtensionNode.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentCloseExtensionNode.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentOpenExtensionNode.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperBoundAttributeDescriptorExtensions.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/DesignTimeCodeGenerationRazorIntegrationTest.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/RuntimeCodeGenerationRazorIntegrationTest.cs create mode 100644 tooling/Microsoft.VisualStudio.LanguageServices.Blazor/RazorTextViewListener.cs diff --git a/src/Microsoft.AspNetCore.Blazor.Build/targets/RazorCompilation.targets b/src/Microsoft.AspNetCore.Blazor.Build/targets/RazorCompilation.targets index 3714d48c9c..015714a36d 100644 --- a/src/Microsoft.AspNetCore.Blazor.Build/targets/RazorCompilation.targets +++ b/src/Microsoft.AspNetCore.Blazor.Build/targets/RazorCompilation.targets @@ -39,7 +39,7 @@ - <_BlazorTempAssembly Include="$(IntermediateOutputPath)$(TargetName).BlazorTemp.dll" /> + <_BlazorTempAssembly Include="$(IntermediateOutputPath)BlazorTemp\$(TargetName).dll" /> @@ -294,6 +294,8 @@ Outputs="@(_BlazorTempAssembly);$(NonExistentFile)" Condition="'$(DesignTimeBuild)'!='true'"> + + diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs index ed5d666141..e324c4ff64 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorApi.cs @@ -46,7 +46,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor public static readonly string OpenComponent = nameof(OpenComponent); - public static readonly string CloseComponent = nameof(CloseElement); + public static readonly string CloseComponent = nameof(CloseComponent); public static readonly string AddContent = nameof(AddContent); @@ -65,5 +65,10 @@ namespace Microsoft.AspNetCore.Blazor.Razor public static readonly string SetValue = "Microsoft.AspNetCore.Blazor.Components.BindMethods.SetValue"; } + + public static class UIEventHandler + { + public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.UIEventHandler"; + } } } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorCodeTarget.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorCodeTarget.cs index 13d3e3ed41..9e52a73666 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorCodeTarget.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorCodeTarget.cs @@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Razor.Language.CodeGeneration; namespace Microsoft.AspNetCore.Blazor.Razor { /// - /// Directs a to use . + /// Directs a to use . /// internal class BlazorCodeTarget : CodeTarget { @@ -25,7 +25,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor public override IntermediateNodeWriter CreateNodeWriter() { - return _options.DesignTime ? (IntermediateNodeWriter)new DesignTimeNodeWriter() : new BlazorIntermediateNodeWriter(); + return _options.DesignTime ? (BlazorNodeWriter)new BlazorDesignTimeNodeWriter() : new BlazorRuntimeNodeWriter(); } public override TExtension GetExtension() diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDesignTimeNodeWriter.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDesignTimeNodeWriter.cs new file mode 100644 index 0000000000..2d42519b9d --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDesignTimeNodeWriter.cs @@ -0,0 +1,499 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.CodeGeneration; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + // Based on the DesignTimeNodeWriter from Razor repo. + internal class BlazorDesignTimeNodeWriter : BlazorNodeWriter + { + private readonly ScopeStack _scopeStack = new ScopeStack(); + + private readonly static string DesignTimeVariable = "__o"; + + public override void WriteUsingDirective(CodeRenderingContext context, UsingDirectiveIntermediateNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + if (node.Source.HasValue) + { + using (context.CodeWriter.BuildLinePragma(node.Source.Value)) + { + context.AddSourceMappingFor(node); + context.CodeWriter.WriteUsing(node.Content); + } + } + else + { + context.CodeWriter.WriteUsing(node.Content); + } + } + + public override void WriteCSharpExpression(CodeRenderingContext context, CSharpExpressionIntermediateNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + if (node.Children.Count == 0) + { + return; + } + + if (node.Source != null) + { + using (context.CodeWriter.BuildLinePragma(node.Source.Value)) + { + var offset = DesignTimeVariable.Length + " = ".Length; + context.CodeWriter.WritePadding(offset, node.Source, context); + context.CodeWriter.WriteStartAssignment(DesignTimeVariable); + + for (var i = 0; i < node.Children.Count; i++) + { + if (node.Children[i] is IntermediateToken token && token.IsCSharp) + { + context.AddSourceMappingFor(token); + context.CodeWriter.Write(token.Content); + } + else + { + // There may be something else inside the expression like a Template or another extension node. + context.RenderNode(node.Children[i]); + } + } + + context.CodeWriter.WriteLine(";"); + } + } + else + { + context.CodeWriter.WriteStartAssignment(DesignTimeVariable); + for (var i = 0; i < node.Children.Count; i++) + { + if (node.Children[i] is IntermediateToken token && token.IsCSharp) + { + context.CodeWriter.Write(token.Content); + } + else + { + // There may be something else inside the expression like a Template or another extension node. + context.RenderNode(node.Children[i]); + } + } + context.CodeWriter.WriteLine(";"); + } + } + + public override void WriteCSharpCode(CodeRenderingContext context, CSharpCodeIntermediateNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + var isWhitespaceStatement = true; + for (var i = 0; i < node.Children.Count; i++) + { + var token = node.Children[i] as IntermediateToken; + if (token == null || !string.IsNullOrWhiteSpace(token.Content)) + { + isWhitespaceStatement = false; + break; + } + } + + IDisposable linePragmaScope = null; + if (node.Source != null) + { + if (!isWhitespaceStatement) + { + linePragmaScope = context.CodeWriter.BuildLinePragma(node.Source.Value); + } + + context.CodeWriter.WritePadding(0, node.Source.Value, context); + } + else if (isWhitespaceStatement) + { + // Don't write whitespace if there is no line mapping for it. + return; + } + + for (var i = 0; i < node.Children.Count; i++) + { + if (node.Children[i] is IntermediateToken token && token.IsCSharp) + { + context.AddSourceMappingFor(token); + context.CodeWriter.Write(token.Content); + } + else + { + // There may be something else inside the statement like an extension node. + context.RenderNode(node.Children[i]); + } + } + + if (linePragmaScope != null) + { + linePragmaScope.Dispose(); + } + else + { + context.CodeWriter.WriteLine(); + } + } + + public override void WriteHtmlAttribute(CodeRenderingContext context, HtmlAttributeIntermediateNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + context.RenderChildren(node); + } + + public override void WriteHtmlAttributeValue(CodeRenderingContext context, HtmlAttributeValueIntermediateNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + context.RenderChildren(node); + } + + public override void WriteCSharpExpressionAttributeValue(CodeRenderingContext context, CSharpExpressionAttributeValueIntermediateNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + if (node.Children.Count == 0) + { + return; + } + + var firstChild = node.Children[0]; + if (firstChild.Source != null) + { + using (context.CodeWriter.BuildLinePragma(firstChild.Source.Value)) + { + var offset = DesignTimeVariable.Length + " = ".Length; + context.CodeWriter.WritePadding(offset, firstChild.Source, context); + context.CodeWriter.WriteStartAssignment(DesignTimeVariable); + + for (var i = 0; i < node.Children.Count; i++) + { + if (node.Children[i] is IntermediateToken token && token.IsCSharp) + { + context.AddSourceMappingFor(token); + context.CodeWriter.Write(token.Content); + } + else + { + // There may be something else inside the expression like a Template or another extension node. + context.RenderNode(node.Children[i]); + } + } + + context.CodeWriter.WriteLine(";"); + } + } + else + { + context.CodeWriter.WriteStartAssignment(DesignTimeVariable); + for (var i = 0; i < node.Children.Count; i++) + { + if (node.Children[i] is IntermediateToken token && token.IsCSharp) + { + if (token.Source != null) + { + context.AddSourceMappingFor(token); + } + + context.CodeWriter.Write(token.Content); + } + else + { + // There may be something else inside the expression like a Template or another extension node. + context.RenderNode(node.Children[i]); + } + } + context.CodeWriter.WriteLine(";"); + } + } + + public override void WriteCSharpCodeAttributeValue(CodeRenderingContext context, CSharpCodeAttributeValueIntermediateNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + for (var i = 0; i < node.Children.Count; i++) + { + if (node.Children[i] is IntermediateToken token && token.IsCSharp) + { + IDisposable linePragmaScope = null; + var isWhitespaceStatement = string.IsNullOrWhiteSpace(token.Content); + + if (token.Source != null) + { + if (!isWhitespaceStatement) + { + linePragmaScope = context.CodeWriter.BuildLinePragma(token.Source.Value); + } + + context.CodeWriter.WritePadding(0, token.Source.Value, context); + } + else if (isWhitespaceStatement) + { + // Don't write whitespace if there is no line mapping for it. + continue; + } + + context.AddSourceMappingFor(token); + context.CodeWriter.Write(token.Content); + + if (linePragmaScope != null) + { + linePragmaScope.Dispose(); + } + else + { + context.CodeWriter.WriteLine(); + } + } + else + { + // There may be something else inside the statement like an extension node. + context.RenderNode(node.Children[i]); + } + } + } + + public override void WriteHtmlContent(CodeRenderingContext context, HtmlContentIntermediateNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + // Do nothing + } + + public override void BeginWriteAttribute(CodeWriter codeWriter, string key) + { + codeWriter + .WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(BlazorApi.RenderTreeBuilder.AddAttribute)}") + .Write("-1") + .WriteParameterSeparator() + .WriteStringLiteral(key) + .WriteParameterSeparator(); + } + + public override void WriteComponentOpen(CodeRenderingContext context, ComponentOpenExtensionNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + // Do nothing + } + + public override void WriteComponentClose(CodeRenderingContext context, ComponentCloseExtensionNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + // Do nothing + } + + public override void WriteComponentBody(CodeRenderingContext context, ComponentBodyExtensionNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + // We need to be aware of the blazor scope-tracking concept in design-time code generation + // because each component creates a lambda scope for its child content. + // + // We're hacking it a bit here by just forcing every component to have an empty lambda + _scopeStack.OpenScope(node.TagName, isComponent: true); + _scopeStack.IncrementCurrentScopeChildCount(context); + context.RenderChildren(node); + _scopeStack.CloseScope(context, node.TagName, isComponent: true, source: node.Source); + } + + public override void WriteComponentAttribute(CodeRenderingContext context, ComponentAttributeExtensionNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + // For design time we only care about the case where the attribute has c# code. + // + // We also limit component attributes to simple cases. However there is still a lot of complexity + // to handle here, since there are a few different cases for how an attribute might be structured. + // + // This rougly follows the design of the runtime writer for simplicity. + if (node.AttributeStructure == AttributeStructure.Minimized) + { + // Do nothhing + } + else if ( + node.Children.Count != 1 || + node.Children[0] is HtmlContentIntermediateNode htmlNode && htmlNode.Children.Count != 1 || + node.Children[0] is CSharpExpressionIntermediateNode cSharpNode && cSharpNode.Children.Count != 1) + { + // We don't expect this to happen, we just want to know if it can. + throw new InvalidOperationException("Attribute nodes should either be minimized or a single content node."); + } + else if (node.BoundAttribute.IsUIEventHandlerProperty()) + { + // See the runtime version of this code for a thorough description of what we're doing here + if ((cSharpNode = node.Children[0] as CSharpExpressionIntermediateNode) != null) + { + // This is an escaped event handler + context.CodeWriter.Write(DesignTimeVariable); + context.CodeWriter.Write(" = "); + context.CodeWriter.Write("new "); + context.CodeWriter.Write(node.BoundAttribute.TypeName); + context.CodeWriter.Write("("); + context.CodeWriter.WriteLine(); + WriteCSharpToken(context, ((IntermediateToken)cSharpNode.Children[0])); + context.CodeWriter.Write(");"); + context.CodeWriter.WriteLine(); + } + else + { + context.CodeWriter.Write(DesignTimeVariable); + context.CodeWriter.Write(" = "); + context.CodeWriter.Write("new "); + context.CodeWriter.Write(node.BoundAttribute.TypeName); + context.CodeWriter.Write("(e => "); + WriteCSharpToken(context, ((IntermediateToken)node.Children[0])); + context.CodeWriter.Write(");"); + context.CodeWriter.WriteLine(); + } + } + else if ((cSharpNode = node.Children[0] as CSharpExpressionIntermediateNode) != null) + { + // This is the case when an attribute has an explicit C# transition like: + // + context.CodeWriter.Write(DesignTimeVariable); + context.CodeWriter.Write(" = "); + WriteCSharpToken(context, ((IntermediateToken)cSharpNode.Children[0])); + context.CodeWriter.Write(";"); + context.CodeWriter.WriteLine(); + } + else if ((htmlNode = node.Children[0] as HtmlContentIntermediateNode) != null) + { + // Do nothing + } + else if (node.Children[0] is IntermediateToken token && token.IsCSharp) + { + context.CodeWriter.Write(DesignTimeVariable); + context.CodeWriter.Write(" = "); + WriteCSharpToken(context, token); + context.CodeWriter.Write(";"); + context.CodeWriter.WriteLine(); + } + } + + private void WriteCSharpToken(CodeRenderingContext context, IntermediateToken token) + { + if (string.IsNullOrWhiteSpace(token.Content)) + { + return; + } + + if (token.Source?.FilePath == null) + { + context.CodeWriter.Write(token.Content); + return; + } + + using (context.CodeWriter.BuildLinePragma(token.Source)) + { + context.CodeWriter.WritePadding(0, token.Source.Value, context); + context.AddSourceMappingFor(token); + context.CodeWriter.Write(token.Content); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs index c2b482ef00..66af53b7c7 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs @@ -1,8 +1,11 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Collections.Generic; +using System.Linq; using AngleSharp; using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; namespace Microsoft.AspNetCore.Blazor.Razor { @@ -51,6 +54,40 @@ namespace Microsoft.AspNetCore.Blazor.Razor return RazorDiagnostic.Create(MismatchedClosingTagKind, span ?? SourceSpan.Undefined, tagName, kind, expectedKind); } + public static readonly RazorDiagnosticDescriptor MultipleComponents = new RazorDiagnosticDescriptor( + "BL9985", + () => "Multiple components use the tag '{0}'. Components: {1}", + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic Create_MultipleComponents(SourceSpan? span, string tagName, IEnumerable components) + { + return RazorDiagnostic.Create(MultipleComponents, span ?? SourceSpan.Undefined, tagName, string.Join(", ", components.Select(c => c.DisplayName))); + } + + public static readonly RazorDiagnosticDescriptor UnsupportedComplexContent = new RazorDiagnosticDescriptor( + "BL9986", + () => "Component attributes do not support complex content (mixed C# and markup). Attribute: '{0}', text '{1}'", + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic Create_UnsupportedComplexContent( + SourceSpan? source, + TagHelperPropertyIntermediateNode node, + IntermediateNodeCollection children) + { + var content = string.Join("", children.OfType().Select(t => t.Content)); + return RazorDiagnostic.Create(UnsupportedComplexContent, source ?? SourceSpan.Undefined, node.AttributeName, content); + } + + public static readonly RazorDiagnosticDescriptor UnboundComponentAttribute = new RazorDiagnosticDescriptor( + "BL9987", + () => "The component '{0}' does not have an attribute named '{1}'.", + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic Create_UnboundComponentAttribute(SourceSpan? source, string componentType, TagHelperHtmlAttributeIntermediateNode node) + { + return RazorDiagnostic.Create(UnboundComponentAttribute, source ?? SourceSpan.Undefined, componentType, node.AttributeName); + } + private static SourceSpan? CalculateSourcePosition( SourceSpan? razorTokenPosition, TextPosition htmlNodePosition) diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs index 847cda3e7b..9bc46ac0a0 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs @@ -65,6 +65,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor builder.Features.Add(new ConfigureBlazorCodeGenerationOptions()); builder.Features.Add(new ComponentDocumentClassifierPass()); + builder.Features.Add(new ComponentLoweringPass()); builder.Features.Add(new ComponentTagHelperDescriptorProvider()); diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorImportProjectFeature.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorImportProjectFeature.cs index d79794be42..af943fad46 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorImportProjectFeature.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorImportProjectFeature.cs @@ -14,7 +14,12 @@ namespace Microsoft.AspNetCore.Blazor.Razor { private const string ImportsFileName = "_ViewImports.cshtml"; - public RazorProjectItem DefaultImports => VirtualProjectItem.Instance; + private const string DefaultUsingImportContent = @" +@using System +@using System.Collections.Generic +@using System.Linq +@using System.Threading.Tasks +"; public RazorProjectEngine ProjectEngine { get; set; } @@ -27,9 +32,23 @@ namespace Microsoft.AspNetCore.Blazor.Razor var imports = new List() { - VirtualProjectItem.Instance, + new VirtualProjectItem(DefaultUsingImportContent), + new VirtualProjectItem(@"@addTagHelper ""*, Microsoft.AspNetCore.Blazor"""), }; + // Try and infer a namespace from the project directory. We don't yet have the ability to pass + // the namespace through from the project. + if (projectItem.PhysicalPath != null && projectItem.FilePath != null) + { + var trimLength = projectItem.FilePath.Length + (projectItem.FilePath.StartsWith("/") ? 0 : 1); + var baseDirectory = projectItem.PhysicalPath.Substring(0, projectItem.PhysicalPath.Length - trimLength); + var baseNamespace = Path.GetFileName(baseDirectory); + if (!string.IsNullOrEmpty(baseNamespace)) + { + imports.Add(new VirtualProjectItem($@"@addTagHelper ""*, {baseNamespace}""")); + } + } + // We add hierarchical imports second so any default directive imports can be overridden. imports.AddRange(GetHierarchicalImports(ProjectEngine.FileSystem, projectItem)); @@ -45,22 +64,16 @@ namespace Microsoft.AspNetCore.Blazor.Razor private class VirtualProjectItem : RazorProjectItem { - private readonly byte[] _defaultImportBytes; + private readonly byte[] _bytes; - private VirtualProjectItem() + public VirtualProjectItem(string content) { var preamble = Encoding.UTF8.GetPreamble(); - var content = @" -@using System -@using System.Collections.Generic -@using System.Linq -@using System.Threading.Tasks -"; var contentBytes = Encoding.UTF8.GetBytes(content); - _defaultImportBytes = new byte[preamble.Length + contentBytes.Length]; - preamble.CopyTo(_defaultImportBytes, 0); - contentBytes.CopyTo(_defaultImportBytes, preamble.Length); + _bytes = new byte[preamble.Length + contentBytes.Length]; + preamble.CopyTo(_bytes, 0); + contentBytes.CopyTo(_bytes, preamble.Length); } public override string BasePath => null; @@ -71,9 +84,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor public override bool Exists => true; - public static VirtualProjectItem Instance { get; } = new VirtualProjectItem(); - - public override Stream Read() => new MemoryStream(_defaultImportBytes); + public override Stream Read() => new MemoryStream(_bytes); } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorNodeWriter.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorNodeWriter.cs new file mode 100644 index 0000000000..d60affa49d --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorNodeWriter.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Razor.Language.CodeGeneration; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal abstract class BlazorNodeWriter : IntermediateNodeWriter + { + public sealed override void BeginWriterScope(CodeRenderingContext context, string writer) + { + throw new NotImplementedException(nameof(BeginWriterScope)); + } + + public sealed override void EndWriterScope(CodeRenderingContext context) + { + throw new NotImplementedException(nameof(EndWriterScope)); + } + + public abstract void BeginWriteAttribute(CodeWriter codeWriter, string key); + + public abstract void WriteComponentOpen(CodeRenderingContext context, ComponentOpenExtensionNode node); + + public abstract void WriteComponentClose(CodeRenderingContext context, ComponentCloseExtensionNode node); + + public abstract void WriteComponentBody(CodeRenderingContext context, ComponentBodyExtensionNode node); + + public abstract void WriteComponentAttribute(CodeRenderingContext context, ComponentAttributeExtensionNode node); + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRuntimeNodeWriter.cs similarity index 75% rename from src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs rename to src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRuntimeNodeWriter.cs index ee549536d0..3cd88967a5 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorRuntimeNodeWriter.cs @@ -19,7 +19,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor /// /// Generates the C# code corresponding to Razor source document contents. /// - internal class BlazorIntermediateNodeWriter : IntermediateNodeWriter + internal class BlazorRuntimeNodeWriter : BlazorNodeWriter { // Per the HTML spec, the following elements are inherently self-closing // For example, is the same as (and therefore it cannot contain descendants) @@ -48,16 +48,6 @@ namespace Microsoft.AspNetCore.Blazor.Razor public IntermediateToken AttributeValue; } - public override void BeginWriterScope(CodeRenderingContext context, string writer) - { - throw new System.NotImplementedException(nameof(BeginWriterScope)); - } - - public override void EndWriterScope(CodeRenderingContext context) - { - throw new System.NotImplementedException(nameof(EndWriterScope)); - } - public override void WriteCSharpCode(CodeRenderingContext context, CSharpCodeIntermediateNode node) { var isWhitespaceStatement = true; @@ -373,6 +363,173 @@ namespace Microsoft.AspNetCore.Blazor.Razor } } + public override void WriteUsingDirective(CodeRenderingContext context, UsingDirectiveIntermediateNode node) + { + context.CodeWriter.WriteUsing(node.Content, endLine: true); + } + + public override void WriteComponentOpen(CodeRenderingContext context, ComponentOpenExtensionNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + // The start tag counts as a child from a markup point of view. + _scopeStack.IncrementCurrentScopeChildCount(context); + + // builder.OpenComponent(42); + context.CodeWriter.Write(_scopeStack.BuilderVarName); + context.CodeWriter.Write("."); + context.CodeWriter.Write(BlazorApi.RenderTreeBuilder.OpenComponent); + context.CodeWriter.Write("<"); + context.CodeWriter.Write(node.TypeName); + context.CodeWriter.Write(">("); + context.CodeWriter.Write((_sourceSequence++).ToString()); + context.CodeWriter.Write(");"); + context.CodeWriter.WriteLine(); + } + + public override void WriteComponentClose(CodeRenderingContext context, ComponentCloseExtensionNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + // The close tag counts as a child from a markup point of view. + _scopeStack.IncrementCurrentScopeChildCount(context); + + // builder.OpenComponent(42); + context.CodeWriter.Write(_scopeStack.BuilderVarName); + context.CodeWriter.Write("."); + context.CodeWriter.Write(BlazorApi.RenderTreeBuilder.CloseComponent); + context.CodeWriter.Write("();"); + context.CodeWriter.WriteLine(); + } + + public override void WriteComponentBody(CodeRenderingContext context, ComponentBodyExtensionNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + _scopeStack.OpenScope(node.TagName, isComponent: true); + context.RenderChildren(node); + _scopeStack.CloseScope(context, node.TagName, isComponent: true, source: node.Source); + } + + public override void WriteComponentAttribute(CodeRenderingContext context, ComponentAttributeExtensionNode node) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + // builder.OpenComponent(42); + context.CodeWriter.Write(_scopeStack.BuilderVarName); + context.CodeWriter.Write("."); + context.CodeWriter.Write(BlazorApi.RenderTreeBuilder.AddAttribute); + context.CodeWriter.Write("("); + context.CodeWriter.Write((_sourceSequence++).ToString()); + context.CodeWriter.Write(", "); + context.CodeWriter.WriteStringLiteral(node.AttributeName); + context.CodeWriter.Write(", "); + + if (node.AttributeStructure == AttributeStructure.Minimized) + { + // Minimized attributes always map to 'true' + context.CodeWriter.Write("true"); + } + else if ( + node.Children.Count != 1 || + node.Children[0] is HtmlContentIntermediateNode htmlNode && htmlNode.Children.Count != 1 || + node.Children[0] is CSharpExpressionIntermediateNode cSharpNode && cSharpNode.Children.Count != 1) + { + // We don't expect this to happen, we just want to know if it can. + throw new InvalidOperationException("Attribute nodes should either be minimized or a single content node."); + } + else if (node.BoundAttribute.IsUIEventHandlerProperty()) + { + // This is a UIEventHandler property. We do some special code generation for this + // case so that it's easier to write for common cases. + // + // Example: + // + // --> builder.AddAttribute(X, "OnClick", new UIEventHandler((e) => Foo())); + // + // The constructor is important because we want to put type inference into a state where + // we know the delegate's type should be UIEventHandler. AddAttribute has an overload that + // accepts object, so without the 'new UIEventHandler' things will get ugly. + // + // The escape for this behavior is to prefix the expression with @. This is similar to + // how escaping works for ModelExpression in MVC. + // Example: + // + // --> builder.AddAttribute(X, "OnClick", new UIEventHandler(Foo)); + if ((cSharpNode = node.Children[0] as CSharpExpressionIntermediateNode) != null) + { + // This is an escaped event handler; + context.CodeWriter.Write("new "); + context.CodeWriter.Write(node.BoundAttribute.TypeName); + context.CodeWriter.Write("("); + context.CodeWriter.Write(((IntermediateToken)cSharpNode.Children[0]).Content); + context.CodeWriter.Write(")"); + } + else + { + context.CodeWriter.Write("new "); + context.CodeWriter.Write(node.BoundAttribute.TypeName); + context.CodeWriter.Write("("); + context.CodeWriter.Write("e => "); + context.CodeWriter.Write(((IntermediateToken)node.Children[0]).Content); + context.CodeWriter.Write(")"); + } + } + else if ((cSharpNode = node.Children[0] as CSharpExpressionIntermediateNode) != null) + { + context.CodeWriter.Write(((IntermediateToken)cSharpNode.Children[0]).Content); + } + else if ((htmlNode = node.Children[0] as HtmlContentIntermediateNode) != null) + { + // This is how string attributes are lowered by default, a single HTML node with a single HTML token. + context.CodeWriter.WriteStringLiteral(((IntermediateToken)htmlNode.Children[0]).Content); + } + else if (node.Children[0] is IntermediateToken token) + { + // This is what we expect for non-string nodes. + context.CodeWriter.Write(((IntermediateToken)node.Children[0]).Content); + } + else + { + throw new InvalidOperationException("Unexpected node type " + node.Children[0].GetType().FullName); + } + + context.CodeWriter.Write(");"); + context.CodeWriter.WriteLine(); + } + private SourceSpan? CalculateSourcePosition( SourceSpan? razorTokenPosition, TextPosition htmlNodePosition) @@ -434,7 +591,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor codeWriter.WriteEndMethodInvocation(); } - public void BeginWriteAttribute(CodeWriter codeWriter, string key) + public override void BeginWriteAttribute(CodeWriter codeWriter, string key) { codeWriter .WriteStartMethodInvocation($"{_scopeStack.BuilderVarName}.{nameof(BlazorApi.RenderTreeBuilder.AddAttribute)}") @@ -444,11 +601,6 @@ namespace Microsoft.AspNetCore.Blazor.Razor .WriteParameterSeparator(); } - public override void WriteUsingDirective(CodeRenderingContext context, UsingDirectiveIntermediateNode node) - { - context.CodeWriter.WriteUsing(node.Content, endLine: true); - } - private static string GetContent(HtmlContentIntermediateNode node) { var builder = new StringBuilder(); diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentAttributeExtensionNode.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentAttributeExtensionNode.cs new file mode 100644 index 0000000000..29fe41b10d --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentAttributeExtensionNode.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.CodeGeneration; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal class ComponentAttributeExtensionNode : ExtensionIntermediateNode + { + public ComponentAttributeExtensionNode() + { + } + + public ComponentAttributeExtensionNode(TagHelperPropertyIntermediateNode propertyNode) + { + if (propertyNode == null) + { + throw new ArgumentNullException(nameof(propertyNode)); + } + + AttributeName = propertyNode.AttributeName; + AttributeStructure = propertyNode.AttributeStructure; + BoundAttribute = propertyNode.BoundAttribute; + IsIndexerNameMatch = propertyNode.IsIndexerNameMatch; + Source = propertyNode.Source; + TagHelper = propertyNode.TagHelper; + + for (var i = 0; i < propertyNode.Children.Count; i++) + { + Children.Add(propertyNode.Children[i]); + } + + for (var i = 0; i < propertyNode.Diagnostics.Count; i++) + { + Diagnostics.Add(propertyNode.Diagnostics[i]); + } + } + + public override IntermediateNodeCollection Children { get; } = new IntermediateNodeCollection(); + + public string AttributeName { get; set; } + + public AttributeStructure AttributeStructure { get; set; } + + public BoundAttributeDescriptor BoundAttribute { get; set; } + + public string FieldName { get; set; } + + public bool IsIndexerNameMatch { get; set; } + + public string PropertyName { get; set; } + + public TagHelperDescriptor TagHelper { get; set; } + + public override void Accept(IntermediateNodeVisitor visitor) + { + if (visitor == null) + { + throw new ArgumentNullException(nameof(visitor)); + } + + AcceptExtensionNode(this, visitor); + } + + public override void WriteNode(CodeTarget target, CodeRenderingContext context) + { + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var writer = (BlazorNodeWriter)context.NodeWriter; + writer.WriteComponentAttribute(context, this); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentBodyExtensionNode.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentBodyExtensionNode.cs new file mode 100644 index 0000000000..4ab7c24842 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentBodyExtensionNode.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.CodeGeneration; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + public sealed class ComponentBodyExtensionNode : ExtensionIntermediateNode + { + public ComponentBodyExtensionNode() + { + } + + public ComponentBodyExtensionNode(TagHelperBodyIntermediateNode bodyNode) + { + if (bodyNode == null) + { + throw new ArgumentNullException(nameof(bodyNode)); + } + + Source = bodyNode.Source; + + for (var i = 0; i < bodyNode.Children.Count; i++) + { + Children.Add(bodyNode.Children[i]); + } + + for (var i = 0; i < bodyNode.Diagnostics.Count; i++) + { + Diagnostics.Add(bodyNode.Diagnostics[i]); + } + } + + public override IntermediateNodeCollection Children { get; } = new IntermediateNodeCollection(); + + public TagMode TagMode { get; set; } + + public string TagName { get; set; } + + public override void Accept(IntermediateNodeVisitor visitor) + { + if (visitor == null) + { + throw new ArgumentNullException(nameof(visitor)); + } + + AcceptExtensionNode(this, visitor); + } + + public override void WriteNode(CodeTarget target, CodeRenderingContext context) + { + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var writer = (BlazorNodeWriter)context.NodeWriter; + writer.WriteComponentBody(context, this); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentCloseExtensionNode.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentCloseExtensionNode.cs new file mode 100644 index 0000000000..b02e3bd7ac --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentCloseExtensionNode.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Razor.Language.CodeGeneration; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal class ComponentCloseExtensionNode : ExtensionIntermediateNode + { + public override IntermediateNodeCollection Children => IntermediateNodeCollection.ReadOnly; + + public override void Accept(IntermediateNodeVisitor visitor) + { + if (visitor == null) + { + throw new ArgumentNullException(nameof(visitor)); + } + + AcceptExtensionNode(this, visitor); + } + + public override void WriteNode(CodeTarget target, CodeRenderingContext context) + { + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var writer = (BlazorNodeWriter)context.NodeWriter; + writer.WriteComponentClose(context, this); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs new file mode 100644 index 0000000000..ea7ff5cf3d --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentLoweringPass.cs @@ -0,0 +1,135 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal class ComponentLoweringPass : IntermediateNodePassBase, IRazorOptimizationPass + { + protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) + { + var @namespace = documentNode.FindPrimaryNamespace(); + var @class = documentNode.FindPrimaryClass(); + if (@namespace == null || @class == null) + { + // Nothing to do, bail. We can't function without the standard structure. + return; + } + + // For each component *usage* we need to rewrite the tag helper node to map to the relevant component + // APIs. + var nodes = documentNode.FindDescendantNodes(); + for (var i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + if (node.TagHelpers.Count > 1) + { + node.Diagnostics.Add(BlazorDiagnosticFactory.Create_MultipleComponents(node.Source, node.TagName, node.TagHelpers)); + } + + RewriteUsage(node, node.TagHelpers[0]); + } + } + + private void RewriteUsage(TagHelperIntermediateNode node, TagHelperDescriptor tagHelper) + { + // Ignore Kind here. Some versions of Razor have a bug in the serializer that ignores it. + + // We need to surround the contents of the node with open and close nodes to ensure the component + // is scoped correctly. + node.Children.Insert(0, new ComponentOpenExtensionNode() + { + TypeName = tagHelper.GetTypeName(), + }); + + for (var i = node.Children.Count - 1; i >= 0; i--) + { + if (node.Children[i] is TagHelperBodyIntermediateNode bodyNode) + { + // Replace with a node that we recognize so that it we can do proper scope tracking. + // + // Note that we force the body node to be last, this is done to push it after the + // attribute nodes. This gives us the ordering we want for the render tree. + node.Children.RemoveAt(i); + node.Children.Add(new ComponentBodyExtensionNode(bodyNode) + { + TagMode = node.TagMode, + TagName = node.TagName, + }); + } + } + + node.Children.Add(new ComponentCloseExtensionNode()); + + // Now we need to rewrite any set property nodes to call the appropriate AddAttribute api. + for (var i = node.Children.Count - 1; i >= 0; i--) + { + if (node.Children[i] is TagHelperPropertyIntermediateNode propertyNode && + propertyNode.TagHelper == tagHelper) + { + // We don't support 'complex' content for components (mixed C# and markup) right now. + // It's not clear yet if Blazor will have a good scenario to use these constructs. + // + // This is where a lot of the complexity in the Razor/TagHelpers model creeps in and we + // might be able to avoid it if these features aren't needed. + if (propertyNode.Children.Count == 1 && + propertyNode.Children[0] is HtmlAttributeIntermediateNode htmlNode && + htmlNode.Children.Count > 1) + { + // This case can be hit for a 'string' attribute + node.Diagnostics.Add(BlazorDiagnosticFactory.Create_UnsupportedComplexContent( + propertyNode.Source, + propertyNode, + htmlNode.Children)); + node.Children.RemoveAt(i); + continue; + } + if (propertyNode.Children.Count == 1 && + propertyNode.Children[0] is CSharpExpressionIntermediateNode cSharpNode && + cSharpNode.Children.Count > 1) + { + // This case can be hit when the attribute has an explicit @ inside, which + // 'escapes' any special sugar we provide for codegen. + node.Diagnostics.Add(BlazorDiagnosticFactory.Create_UnsupportedComplexContent( + propertyNode.Source, + propertyNode, + cSharpNode.Children)); + node.Children.RemoveAt(i); + continue; + } + else if (propertyNode.Children.Count > 1) + { + node.Diagnostics.Add(BlazorDiagnosticFactory.Create_UnsupportedComplexContent( + propertyNode.Source, + propertyNode, + propertyNode.Children)); + node.Children.RemoveAt(i); + continue; + } + + node.Children[i] = new ComponentAttributeExtensionNode(propertyNode) + { + PropertyName = propertyNode.BoundAttribute.GetPropertyName(), + }; + } + } + + // Add an error and remove any nodes that don't map to a component property. + for (var i = node.Children.Count - 1; i >= 0; i--) + { + if (node.Children[i] is TagHelperHtmlAttributeIntermediateNode attributeNode) + { + node.Diagnostics.Add(BlazorDiagnosticFactory.Create_UnboundComponentAttribute( + attributeNode.Source, + tagHelper.GetTypeName(), + attributeNode)); + node.Children.RemoveAt(i); + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentOpenExtensionNode.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentOpenExtensionNode.cs new file mode 100644 index 0000000000..c4251b377a --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentOpenExtensionNode.cs @@ -0,0 +1,42 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Razor.Language.CodeGeneration; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal class ComponentOpenExtensionNode : ExtensionIntermediateNode + { + public override IntermediateNodeCollection Children => IntermediateNodeCollection.ReadOnly; + + public string TypeName { get; set; } + + public override void Accept(IntermediateNodeVisitor visitor) + { + if (visitor == null) + { + throw new ArgumentNullException(nameof(visitor)); + } + + AcceptExtensionNode(this, visitor); + } + + public override void WriteNode(CodeTarget target, CodeRenderingContext context) + { + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var writer = (BlazorNodeWriter)context.NodeWriter; + writer.WriteComponentOpen(context, this); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentTagHelperDescriptorProvider.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentTagHelperDescriptorProvider.cs index 21cb10a6eb..e07439a89e 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentTagHelperDescriptorProvider.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentTagHelperDescriptorProvider.cs @@ -11,8 +11,10 @@ namespace Microsoft.AspNetCore.Blazor.Razor { internal class ComponentTagHelperDescriptorProvider : RazorEngineFeatureBase, ITagHelperDescriptorProvider { - public readonly static string ComponentTagHelperKind = ComponentDocumentClassifierPass.ComponentDocumentKind; + public static readonly string UIEventHandlerPropertyMetadata = "Blazor.IsUIEventHandler"; + public readonly static string ComponentTagHelperKind = ComponentDocumentClassifierPass.ComponentDocumentKind; + private static readonly SymbolDisplayFormat FullNameTypeDisplayFormat = SymbolDisplayFormat.FullyQualifiedFormat .WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted) @@ -109,6 +111,11 @@ namespace Microsoft.AspNetCore.Blazor.Razor pb.IsEnum = true; } + if (property.kind == PropertyKind.Delegate) + { + pb.Metadata.Add(UIEventHandlerPropertyMetadata, bool.TrueString); + } + xml = property.property.GetDocumentationCommentXml(); if (!string.IsNullOrEmpty(xml)) { @@ -145,13 +152,13 @@ namespace Microsoft.AspNetCore.Blazor.Razor continue; } - var kind = PropertyKind.Default; if (properties.ContainsKey(property.Name)) { // Not visible - kind = PropertyKind.Ignored; + continue; } + var kind = PropertyKind.Default; if (property.Parameters.Length != 0) { // Indexer @@ -175,6 +182,15 @@ namespace Microsoft.AspNetCore.Blazor.Razor kind = PropertyKind.Enum; } + if (kind == PropertyKind.Default && + property.Type.TypeKind == TypeKind.Delegate && + property.Type.ToDisplayString(FullNameTypeDisplayFormat) == BlazorApi.UIEventHandler.FullTypeName) + { + // For delegate types we do some special code generation when the type + // UIEventHandler. + kind = PropertyKind.Delegate; + } + properties.Add(property.Name, (property, kind)); } @@ -190,6 +206,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor Ignored, Default, Enum, + Delegate, } private class ComponentTypeVisitor : SymbolVisitor diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs index a950a989e9..d6437f2a28 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs @@ -71,7 +71,7 @@ namespace Microsoft.AspNetCore.Blazor.Razor { // When we're about to insert the first child into a component, // it's time to open a new lambda - var blazorNodeWriter = (BlazorIntermediateNodeWriter)context.NodeWriter; + var blazorNodeWriter = (BlazorNodeWriter)context.NodeWriter; blazorNodeWriter.BeginWriteAttribute(context.CodeWriter, BlazorApi.RenderTreeBuilder.ChildContent); OffsetBuilderVarNumber(1); context.CodeWriter.Write($"({BlazorApi.RenderFragment.FullTypeName})("); diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperBoundAttributeDescriptorExtensions.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperBoundAttributeDescriptorExtensions.cs new file mode 100644 index 0000000000..64e7088932 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/TagHelperBoundAttributeDescriptorExtensions.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Razor.Language; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal static class TagHelperBoundAttributeDescriptorExtensions + { + public static bool IsUIEventHandlerProperty(this BoundAttributeDescriptor attribute) + { + if (attribute == null) + { + throw new ArgumentNullException(nameof(attribute)); + } + + var key = ComponentTagHelperDescriptorProvider.UIEventHandlerPropertyMetadata; + return + attribute.Metadata.TryGetValue(key, out var value) && + string.Equals(value, bool.TrueString); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeFrame.cs b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeFrame.cs index 7ee103ebc9..3db409e23f 100644 --- a/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeFrame.cs +++ b/src/Microsoft.AspNetCore.Blazor/RenderTree/RenderTreeFrame.cs @@ -1,9 +1,9 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using Microsoft.AspNetCore.Blazor.Components; using System; using System.Runtime.InteropServices; +using Microsoft.AspNetCore.Blazor.Components; namespace Microsoft.AspNetCore.Blazor.RenderTree { @@ -224,5 +224,34 @@ namespace Microsoft.AspNetCore.Blazor.RenderTree internal RenderTreeFrame WithRegionSubtreeLength(int regionSubtreeLength) => new RenderTreeFrame(Sequence, regionSubtreeLength: regionSubtreeLength); + + // Just to be nice for debugging and unit tests. + public override string ToString() + { + switch (FrameType) + { + case RenderTreeFrameType.Attribute: + return $"Attribute: (seq={Sequence}, id={AttributeEventHandlerId}) '{AttributeName}'='{AttributeValue}'"; + + case RenderTreeFrameType.Component: + return $"Component: (seq={Sequence}, len={ComponentSubtreeLength}) {ComponentType}"; + + case RenderTreeFrameType.Element: + return $"Element: (seq={Sequence}, len={ElementSubtreeLength}) {ElementName}"; + + case RenderTreeFrameType.Region: + return $"Region: (seq={Sequence}, len={RegionSubtreeLength})"; + + case RenderTreeFrameType.Text: + return $"Text: (seq={Sequence}, len=n/a) {EscapeNewlines(TextContent)}"; + } + + return base.ToString(); + } + + private static string EscapeNewlines(string text) + { + return text.Replace("\n", "\\n").Replace("\r\n", "\\r\\n"); + } } } diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentDiscoveryRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentDiscoveryRazorIntegrationTest.cs index d5f0d1ad95..0794ae7462 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentDiscoveryRazorIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentDiscoveryRazorIntegrationTest.cs @@ -1,6 +1,9 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.IO; +using System.Text; +using Microsoft.AspNetCore.Blazor.Test.Helpers; using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis.CSharp; using Xunit; @@ -18,7 +21,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" using Microsoft.AspNetCore.Blazor.Components; -using namespace Test +namespace Test { public class MyComponent : BlazorComponent { @@ -34,6 +37,29 @@ using namespace Test Assert.Single(bindings.TagHelpers, t => t.Name == "Test.MyComponent"); } + [Fact] + public void ComponentDiscovery_CanFindComponent_WithNamespace_DefinedinCSharp() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test.AnotherNamespace +{ + public class MyComponent : BlazorComponent + { + } +} +")); + + // Act + var result = CompileToCSharp("@addTagHelper *, TestAssembly"); + + // Assert + var bindings = result.CodeDocument.GetTagHelperContext(); + Assert.Single(bindings.TagHelpers, t => t.Name == "Test.AnotherNamespace.MyComponent"); + } + [Fact] public void ComponentDiscovery_CanFindComponent_DefinedinCshtml() { diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.cs index 71c260e254..4b9f75b409 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.cs @@ -2,79 +2,285 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Linq; -using System.Reflection; -using Microsoft.AspNetCore.Blazor.Components; -using Microsoft.AspNetCore.Blazor.Layouts; using Microsoft.AspNetCore.Blazor.RenderTree; using Microsoft.AspNetCore.Blazor.Test.Helpers; +using Microsoft.CodeAnalysis.CSharp; using Xunit; namespace Microsoft.AspNetCore.Blazor.Build.Test { public class ComponentRenderingRazorIntegrationTest : RazorIntegrationTestBase { + internal override bool UseTwoPhaseCompilation => true; + [Fact] - public void SupportsChildComponentsViaTemporarySyntax() + public void Render_ChildComponent_Simple() { - // Arrange/Act - var testComponentTypeName = FullTypeName(); - var component = CompileToComponent($""); + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + } +} +")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly +"); + + // Act var frames = GetRenderTree(component); // Assert - Assert.Collection(frames, - frame => AssertFrame.Component(frame, 1, 0)); + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 1, 0)); } [Fact] - public void CanPassParametersToComponents() + public void Render_ChildComponent_WithParameters() { - // Arrange/Act - var testComponentTypeName = FullTypeName(); - var testObjectTypeName = FullTypeName(); - // TODO: Once we have the improved component tooling and can allow syntax - // like StringProperty="My string" or BoolProperty=true, update this - // test to use that syntax. - var component = CompileToComponent($""); + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class SomeType + { + } + + public class MyComponent : BlazorComponent + { + public int IntProperty { get; set; } + public bool BoolProperty { get; set; } + public string StringProperty { get; set; } + public SomeType ObjectProperty { get; set; } + } +} +")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly +"); + + // Act var frames = GetRenderTree(component); // Assert - Assert.Collection(frames, - frame => AssertFrame.Component(frame, 5, 0), + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 5, 0), frame => AssertFrame.Attribute(frame, "IntProperty", 123, 1), frame => AssertFrame.Attribute(frame, "BoolProperty", true, 2), frame => AssertFrame.Attribute(frame, "StringProperty", "My string", 3), frame => { AssertFrame.Attribute(frame, "ObjectProperty", 4); - Assert.IsType(frame.AttributeValue); + Assert.Equal("Test.SomeType", frame.AttributeValue.GetType().FullName); }); } [Fact] - public void CanIncludeChildrenInComponents() + public void Render_ChildComponent_WithExplicitStringParameter() { - // Arrange/Act - var testComponentTypeName = FullTypeName(); - var component = CompileToComponent($"" + - $"Some text" + - $"Nested text" + - $""); + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public string StringProperty { get; set; } + } +} +")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly +"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 2, 0), + frame => AssertFrame.Attribute(frame, "StringProperty", "42", 1)); + } + + [Fact] + public void Render_ChildComponent_WithLambdaEventHandler() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public UIEventHandler OnClick { get; set; } + } +} +")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly + + +@functions { + private int counter; + private void Increment() { + counter++; + } +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 2, 0), + frame => + { + AssertFrame.Attribute(frame, "OnClick", 1); + + // The handler will have been assigned to a lambda + var handler = Assert.IsType(frame.AttributeValue); + Assert.Equal("Test.TestComponent", handler.Target.GetType().FullName); + }, + frame => AssertFrame.Whitespace(frame, 2)); + } + + [Fact] + public void Render_ChildComponent_WithExplicitEventHandler() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public UIEventHandler OnClick { get; set; } + } +} +")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly +@using Microsoft.AspNetCore.Blazor + + +@functions { + private int counter; + private void Increment(UIEventArgs e) { + counter++; + } +}"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 2, 0), + frame => + { + AssertFrame.Attribute(frame, "OnClick", 1); + + // The handler will have been assigned to a lambda + var handler = Assert.IsType(frame.AttributeValue); + Assert.Equal("Test.TestComponent", handler.Target.GetType().FullName); + Assert.Equal("Increment", handler.Method.Name); + }, + frame => AssertFrame.Whitespace(frame, 2)); + } + + [Fact] + public void Render_ChildComponent_WithMinimizedBoolAttribute() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public bool BoolProperty { get; set; } + } +}")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly +"); + + // Act + var frames = GetRenderTree(component); + + // Assert + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 2, 0), + frame => AssertFrame.Attribute(frame, "BoolProperty", true, 1)); + } + + [Fact] + public void Render_ChildComponent_WithChildContent() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public string MyAttr { get; set; } + + public RenderFragment ChildContent { get; set; } + } +} +")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly +Some textNested text"); + + // Act var frames = GetRenderTree(component); // Assert: component frames are correct - Assert.Collection(frames, - frame => AssertFrame.Component(frame, 3, 0), + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 3, 0), frame => AssertFrame.Attribute(frame, "MyAttr", "abc", 1), frame => AssertFrame.Attribute(frame, RenderTreeBuilder.ChildContent, 2)); // Assert: Captured ChildContent frames are correct var childFrames = GetFrames((RenderFragment)frames[2].AttributeValue); - Assert.Collection(childFrames, + Assert.Collection( + childFrames, frame => AssertFrame.Text(frame, "Some text", 3), frame => AssertFrame.Element(frame, "some-child", 3, 4), frame => AssertFrame.Attribute(frame, "a", "1", 5), @@ -82,21 +288,33 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test } [Fact] - public void CanNestComponentChildContent() + public void Render_ChildComponent_Nested() { - // Arrange/Act - var testComponentTypeName = FullTypeName(); - var component = CompileToComponent( - $"" + - $"" + - $"Some text" + - $"" + - $""); + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public RenderFragment ChildContent { get; set; } + } +} +")); + + var component = CompileToComponent(@" +@addTagHelper *, TestAssembly +Some text"); + + // Act var frames = GetRenderTree(component); // Assert: outer component frames are correct - Assert.Collection(frames, - frame => AssertFrame.Component(frame, 2, 0), + Assert.Collection( + frames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 2, 0), frame => AssertFrame.Attribute(frame, RenderTreeBuilder.ChildContent, 1)); // Assert: first level of ChildContent is correct @@ -106,26 +324,15 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test // As an implementation detail, it happens that they do follow on from the parent // level, but we could change that part of the implementation if we wanted. var innerFrames = GetFrames((RenderFragment)frames[1].AttributeValue).ToArray(); - Assert.Collection(innerFrames, - frame => AssertFrame.Component(frame, 2, 2), + Assert.Collection( + innerFrames, + frame => AssertFrame.Component(frame, "Test.MyComponent", 2, 2), frame => AssertFrame.Attribute(frame, RenderTreeBuilder.ChildContent, 3)); // Assert: second level of ChildContent is correct - Assert.Collection(GetFrames((RenderFragment)innerFrames[1].AttributeValue), + Assert.Collection( + GetFrames((RenderFragment)innerFrames[1].AttributeValue), frame => AssertFrame.Text(frame, "Some text", 4)); } - - public class SomeType { } - - public class TestComponent : IComponent - { - public void Init(RenderHandle renderHandle) - { - } - - public void SetParameters(ParameterCollection parameters) - { - } - } } } diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/DeclarationRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/DeclarationRazorIntegrationTest.cs index 9b0f109a92..555b41f670 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/DeclarationRazorIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/DeclarationRazorIntegrationTest.cs @@ -5,7 +5,6 @@ using System.Reflection; using System.Text; using Microsoft.AspNetCore.Blazor.Components; using Microsoft.AspNetCore.Blazor.Razor; -using Microsoft.AspNetCore.Blazor.Test.Helpers; using Microsoft.AspNetCore.Razor.Language; using Xunit; diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/DesignTimeCodeGenerationRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/DesignTimeCodeGenerationRazorIntegrationTest.cs new file mode 100644 index 0000000000..5175a51167 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/DesignTimeCodeGenerationRazorIntegrationTest.cs @@ -0,0 +1,431 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Microsoft.AspNetCore.Blazor.Build.Test +{ + public class DesignTimeCodeGenerationRazorIntegrationTest : RazorIntegrationTestBase + { + internal override bool DesignTime => true; + + internal override bool UseTwoPhaseCompilation => true; + + [Fact] + public void CodeGeneration_ChildComponent_WithParameters() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class SomeType + { + } + + public class MyComponent : BlazorComponent + { + public int IntProperty { get; set; } + public bool BoolProperty { get; set; } + public string StringProperty { get; set; } + public SomeType ObjectProperty { get; set; } + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly +"); + + // Assert + CompileToAssembly(generated); + + Assert.Equal(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 219 + private void __RazorDirectiveTokenHelpers__() { + ((System.Action)(() => { +global::System.Object __typeHelper = ""*, TestAssembly""; + } + ))(); + } + #pragma warning restore 219 + #pragma warning disable 0414 + private static System.Object __o = null; + #pragma warning restore 0414 + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + + __o = +#line 3 ""x:\dir\subdir\Test\TestComponent.cshtml"" + 123 + +#line default +#line hidden + ; + __o = +#line 4 ""x:\dir\subdir\Test\TestComponent.cshtml"" + true + +#line default +#line hidden + ; + __o = +#line 6 ""x:\dir\subdir\Test\TestComponent.cshtml"" + new SomeType() + +#line default +#line hidden + ; + builder.AddAttribute(-1, ""ChildContent"", (Microsoft.AspNetCore.Blazor.RenderFragment)((builder2) => { + } + )); + } + #pragma warning restore 1998 + } +} +#pragma warning restore 1591 + +".Trim(), generated.Code.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public void CodeGeneration_ChildComponent_WithExplicitStringParameter() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public string StringProperty { get; set; } + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly +"); + + // Assert + CompileToAssembly(generated); + + Assert.Equal(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 219 + private void __RazorDirectiveTokenHelpers__() { + ((System.Action)(() => { +global::System.Object __typeHelper = ""*, TestAssembly""; + } + ))(); + } + #pragma warning restore 219 + #pragma warning disable 0414 + private static System.Object __o = null; + #pragma warning restore 0414 + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + + __o = +#line 2 ""x:\dir\subdir\Test\TestComponent.cshtml"" + 42.ToString() + +#line default +#line hidden + ; + builder.AddAttribute(-1, ""ChildContent"", (Microsoft.AspNetCore.Blazor.RenderFragment)((builder2) => { + } + )); + } + #pragma warning restore 1998 + } +} +#pragma warning restore 1591 +".Trim(), generated.Code.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public void CodeGeneration_ChildComponent_WithLambdaEventHandler() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public UIEventHandler OnClick { get; set; } + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly + + +@functions { + private int counter; + private void Increment() { + counter++; + } +}"); + + // Assert + CompileToAssembly(generated); + + Assert.Equal(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 219 + private void __RazorDirectiveTokenHelpers__() { + ((System.Action)(() => { +global::System.Object __typeHelper = ""*, TestAssembly""; + } + ))(); + } + #pragma warning restore 219 + #pragma warning disable 0414 + private static System.Object __o = null; + #pragma warning restore 0414 + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + + __o = new Microsoft.AspNetCore.Blazor.UIEventHandler(e => +#line 2 ""x:\dir\subdir\Test\TestComponent.cshtml"" + Increment() + +#line default +#line hidden + ); + builder.AddAttribute(-1, ""ChildContent"", (Microsoft.AspNetCore.Blazor.RenderFragment)((builder2) => { + } + )); + } + #pragma warning restore 1998 +#line 4 ""x:\dir\subdir\Test\TestComponent.cshtml"" + + private int counter; + private void Increment() { + counter++; + } + +#line default +#line hidden + } +} +#pragma warning restore 1591 + +".Trim(), generated.Code.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public void CodeGeneration_ChildComponent_WithExplicitEventHandler() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public UIEventHandler OnClick { get; set; } + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly +@using Microsoft.AspNetCore.Blazor + + +@functions { + private int counter; + private void Increment(UIEventArgs e) { + counter++; + } +}"); + + // Assert + CompileToAssembly(generated); + + Assert.Equal(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; +#line 2 ""x:\dir\subdir\Test\TestComponent.cshtml"" +using Microsoft.AspNetCore.Blazor; + +#line default +#line hidden + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 219 + private void __RazorDirectiveTokenHelpers__() { + ((System.Action)(() => { +global::System.Object __typeHelper = ""*, TestAssembly""; + } + ))(); + } + #pragma warning restore 219 + #pragma warning disable 0414 + private static System.Object __o = null; + #pragma warning restore 0414 + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + + __o = new Microsoft.AspNetCore.Blazor.UIEventHandler( +#line 3 ""x:\dir\subdir\Test\TestComponent.cshtml"" + Increment + +#line default +#line hidden + ); + builder.AddAttribute(-1, ""ChildContent"", (Microsoft.AspNetCore.Blazor.RenderFragment)((builder2) => { + } + )); + } + #pragma warning restore 1998 +#line 5 ""x:\dir\subdir\Test\TestComponent.cshtml"" + + private int counter; + private void Increment(UIEventArgs e) { + counter++; + } + +#line default +#line hidden + } +} +#pragma warning restore 1591 + +".Trim(), generated.Code.Trim()); + } + + [Fact] + public void CodeGeneration_ChildComponent_WithChildContent() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public string MyAttr { get; set; } + + public RenderFragment ChildContent { get; set; } + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly +Some textNested text"); + + // Assert + CompileToAssembly(generated); + + Assert.Equal(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 219 + private void __RazorDirectiveTokenHelpers__() { + ((System.Action)(() => { +global::System.Object __typeHelper = ""*, TestAssembly""; + } + ))(); + } + #pragma warning restore 219 + #pragma warning disable 0414 + private static System.Object __o = null; + #pragma warning restore 0414 + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + + builder.AddAttribute(-1, ""ChildContent"", (Microsoft.AspNetCore.Blazor.RenderFragment)((builder2) => { + } + )); + } + #pragma warning restore 1998 + } +} +#pragma warning restore 1591 +".Trim(), generated.Code.Trim()); + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorIntegrationTestBase.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorIntegrationTestBase.cs index c84cf02a03..f4b9c7edba 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorIntegrationTestBase.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorIntegrationTestBase.cs @@ -56,14 +56,19 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public RazorIntegrationTestBase() { AdditionalSyntaxTrees = new List(); + AdditionalRazorItems = new List(); + Configuration = BlazorExtensionInitializer.DefaultConfiguration; FileSystem = new VirtualRazorProjectFileSystem(); WorkingDirectory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ArbitraryWindowsPath : ArbitraryMacLinuxPath; DefaultBaseNamespace = "Test"; // Matches the default working directory DefaultFileName = "TestComponent.cshtml"; + } + internal List AdditionalRazorItems { get; } + internal List AdditionalSyntaxTrees { get; } internal virtual RazorConfiguration Configuration { get; } @@ -71,6 +76,8 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test internal virtual string DefaultBaseNamespace { get; } internal virtual string DefaultFileName { get; } + + internal virtual bool DesignTime { get; } internal virtual VirtualRazorProjectFileSystem FileSystem { get; } @@ -82,6 +89,9 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test { return RazorProjectEngine.Create(configuration, FileSystem, b => { + // Turn off checksums, we're testing code generation. + b.Features.Add(new SuppressChecksum()); + BlazorExtensionInitializer.Register(b); b.Features.Add(new CompilationTagHelperFeature()); @@ -92,58 +102,83 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test }); } - protected CompileToCSharpResult CompileToCSharp(string cshtmlContent) - { - return CompileToCSharp(WorkingDirectory, DefaultFileName, cshtmlContent); - } - - protected CompileToCSharpResult CompileToCSharp(string cshtmlRelativePath, string cshtmlContent) - { - return CompileToCSharp(WorkingDirectory, cshtmlRelativePath, cshtmlContent); - } - - protected CompileToCSharpResult CompileToCSharp(string cshtmlRootPath, string cshtmlRelativePath, string cshtmlContent) + internal RazorProjectItem CreateProjectItem(string chtmlRelativePath, string cshtmlContent) { // FilePaths in Razor are **always** are of the form '/a/b/c.cshtml' - var filePath = cshtmlRelativePath.Replace('\\', '/'); + var filePath = chtmlRelativePath.Replace('\\', '/'); if (!filePath.StartsWith('/')) { filePath = '/' + filePath; } - var projectItem = new VirtualProjectItem( - cshtmlRootPath, - filePath, - Path.Combine(cshtmlRootPath, cshtmlRelativePath), - cshtmlRelativePath, - Encoding.UTF8.GetBytes(cshtmlContent)); + return new VirtualProjectItem( + WorkingDirectory, + filePath, + Path.Combine(WorkingDirectory, chtmlRelativePath), + chtmlRelativePath, + Encoding.UTF8.GetBytes(cshtmlContent.TrimStart())); + } + protected CompileToCSharpResult CompileToCSharp(string cshtmlContent) + { + return CompileToCSharp(DefaultFileName, cshtmlContent); + } + + protected CompileToCSharpResult CompileToCSharp(string cshtmlRelativePath, string cshtmlContent) + { if (UseTwoPhaseCompilation) { // The first phase won't include any metadata references for component discovery. This mirrors // what the build does. var projectEngine = CreateProjectEngine(BlazorExtensionInitializer.DeclarationConfiguration, Array.Empty()); - var codeDocument = projectEngine.Process(projectItem); + + RazorCodeDocument codeDocument; + foreach (var item in AdditionalRazorItems) + { + // Result of generating declarations + codeDocument = projectEngine.Process(item); + Assert.Empty(codeDocument.GetCSharpDocument().Diagnostics); + + var syntaxTree = CSharpSyntaxTree.ParseText(codeDocument.GetCSharpDocument().GeneratedCode, path: item.FilePath); + AdditionalSyntaxTrees.Add(syntaxTree); + } // Result of generating declarations + var projectItem = CreateProjectItem(cshtmlRelativePath, cshtmlContent); + codeDocument = projectEngine.Process(projectItem); var declaration = new CompileToCSharpResult { + BaseCompilation = BaseCompilation.AddSyntaxTrees(AdditionalSyntaxTrees), CodeDocument = codeDocument, Code = codeDocument.GetCSharpDocument().GeneratedCode, Diagnostics = codeDocument.GetCSharpDocument().Diagnostics, }; // Result of doing 'temp' compilation - var tempAssembly = CompileToAssembly(declaration, BaseCompilation); + var tempAssembly = CompileToAssembly(declaration); // Add the 'temp' compilation as a metadata reference var references = BaseCompilation.References.Concat(new[] { tempAssembly.Compilation.ToMetadataReference() }).ToArray(); projectEngine = CreateProjectEngine(BlazorExtensionInitializer.DefaultConfiguration, references); - - // Result of real code - codeDocument = projectEngine.Process(projectItem); + + // Now update the any additional files + foreach (var item in AdditionalRazorItems) + { + // Result of generating declarations + codeDocument = projectEngine.Process(item); + Assert.Empty(codeDocument.GetCSharpDocument().Diagnostics); + + // Replace the 'declaration' syntax tree + var syntaxTree = CSharpSyntaxTree.ParseText(codeDocument.GetCSharpDocument().GeneratedCode, path: item.FilePath); + AdditionalSyntaxTrees.RemoveAll(st => st.FilePath == item.FilePath); + AdditionalSyntaxTrees.Add(syntaxTree); + } + + // Result of real code generation for the document under test + codeDocument = DesignTime ? projectEngine.ProcessDesignTime(projectItem) : projectEngine.Process(projectItem); return new CompileToCSharpResult { + BaseCompilation = BaseCompilation.AddSyntaxTrees(AdditionalSyntaxTrees), CodeDocument = codeDocument, Code = codeDocument.GetCSharpDocument().GeneratedCode, Diagnostics = codeDocument.GetCSharpDocument().Diagnostics, @@ -155,9 +190,11 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test // This will include the built-in Blazor components. var projectEngine = CreateProjectEngine(Configuration, BaseCompilation.References.ToArray()); - var codeDocument = projectEngine.Process(projectItem); + var projectItem = CreateProjectItem(cshtmlRelativePath, cshtmlContent); + var codeDocument = DesignTime ? projectEngine.ProcessDesignTime(projectItem) : projectEngine.Process(projectItem); return new CompileToCSharpResult { + BaseCompilation = BaseCompilation.AddSyntaxTrees(AdditionalSyntaxTrees), CodeDocument = codeDocument, Code = codeDocument.GetCSharpDocument().GeneratedCode, Diagnostics = codeDocument.GetCSharpDocument().Diagnostics, @@ -167,19 +204,12 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test protected CompileToAssemblyResult CompileToAssembly(string cshtmlRelativePath, string cshtmlContent) { - return CompileToAssembly(WorkingDirectory, cshtmlRelativePath, cshtmlContent); - } - - protected CompileToAssemblyResult CompileToAssembly(string cshtmlRootDirectory, string cshtmlRelativePath, string cshtmlContent) - { - var cSharpResult = CompileToCSharp(cshtmlRootDirectory, cshtmlRelativePath, cshtmlContent); + var cSharpResult = CompileToCSharp(cshtmlRelativePath, cshtmlContent); return CompileToAssembly(cSharpResult); } - protected CompileToAssemblyResult CompileToAssembly(CompileToCSharpResult cSharpResult, CSharpCompilation baseCompilation = null) + protected CompileToAssemblyResult CompileToAssembly(CompileToCSharpResult cSharpResult) { - baseCompilation = baseCompilation ?? BaseCompilation; - if (cSharpResult.Diagnostics.Any()) { var diagnosticsLog = string.Join(Environment.NewLine, cSharpResult.Diagnostics.Select(d => d.ToString()).ToArray()); @@ -188,17 +218,24 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test var syntaxTrees = new[] { - CSharpSyntaxTree.ParseText(cSharpResult.Code) + CSharpSyntaxTree.ParseText(cSharpResult.Code), }; - var compilation = baseCompilation.AddSyntaxTrees(syntaxTrees).AddSyntaxTrees(AdditionalSyntaxTrees); + var compilation = cSharpResult.BaseCompilation.AddSyntaxTrees(syntaxTrees); + + var diagnostics = compilation + .GetDiagnostics() + .Where(d => d.Severity != DiagnosticSeverity.Hidden); + + if (diagnostics.Any()) + { + throw new CompilationFailedException(compilation); + } + using (var peStream = new MemoryStream()) { compilation.Emit(peStream); - var diagnostics = compilation - .GetDiagnostics() - .Where(d => d.Severity != DiagnosticSeverity.Hidden); return new CompileToAssemblyResult { Compilation = compilation, @@ -206,11 +243,12 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test Assembly = diagnostics.Any() ? null : Assembly.Load(peStream.ToArray()) }; } + } protected IComponent CompileToComponent(string cshtmlSource) { - var assemblyResult = CompileToAssembly(WorkingDirectory, DefaultFileName, cshtmlSource); + var assemblyResult = CompileToAssembly(DefaultFileName, cshtmlSource); var componentFullTypeName = $"{DefaultBaseNamespace}.{Path.GetFileNameWithoutExtension(DefaultFileName)}"; return CompileToComponent(assemblyResult, componentFullTypeName); @@ -223,8 +261,6 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test protected IComponent CompileToComponent(CompileToAssemblyResult assemblyResult, string fullTypeName) { - Assert.Empty(assemblyResult.Diagnostics); - var componentType = assemblyResult.Assembly.GetType(fullTypeName); if (componentType == null) { @@ -255,6 +291,8 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test protected class CompileToCSharpResult { + // A compilation that can be used *with* this code to compile an assembly + public Compilation BaseCompilation { get; set; } public RazorCodeDocument CodeDocument { get; set; } public string Code { get; set; } public IEnumerable Diagnostics { get; set; } @@ -284,5 +322,62 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test LatestBatchReferenceFrames = renderBatch.ReferenceFrames.ToArray(); } } + + private class CompilationFailedException : XunitException + { + public CompilationFailedException(Compilation compilation) + { + Compilation = compilation; + } + + public Compilation Compilation { get; } + + public override string Message + { + get + { + var builder = new StringBuilder(); + builder.AppendLine("Compilation failed: "); + + var diagnostics = Compilation.GetDiagnostics(); + var syntaxTreesWithErrors = new HashSet(); + foreach (var diagnostic in diagnostics) + { + builder.AppendLine(diagnostic.ToString()); + + if (diagnostic.Location.IsInSource) + { + syntaxTreesWithErrors.Add(diagnostic.Location.SourceTree); + } + } + + if (syntaxTreesWithErrors.Any()) + { + builder.AppendLine(); + builder.AppendLine(); + + foreach (var syntaxTree in syntaxTreesWithErrors) + { + builder.AppendLine($"File {syntaxTree.FilePath ?? "unknown"}:"); + builder.AppendLine(syntaxTree.GetText().ToString()); + } + } + + return builder.ToString(); + } + } + } + + private class SuppressChecksum : IConfigureRazorCodeGenerationOptionsFeature + { + public int Order => 0; + + public RazorEngine Engine { get; set; } + + public void Configure(RazorCodeGenerationOptionsBuilder options) + { + options.SuppressChecksum = true; + } + } } } diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs index 7356feee8a..8d069059b2 100644 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs @@ -43,15 +43,14 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test // Assert var frames = GetRenderTree(component); Assert.Collection(frames, - frame => AssertFrame.Whitespace(frame, 0), - frame => AssertFrame.Text(frame, "Hello", 1), - frame => AssertFrame.Whitespace(frame, 2), - frame => AssertFrame.Whitespace(frame, 3), // @((object)null) - frame => AssertFrame.Whitespace(frame, 4), - frame => AssertFrame.Text(frame, "123", 5), - frame => AssertFrame.Whitespace(frame, 6), - frame => AssertFrame.Text(frame, new object().ToString(), 7), - frame => AssertFrame.Whitespace(frame, 8)); + frame => AssertFrame.Text(frame, "Hello", 0), + frame => AssertFrame.Whitespace(frame, 1), + frame => AssertFrame.Whitespace(frame, 2), // @((object)null) + frame => AssertFrame.Whitespace(frame, 3), + frame => AssertFrame.Text(frame, "123", 4), + frame => AssertFrame.Whitespace(frame, 5), + frame => AssertFrame.Text(frame, new object().ToString(), 6), + frame => AssertFrame.Whitespace(frame, 7)); } [Fact] @@ -70,12 +69,11 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test // Assert var frames = GetRenderTree(component); Assert.Collection(frames, - frame => AssertFrame.Whitespace(frame, 0), - frame => AssertFrame.Text(frame, "First", 1), - frame => AssertFrame.Text(frame, "Second", 1), - frame => AssertFrame.Text(frame, "Third", 1), - frame => AssertFrame.Whitespace(frame, 2), - frame => AssertFrame.Whitespace(frame, 3)); + frame => AssertFrame.Text(frame, "First", 0), + frame => AssertFrame.Text(frame, "Second", 0), + frame => AssertFrame.Text(frame, "Third", 0), + frame => AssertFrame.Whitespace(frame, 1), + frame => AssertFrame.Whitespace(frame, 2)); } [Fact] diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RuntimeCodeGenerationRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RuntimeCodeGenerationRazorIntegrationTest.cs new file mode 100644 index 0000000000..f7751602b5 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RuntimeCodeGenerationRazorIntegrationTest.cs @@ -0,0 +1,374 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Microsoft.AspNetCore.Blazor.Build.Test +{ + public class RuntimeCodeGenerationRazorIntegrationTest : RazorIntegrationTestBase + { + internal override bool UseTwoPhaseCompilation => true; + + [Fact] + public void CodeGeneration_ChildComponent_Simple() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly +"); + + // Assert + CompileToAssembly(generated); + + Assert.Equal(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenComponent(0); + builder.CloseComponent(); + } + #pragma warning restore 1998 + } +} +#pragma warning restore 1591 +".Trim(), generated.Code.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public void CodeGeneration_ChildComponent_WithParameters() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class SomeType + { + } + + public class MyComponent : BlazorComponent + { + public int IntProperty { get; set; } + public bool BoolProperty { get; set; } + public string StringProperty { get; set; } + public SomeType ObjectProperty { get; set; } + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly +"); + + // Assert + CompileToAssembly(generated); + + Assert.Equal(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenComponent(0); + builder.AddAttribute(1, ""IntProperty"", 123); + builder.AddAttribute(2, ""BoolProperty"", true); + builder.AddAttribute(3, ""StringProperty"", ""My string""); + builder.AddAttribute(4, ""ObjectProperty"", new SomeType()); + builder.CloseComponent(); + } + #pragma warning restore 1998 + } +} +#pragma warning restore 1591 +".Trim(), generated.Code.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public void CodeGeneration_ChildComponent_WithExplicitStringParameter() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public string StringProperty { get; set; } + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly +"); + + // Assert + CompileToAssembly(generated); + + Assert.Equal(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenComponent(0); + builder.AddAttribute(1, ""StringProperty"", 42.ToString()); + builder.CloseComponent(); + } + #pragma warning restore 1998 + } +} +#pragma warning restore 1591 +".Trim(), generated.Code.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public void CodeGeneration_ChildComponent_WithLambdaEventHandler() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public UIEventHandler OnClick { get; set; } + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly + + +@functions { + private int counter; + private void Increment() { + counter++; + } +}"); + + // Assert + CompileToAssembly(generated); + + Assert.Equal(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenComponent(0); + builder.AddAttribute(1, ""OnClick"", new Microsoft.AspNetCore.Blazor.UIEventHandler(e => Increment())); + builder.CloseComponent(); + builder.AddContent(2, ""\n\n""); + } + #pragma warning restore 1998 + + private int counter; + private void Increment() { + counter++; + } + } +} +#pragma warning restore 1591 +".Trim(), generated.Code.Trim(), ignoreLineEndingDifferences: true); + } + + [Fact] + public void CodeGeneration_ChildComponent_WithExplicitEventHandler() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using System; +using Microsoft.AspNetCore.Blazor; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public UIEventHandler OnClick { get; set; } + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly +@using Microsoft.AspNetCore.Blazor + + +@functions { + private int counter; + private void Increment(UIEventArgs e) { + counter++; + } +}"); + + // Assert + CompileToAssembly(generated); + + Assert.Equal(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Blazor; + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenComponent(0); + builder.AddAttribute(1, ""OnClick"", new Microsoft.AspNetCore.Blazor.UIEventHandler(Increment)); + builder.CloseComponent(); + builder.AddContent(2, ""\n\n""); + } + #pragma warning restore 1998 + + private int counter; + private void Increment(UIEventArgs e) { + counter++; + } + } +} +#pragma warning restore 1591 +".Trim(), generated.Code.Trim()); + } + + [Fact] + public void CodeGeneration_ChildComponent_WithChildContent() + { + // Arrange + AdditionalSyntaxTrees.Add(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public string MyAttr { get; set; } + + public RenderFragment ChildContent { get; set; } + } +} +")); + + // Act + var generated = CompileToCSharp(@" +@addTagHelper *, TestAssembly +Some textNested text"); + + // Assert + CompileToAssembly(generated); + + Assert.Equal(@" +// +#pragma warning disable 1591 +namespace Test +{ + #line hidden + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + public class TestComponent : Microsoft.AspNetCore.Blazor.Components.BlazorComponent + { + #pragma warning disable 1998 + protected override void BuildRenderTree(Microsoft.AspNetCore.Blazor.RenderTree.RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + builder.OpenComponent(0); + builder.AddAttribute(1, ""MyAttr"", ""abc""); + builder.AddAttribute(2, ""ChildContent"", (Microsoft.AspNetCore.Blazor.RenderFragment)((builder2) => { + builder2.AddContent(3, ""Some text""); + builder2.OpenElement(4, ""some-child""); + builder2.AddAttribute(5, ""a"", ""1""); + builder2.AddContent(6, ""Nested text""); + builder2.CloseElement(); + } + )); + builder.CloseComponent(); + } + #pragma warning restore 1998 + } +} +#pragma warning restore 1591 +".Trim(), generated.Code.Trim()); + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/ComponentTagHelperDescriptorProviderTest.cs b/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/ComponentTagHelperDescriptorProviderTest.cs index 97186ebdbc..cee3ed6125 100644 --- a/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/ComponentTagHelperDescriptorProviderTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.Razor.Extensions.Test/ComponentTagHelperDescriptorProviderTest.cs @@ -282,6 +282,53 @@ namespace Test Assert.False(attribute.IsStringProperty); } + [Fact] // UIEventHandler properties have some special intellisense behavior + public void Excecute_UIEventHandlerProperty_CreatesDescriptor() + { + // Arrange + + var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@" +using Microsoft.AspNetCore.Blazor; +using Microsoft.AspNetCore.Blazor.Components; + +namespace Test +{ + public class MyComponent : BlazorComponent + { + public UIEventHandler OnClick { get; set; } + } +} + +")); + + Assert.Empty(compilation.GetDiagnostics()); + + var context = TagHelperDescriptorProviderContext.Create(); + context.SetCompilation(compilation); + + var provider = new ComponentTagHelperDescriptorProvider(); + + // Act + provider.Execute(context); + + // Assert + var components = ExcludeBuiltInComponents(context); + var component = Assert.Single(components); + + Assert.Equal("TestAssembly", component.AssemblyName); + Assert.Equal("Test.MyComponent", component.Name); + + var attribute = Assert.Single(component.BoundAttributes); + Assert.Equal("OnClick", attribute.Name); + Assert.Equal(BlazorApi.UIEventHandler.FullTypeName, attribute.TypeName); + + Assert.False(attribute.HasIndexer); + Assert.False(attribute.IsBooleanProperty); + Assert.False(attribute.IsEnum); + Assert.False(attribute.IsStringProperty); + Assert.True(attribute.IsUIEventHandlerProperty()); + } + // For simplicity in testing, exlude the built-in components. We'll add more and we // don't want to update the tests when that happens. private TagHelperDescriptor[] ExcludeBuiltInComponents(TagHelperDescriptorProviderContext context) diff --git a/test/shared/AssertFrame.cs b/test/shared/AssertFrame.cs index 6bf79c29b2..1b6bf118c1 100644 --- a/test/shared/AssertFrame.cs +++ b/test/shared/AssertFrame.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using Microsoft.AspNetCore.Blazor.Components; using Microsoft.AspNetCore.Blazor.RenderTree; using Xunit; @@ -58,10 +59,21 @@ namespace Microsoft.AspNetCore.Blazor.Test.Helpers Assert.Equal(attributeValue, frame.AttributeValue); } + public static void Attribute(RenderTreeFrame frame, string attributeName, Action attributeValidator, int? sequence = null) + { + AssertFrame.Attribute(frame, attributeName, sequence); + attributeValidator(frame.AttributeValue); + } + public static void Component(RenderTreeFrame frame, int? subtreeLength = null, int? sequence = null) where T : IComponent + { + Component(frame, typeof(T).FullName, subtreeLength, sequence); + } + + public static void Component(RenderTreeFrame frame, string typeName, int? subtreeLength = null, int? sequence = null) { Assert.Equal(RenderTreeFrameType.Component, frame.FrameType); - Assert.Equal(typeof(T), frame.ComponentType); + Assert.Equal(typeName, frame.ComponentType.FullName); if (subtreeLength.HasValue) { Assert.Equal(subtreeLength.Value, frame.ComponentSubtreeLength); diff --git a/tooling/Microsoft.VisualStudio.BlazorExtension/Microsoft.VisualStudio.BlazorExtension.csproj b/tooling/Microsoft.VisualStudio.BlazorExtension/Microsoft.VisualStudio.BlazorExtension.csproj index 923931002c..fb589d1314 100644 --- a/tooling/Microsoft.VisualStudio.BlazorExtension/Microsoft.VisualStudio.BlazorExtension.csproj +++ b/tooling/Microsoft.VisualStudio.BlazorExtension/Microsoft.VisualStudio.BlazorExtension.csproj @@ -122,8 +122,10 @@ Microsoft.AspNetCore.Blazor.Razor.Extensions False - - + + + + Microsoft.AspNetCore.Blazor.Razor.Extensions.dll @@ -146,7 +148,6 @@ PreserveNewest false -