From 6182e8448ddcb4efa2a4111856fb44910c11d494 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Wed, 28 Feb 2018 20:16:15 -0800 Subject: [PATCH] Get rid of RazorCompiler Adds a little more use of Razor extensibility. Razor is a plugin model, so we can't be the 'first mover' for initiating compilation in the build tools and IDE. Reorganizes tests and fills out more reusable test infrastructure for Razor-driven testing. Adds tests for declaration-only configuration. --- .../Cli/Commands/BuildRazorCommand.cs | 43 +- .../RazorCompilation/BlazorProjectItem.cs | 35 - .../Core/RazorCompilation/RazorCompiler.cs | 167 --- .../BlazorCSharpLoweringPhase.cs | 41 + .../BlazorDiagnosticFactory.cs | 76 ++ .../BlazorExtensionInitializer.cs | 59 +- .../BlazorIntermediateNodeWriter.cs | 14 +- .../BlazorTemplateEngine.cs | 57 - .../CSharpIdentifier.cs | 72 ++ .../ComponentDocumentClassifierPass.cs | 98 +- .../EliminateMethodBodyPass.cs | 31 + .../ImplementsDirective.cs | 11 - .../InjectDirective.cs | 6 - .../LayoutDirective.cs | 11 - .../Properties/AssemblyInfo.cs | 2 + .../RazorCompilerDiagnostic.cs | 40 - .../RazorCompilerException.cs | 20 +- .../ScopeStack.cs | 14 +- .../ComponentRenderingRazorIntegrationTest.cs | 131 +++ .../DeclarationRazorIntegrationTest.cs | 112 ++ .../DiagnosticRazorIntegrationTest.cs | 89 ++ .../DirectiveRazorIntegrationTest.cs | 144 +++ .../FilePathRazorIntegrationTest.cs | 48 + .../Razor/NotFoundProjectItem.cs | 40 + .../Razor/VirtualProjectFileSystem.cs | 215 ++++ .../Razor/VirtualProjectItem.cs | 36 + .../RazorCompilerTest.cs | 1000 ----------------- .../RazorIntegrationTestBase.cs | 240 ++++ .../RenderingRazorIntegrationTest.cs | 445 ++++++++ .../BlazorProjectEngineFactory.cs | 4 + 30 files changed, 1892 insertions(+), 1409 deletions(-) delete mode 100644 src/Microsoft.AspNetCore.Blazor.Build/Core/RazorCompilation/BlazorProjectItem.cs delete mode 100644 src/Microsoft.AspNetCore.Blazor.Build/Core/RazorCompilation/RazorCompiler.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorCSharpLoweringPhase.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs delete mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorTemplateEngine.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/CSharpIdentifier.cs create mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/EliminateMethodBodyPass.cs delete mode 100644 src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RazorCompilerDiagnostic.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/DeclarationRazorIntegrationTest.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/DiagnosticRazorIntegrationTest.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/DirectiveRazorIntegrationTest.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/FilePathRazorIntegrationTest.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/NotFoundProjectItem.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/VirtualProjectFileSystem.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/VirtualProjectItem.cs delete mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/RazorIntegrationTestBase.cs create mode 100644 test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs diff --git a/src/Microsoft.AspNetCore.Blazor.Build/Cli/Commands/BuildRazorCommand.cs b/src/Microsoft.AspNetCore.Blazor.Build/Cli/Commands/BuildRazorCommand.cs index 13b3ff60d0..94c8b9dbca 100644 --- a/src/Microsoft.AspNetCore.Blazor.Build/Cli/Commands/BuildRazorCommand.cs +++ b/src/Microsoft.AspNetCore.Blazor.Build/Cli/Commands/BuildRazorCommand.cs @@ -1,13 +1,13 @@ // 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.Build.Core.RazorCompilation; -using Microsoft.AspNetCore.Blazor.Razor; -using Microsoft.Extensions.CommandLineUtils; using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Microsoft.AspNetCore.Blazor.Razor; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.Extensions.CommandLineUtils; namespace Microsoft.AspNetCore.Blazor.Build.Cli.Commands { @@ -47,24 +47,35 @@ namespace Microsoft.AspNetCore.Blazor.Build.Cli.Commands return 1; } - var inputRazorFilePaths = FindRazorFiles(sourceDirPathValue).ToList(); + var fileSystem = RazorProjectFileSystem.Create(sourceDirPathValue); + var engine = RazorProjectEngine.Create(BlazorExtensionInitializer.DefaultConfiguration, fileSystem, b => + { + BlazorExtensionInitializer.Register(b); + }); + + var diagnostics = new List(); + var sourceFiles = FindRazorFiles(sourceDirPathValue).ToList(); using (var outputWriter = new StreamWriter(outputFilePath.Value())) { - var diagnostics = new RazorCompiler().CompileFiles( - sourceDirPathValue, - inputRazorFilePaths, - baseNamespace.Value(), - outputWriter, - verboseFlag.HasValue() ? Console.Out : null); - - foreach (var diagnostic in diagnostics) + foreach (var sourceFile in sourceFiles) { - Console.WriteLine(diagnostic.FormatForConsole()); - } + var item = fileSystem.GetItem(sourceFile); - var hasError = diagnostics.Any(item => item.Type == RazorCompilerDiagnostic.DiagnosticType.Error); - return hasError ? 1 : 0; + var codeDocument = engine.Process(item); + var cSharpDocument = codeDocument.GetCSharpDocument(); + + outputWriter.WriteLine(cSharpDocument.GeneratedCode); + diagnostics.AddRange(cSharpDocument.Diagnostics); + } } + + foreach (var diagnostic in diagnostics) + { + Console.WriteLine(diagnostic.ToString()); + } + + var hasError = diagnostics.Any(item => item.Severity == RazorDiagnosticSeverity.Error); + return hasError ? 1 : 0; }); } diff --git a/src/Microsoft.AspNetCore.Blazor.Build/Core/RazorCompilation/BlazorProjectItem.cs b/src/Microsoft.AspNetCore.Blazor.Build/Core/RazorCompilation/BlazorProjectItem.cs deleted file mode 100644 index 95bf70376f..0000000000 --- a/src/Microsoft.AspNetCore.Blazor.Build/Core/RazorCompilation/BlazorProjectItem.cs +++ /dev/null @@ -1,35 +0,0 @@ -// 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.Razor.Language; -using System.IO; - -namespace Microsoft.AspNetCore.Blazor.Build.Core.RazorCompilation -{ - internal class BlazorProjectItem : RazorProjectItem - { - private readonly string _projectBasePath; - private readonly string _itemFullPhysicalPath; - private readonly Stream _itemContents; - - public BlazorProjectItem( - string projectBasePath, - string itemFullPhysicalPath, - Stream itemFileContents) - { - _projectBasePath = projectBasePath; - _itemFullPhysicalPath = itemFullPhysicalPath; - _itemContents = itemFileContents; - } - - public override string BasePath => _projectBasePath; - - public override string FilePath => _itemFullPhysicalPath; - - public override string PhysicalPath => _itemFullPhysicalPath; - - public override bool Exists => true; - - public override Stream Read() => _itemContents; - } -} diff --git a/src/Microsoft.AspNetCore.Blazor.Build/Core/RazorCompilation/RazorCompiler.cs b/src/Microsoft.AspNetCore.Blazor.Build/Core/RazorCompilation/RazorCompiler.cs deleted file mode 100644 index 1fdb181f40..0000000000 --- a/src/Microsoft.AspNetCore.Blazor.Build/Core/RazorCompilation/RazorCompiler.cs +++ /dev/null @@ -1,167 +0,0 @@ -// 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 System.Collections.Generic; -using System.IO; -using System.Linq; -using System.CodeDom.Compiler; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Blazor.Components; -using Microsoft.AspNetCore.Blazor.Razor; -using Microsoft.AspNetCore.Blazor.RenderTree; - -namespace Microsoft.AspNetCore.Blazor.Build.Core.RazorCompilation -{ - /// - /// Provides facilities for transforming Razor files into Blazor component classes. - /// - public class RazorCompiler - { - private static CodeDomProvider _csharpCodeDomProvider = CodeDomProvider.CreateProvider("c#"); - - /// - /// Writes C# source code representing Blazor components defined by Razor files. - /// - /// Path to a directory containing input files. - /// Paths to the input files relative to . The generated namespaces will be based on these relative paths. - /// The base namespace for the generated classes. - /// A to which C# source code will be written. - /// If not null, additional information will be written to this . - /// A collection of instances representing any warnings or errors that were encountered. - public ICollection CompileFiles( - string inputRootPath, - IEnumerable inputPaths, - string baseNamespace, - TextWriter resultOutput, - TextWriter verboseOutput) - => inputPaths.SelectMany(path => - { - using (var reader = File.OpenRead(path)) - { - return CompileSingleFile(inputRootPath, path, reader, baseNamespace, resultOutput, verboseOutput); - } - }).ToList(); - - /// - /// Writes C# source code representing a Blazor component defined by a Razor file. - /// - /// Path to a directory containing input files. - /// Paths to the input files relative to . The generated namespaces will be based on these relative paths. - /// The base namespace for the generated class. - /// A to which C# source code will be written. - /// If not null, additional information will be written to this . - /// An enumerable of instances representing any warnings or errors that were encountered. - public IEnumerable CompileSingleFile( - string inputRootPath, - string inputFilePath, - Stream inputFileContents, - string baseNamespace, - TextWriter resultOutput, - TextWriter verboseOutput) - { - if (inputFileContents == null) - { - throw new ArgumentNullException(nameof(inputFileContents)); - } - - if (resultOutput == null) - { - throw new ArgumentNullException(nameof(resultOutput)); - } - - if (string.IsNullOrEmpty(inputRootPath)) - { - throw new ArgumentException("Cannot be null or empty.", nameof(inputRootPath)); - } - - if (string.IsNullOrEmpty(baseNamespace)) - { - throw new ArgumentException("Cannot be null or empty.", nameof(baseNamespace)); - } - - try - { - verboseOutput?.WriteLine($"Compiling {inputFilePath}..."); - var (itemNamespace, itemClassName) = GetNamespaceAndClassName(inputRootPath, inputFilePath); - var combinedNamespace = string.IsNullOrEmpty(itemNamespace) - ? baseNamespace - : $"{baseNamespace}.{itemNamespace}"; - - // TODO: Pass through info about whether this is a design-time build, and if so, - // just emit enough of a stub class that intellisense will show the correct type - // name and any public members. Don't need to actually emit all the RenderTreeBuilder - // invocations. - - var engine = RazorEngine.Create(b => BlazorExtensionInitializer.Register(b)); - var blazorTemplateEngine = new BlazorTemplateEngine( - engine, - RazorProjectFileSystem.Create(inputRootPath)); - var codeDoc = blazorTemplateEngine.CreateCodeDocument( - new BlazorProjectItem(inputRootPath, inputFilePath, inputFileContents)); - codeDoc.Items[BlazorCodeDocItems.Namespace] = combinedNamespace; - codeDoc.Items[BlazorCodeDocItems.ClassName] = itemClassName; - var csharpDocument = blazorTemplateEngine.GenerateCode(codeDoc); - var generatedCode = csharpDocument.GeneratedCode; - - // Add parameters to the primary method via string manipulation because - // DefaultDocumentWriter's VisitMethodDeclaration can't emit parameters - var primaryMethodSource = $"protected override void {BlazorComponent.BuildRenderTreeMethodName}"; - generatedCode = generatedCode.Replace( - $"{primaryMethodSource}()", - $"{primaryMethodSource}({typeof(RenderTreeBuilder).FullName} builder)"); - - resultOutput.WriteLine(generatedCode); - - return Enumerable.Empty(); - } - catch (RazorCompilerException ex) - { - return new[] { ex.ToDiagnostic(inputFilePath) }; - } - catch (Exception ex) - { - return new[] - { - new RazorCompilerDiagnostic( - RazorCompilerDiagnostic.DiagnosticType.Error, - inputFilePath, - 1, - 1, - $"Unexpected exception: {ex.Message}{Environment.NewLine}{ex.StackTrace}") - }; - } - } - - private static (string, string) GetNamespaceAndClassName(string inputRootPath, string inputFilePath) - { - // First represent inputFilePath as a path relative to inputRootPath. Not using Path.GetRelativePath - // because it doesn't handle cases like inputFilePath="\\something.cs". - var inputFilePathAbsolute = Path.GetFullPath(Path.Combine(inputRootPath, inputFilePath)); - var inputRootPathWithTrailingSeparator = inputRootPath.EndsWith(Path.DirectorySeparatorChar) - ? inputRootPath - : (inputRootPath + Path.DirectorySeparatorChar); - var inputFilePathRelative = inputFilePathAbsolute.StartsWith(inputRootPathWithTrailingSeparator) - ? inputFilePathAbsolute.Substring(inputRootPathWithTrailingSeparator.Length) - : throw new RazorCompilerException($"File is not within source root directory: '{inputFilePath}'"); - - // Use the set of directory names in the relative path as namespace - var inputDirname = Path.GetDirectoryName(inputFilePathRelative); - var resultNamespace = inputDirname - .Replace(Path.DirectorySeparatorChar, '.') - .Replace(" ", string.Empty); - - // Use the filename as class name - var inputBasename = Path.GetFileNameWithoutExtension(inputFilePathRelative); - if (!IsValidClassName(inputBasename)) - { - throw new RazorCompilerException($"Invalid name '{inputBasename}'. The name must be valid for a C# class name."); - } - - return (resultNamespace, inputBasename); - } - - private static bool IsValidClassName(string name) - => _csharpCodeDomProvider.IsValidIdentifier(name); - } -} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorCSharpLoweringPhase.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorCSharpLoweringPhase.cs new file mode 100644 index 0000000000..aade7e618e --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorCSharpLoweringPhase.cs @@ -0,0 +1,41 @@ +// 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 BlazorRazorCSharpLoweringPhase : RazorEnginePhaseBase, IRazorCSharpLoweringPhase + { + protected override void ExecuteCore(RazorCodeDocument codeDocument) + { + var documentNode = codeDocument.GetDocumentIntermediateNode(); + ThrowForMissingDocumentDependency(documentNode); + + var writer = new DocumentWriterWorkaround().Create(documentNode.Target, documentNode.Options); + try + { + var cSharpDocument = writer.WriteDocument(codeDocument, documentNode); + codeDocument.SetCSharpDocument(cSharpDocument); + } + catch (RazorCompilerException ex) + { + // Currently the Blazor code generation has some 'fatal errors' that can cause code generation + // to fail completely. This class is here to make that implementation work gracefully. + var cSharpDocument = RazorCSharpDocument.Create("", documentNode.Options, new[] { ex.Diagnostic }); + codeDocument.SetCSharpDocument(cSharpDocument); + } + } + + private class DocumentWriterWorkaround : DocumentWriter + { + public override RazorCSharpDocument WriteDocument(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) + { + throw new NotImplementedException(); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs new file mode 100644 index 0000000000..c2b482ef00 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorDiagnosticFactory.cs @@ -0,0 +1,76 @@ +// 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 AngleSharp; +using Microsoft.AspNetCore.Razor.Language; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal static class BlazorDiagnosticFactory + { + public static readonly RazorDiagnosticDescriptor InvalidComponentAttributeSyntax = new RazorDiagnosticDescriptor( + "BL9980", + () => "Wrong syntax for '{0}' on '{1}': As a temporary " + + $"limitation, component attributes must be expressed with C# syntax. For example, " + + $"SomeParam=@(\"Some value\") is allowed, but SomeParam=\"Some value\" is not.", + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic Create_InvalidComponentAttributeSynx(TextPosition position, SourceSpan? span, string attributeName, string componentName) + { + span = CalculateSourcePosition(span, position); + return RazorDiagnostic.Create(InvalidComponentAttributeSyntax, span ?? SourceSpan.Undefined, attributeName, componentName); + } + + public static readonly RazorDiagnosticDescriptor UnexpectedClosingTag = new RazorDiagnosticDescriptor( + "BL9981", + () => "Unexpected closing tag '{0}' with no matching start tag.", + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic Create_UnexpectedClosingTag(SourceSpan? span, string tagName) + { + return RazorDiagnostic.Create(UnexpectedClosingTag, span ?? SourceSpan.Undefined, tagName); + } + + public static readonly RazorDiagnosticDescriptor MismatchedClosingTag = new RazorDiagnosticDescriptor( + "BL9982", + () => "Mismatching closing tag. Found '{0}' but expected '{1}'.", + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic Create_MismatchedClosingTag(SourceSpan? span, string expectedTagName, string tagName) + { + return RazorDiagnostic.Create(MismatchedClosingTag, span ?? SourceSpan.Undefined, expectedTagName, tagName); + } + + public static readonly RazorDiagnosticDescriptor MismatchedClosingTagKind = new RazorDiagnosticDescriptor( + "BL9984", + () => "Mismatching closing tag. Found '{0}' of type '{1}' but expected type '{2}'.", + RazorDiagnosticSeverity.Error); + + public static RazorDiagnostic Create_MismatchedClosingTagKind(SourceSpan? span, string tagName, string kind, string expectedKind) + { + return RazorDiagnostic.Create(MismatchedClosingTagKind, span ?? SourceSpan.Undefined, tagName, kind, expectedKind); + } + + private static SourceSpan? CalculateSourcePosition( + SourceSpan? razorTokenPosition, + TextPosition htmlNodePosition) + { + if (razorTokenPosition.HasValue) + { + var razorPos = razorTokenPosition.Value; + return new SourceSpan( + razorPos.FilePath, + razorPos.AbsoluteIndex + htmlNodePosition.Position, + razorPos.LineIndex + htmlNodePosition.Line - 1, + htmlNodePosition.Line == 1 + ? razorPos.CharacterIndex + htmlNodePosition.Column - 1 + : htmlNodePosition.Column - 1, + length: 1); + } + else + { + return null; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs index 64f74d8b57..847cda3e7b 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorExtensionInitializer.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Reflection; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Extensions; @@ -10,6 +11,38 @@ namespace Microsoft.AspNetCore.Blazor.Razor { public class BlazorExtensionInitializer : RazorExtensionInitializer { + public static readonly RazorConfiguration DeclarationConfiguration; + + public static readonly RazorConfiguration DefaultConfiguration; + + static BlazorExtensionInitializer() + { + // RazorConfiguration is changing between 15.7 and preview2 builds of Razor, this is a reflection-based + // workaround. + DeclarationConfiguration = Create("BlazorDeclaration-0.1"); + DefaultConfiguration = Create("Blazor-0.1"); + + RazorConfiguration Create(string configurationName) + { + var args = new object[] { RazorLanguageVersion.Version_2_1, configurationName, Array.Empty(), }; + + MethodInfo method; + ConstructorInfo constructor; + if ((method = typeof(RazorConfiguration).GetMethod("Create", BindingFlags.Public | BindingFlags.Static)) != null) + { + return (RazorConfiguration)method.Invoke(null, args); + } + else if ((constructor = typeof(RazorConfiguration).GetConstructors().FirstOrDefault()) != null) + { + return (RazorConfiguration)constructor.Invoke(args); + } + else + { + throw new InvalidOperationException("Can't create a configuration. This is bad."); + } + } + } + public static void Register(RazorProjectEngineBuilder builder) { if (builder == null) @@ -26,29 +59,21 @@ namespace Microsoft.AspNetCore.Blazor.Razor builder.Features.Remove(builder.Features.OfType().Single()); builder.Features.Add(new BlazorImportProjectFeature()); + var index = builder.Phases.IndexOf(builder.Phases.OfType().Single()); + builder.Phases[index] = new BlazorRazorCSharpLoweringPhase(); + builder.Features.Add(new ConfigureBlazorCodeGenerationOptions()); builder.Features.Add(new ComponentDocumentClassifierPass()); - } - // This is temporarily used to initialize a RazorEngine by the build tools until we get the features - // we need into the RazorProjectEngine (namespace). - public static void Register(IRazorEngineBuilder builder) - { - if (builder == null) + builder.Features.Add(new ComponentTagHelperDescriptorProvider()); + + if (builder.Configuration.ConfigurationName == DeclarationConfiguration.ConfigurationName) { - throw new ArgumentNullException(nameof(builder)); + // This is for 'declaration only' processing. We don't want to try and emit any method bodies during + // the design time build because we can't do it correctly until the set of components is known. + builder.Features.Add(new EliminateMethodBodyPass()); } - - FunctionsDirective.Register(builder); - ImplementsDirective.Register(builder); - InheritsDirective.Register(builder); - InjectDirective.Register(builder); - LayoutDirective.Register(builder); - - builder.Features.Add(new ConfigureBlazorCodeGenerationOptions()); - - builder.Features.Add(new ComponentDocumentClassifierPass()); } public override void Initialize(RazorProjectEngineBuilder builder) diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs index 5a6d7eb687..ee549536d0 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorIntermediateNodeWriter.cs @@ -253,7 +253,12 @@ namespace Microsoft.AspNetCore.Blazor.Razor if (isComponent && nextTag.Attributes.Count > 0) { - ThrowTemporaryComponentSyntaxError(node, nextTag, tagNameOriginalCase); + var diagnostic = BlazorDiagnosticFactory.Create_InvalidComponentAttributeSynx( + nextTag.Position, + node.Source, + nextTag.Attributes[0].Key, + tagNameOriginalCase); + throw new RazorCompilerException(diagnostic); } foreach (var attribute in nextTag.Attributes) @@ -368,13 +373,6 @@ namespace Microsoft.AspNetCore.Blazor.Razor } } - private void ThrowTemporaryComponentSyntaxError(HtmlContentIntermediateNode node, HtmlTagToken tag, string componentName) - => throw new RazorCompilerException( - $"Wrong syntax for '{tag.Attributes[0].Key}' on '{componentName}': As a temporary " + - $"limitation, component attributes must be expressed with C# syntax. For example, " + - $"SomeParam=@(\"Some value\") is allowed, but SomeParam=\"Some value\" is not.", - CalculateSourcePosition(node.Source, tag.Position)); - private SourceSpan? CalculateSourcePosition( SourceSpan? razorTokenPosition, TextPosition htmlNodePosition) diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorTemplateEngine.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorTemplateEngine.cs deleted file mode 100644 index da0450ec10..0000000000 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/BlazorTemplateEngine.cs +++ /dev/null @@ -1,57 +0,0 @@ -// 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 Microsoft.AspNetCore.Razor.Language; -using System.Text; -using System.Collections.Generic; - -namespace Microsoft.AspNetCore.Blazor.Razor -{ - /// - /// A for Blazor components. - /// - public class BlazorTemplateEngine : RazorTemplateEngine - { - // We need to implement and register this feature for tooling support to work. Subclassing TemplateEngine - // doesn't work inside visual studio. - private readonly BlazorImportProjectFeature _feature; - - public BlazorTemplateEngine(RazorEngine engine, RazorProject project) - : base(engine, project) - { - _feature = new BlazorImportProjectFeature(); - - Options.DefaultImports = RazorSourceDocument.ReadFrom(_feature.DefaultImports); - } - - public override IEnumerable GetImportItems(RazorProjectItem projectItem) - { - if (projectItem == null) - { - throw new System.ArgumentNullException(nameof(projectItem)); - } - - return _feature.GetHierarchicalImports(Project, projectItem); - } - - private static RazorSourceDocument GetDefaultImports() - { - using (var stream = new MemoryStream()) - using (var writer = new StreamWriter(stream, Encoding.UTF8)) - { - // TODO: Add other commonly-used Blazor namespaces here. Can't do so yet - // because the tooling wouldn't know about it, so it would still look like - // an error if you hadn't explicitly imported them. - writer.WriteLine("@using System"); - writer.WriteLine("@using System.Collections.Generic"); - writer.WriteLine("@using System.Linq"); - writer.WriteLine("@using System.Threading.Tasks"); - writer.Flush(); - - stream.Position = 0; - return RazorSourceDocument.ReadFrom(stream, fileName: null, encoding: Encoding.UTF8); - } - } - } -} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/CSharpIdentifier.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/CSharpIdentifier.cs new file mode 100644 index 0000000000..cb2d5a6d07 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/CSharpIdentifier.cs @@ -0,0 +1,72 @@ +// 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 System.Globalization; +using System.Text; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + // Copied from the Razor repo + internal static class CSharpIdentifier + { + private const string CshtmlExtension = ".cshtml"; + + public static string GetClassNameFromPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + if (path.EndsWith(CshtmlExtension, StringComparison.OrdinalIgnoreCase)) + { + path = path.Substring(0, path.Length - CshtmlExtension.Length); + } + + return SanitizeClassName(path); + } + + // CSharp Spec §2.4.2 + private static bool IsIdentifierStart(char character) + { + return char.IsLetter(character) || + character == '_' || + CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.LetterNumber; + } + + public static bool IsIdentifierPart(char character) + { + return char.IsDigit(character) || + IsIdentifierStart(character) || + IsIdentifierPartByUnicodeCategory(character); + } + + private static bool IsIdentifierPartByUnicodeCategory(char character) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + + return category == UnicodeCategory.NonSpacingMark || // Mn + category == UnicodeCategory.SpacingCombiningMark || // Mc + category == UnicodeCategory.ConnectorPunctuation || // Pc + category == UnicodeCategory.Format; // Cf + } + + public static string SanitizeClassName(string inputName) + { + if (!IsIdentifierStart(inputName[0]) && IsIdentifierPart(inputName[0])) + { + inputName = "_" + inputName; + } + + var builder = new StringBuilder(inputName.Length); + for (var i = 0; i < inputName.Length; i++) + { + var ch = inputName[i]; + builder.Append(IsIdentifierPart(ch) ? ch : '_'); + } + + return builder.ToString(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentDocumentClassifierPass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentDocumentClassifierPass.cs index 6ad5c0273d..0e2e51493f 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentDocumentClassifierPass.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ComponentDocumentClassifierPass.cs @@ -1,19 +1,34 @@ // 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.Razor.Language; -using Microsoft.AspNetCore.Razor.Language.CodeGeneration; -using Microsoft.AspNetCore.Razor.Language.Intermediate; using System; using System.IO; using System.Linq; +using System.Text; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.CodeGeneration; +using Microsoft.AspNetCore.Razor.Language.Intermediate; namespace Microsoft.AspNetCore.Blazor.Razor { - internal class ComponentDocumentClassifierPass : DocumentClassifierPassBase, IRazorDocumentClassifierPass + public class ComponentDocumentClassifierPass : DocumentClassifierPassBase, IRazorDocumentClassifierPass { public static readonly string ComponentDocumentKind = "Blazor.Component-0.1"; + private static readonly char[] PathSeparators = new char[] { '/', '\\' }; + + // This is a fallback value and will only be used if we can't compute + // a reasonable namespace. + public string BaseNamespace { get; set; } = "__BlazorGenerated"; + + // Set to true in the IDE so we can generated mangled class names. This is needed + // to avoid conflicts between generated design-time code and the code in the editor. + // + // A better workaround for this would be to create a singlefilegenerator that overrides + // the codegen process when a document is open, but this is more involved, so hacking + // it for now. + public bool MangleClassNames { get; set; } = false; + protected override string DocumentKind => ComponentDocumentKind; protected override bool IsMatch(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) @@ -28,24 +43,27 @@ namespace Microsoft.AspNetCore.Blazor.Razor ClassDeclarationIntermediateNode @class, MethodDeclarationIntermediateNode method) { - @namespace.Content = (string)codeDocument.Items[BlazorCodeDocItems.Namespace]; - if (@namespace.Content == null) + if (!TryComputeNamespaceAndClass( + codeDocument.Source.FilePath, + codeDocument.Source.RelativePath, + out var computedNamespace, + out var computedClass)) { - @namespace.Content = "Blazor"; + // If we can't compute a nice namespace (no relative path) then just generate something + // mangled. + computedNamespace = BaseNamespace; + computedClass = CSharpIdentifier.GetClassNameFromPath(codeDocument.Source.FilePath) ?? "__BlazorComponent"; } + if (MangleClassNames) + { + computedClass = "__" + computedClass; + } + + @namespace.Content = computedNamespace; + @class.BaseType = BlazorApi.BlazorComponent.FullTypeName; - @class.ClassName = (string)codeDocument.Items[BlazorCodeDocItems.ClassName]; - if (@class.ClassName == null) - { - @class.ClassName = codeDocument.Source.FilePath == null ? null : Path.GetFileNameWithoutExtension(codeDocument.Source.FilePath); - } - - if (@class.ClassName == null) - { - @class.ClassName = "__BlazorComponent"; - } - + @class.ClassName = computedClass; @class.Modifiers.Clear(); @class.Modifiers.Add("public"); @@ -72,6 +90,50 @@ namespace Microsoft.AspNetCore.Blazor.Razor method.Children.Insert(0, callBase); } + // In general documents will have a relative path (relative to the project root). + // We can only really compute a nice class/namespace when we know a relative path. + // + // However all kinds of thing are possible in tools. We shouldn't barf here if the document isn't + // set up correctly. + private bool TryComputeNamespaceAndClass(string filePath, string relativePath, out string @namespace, out string @class) + { + if (filePath == null || relativePath == null || filePath.Length <= relativePath.Length) + { + @namespace = null; + @class = null; + return false; + } + + // Try and infer a namespace from the project directory. We don't yet have the ability to pass + // the namespace through from the project. + var trimLength = relativePath.Length + (relativePath.StartsWith("/") ? 0 : 1); + var baseDirectory = filePath.Substring(0, filePath.Length - trimLength); + var baseNamespace = Path.GetFileName(baseDirectory); + if (string.IsNullOrEmpty(baseNamespace)) + { + @namespace = null; + @class = null; + return false; + } + + var builder = new StringBuilder(); + builder.Append(baseNamespace); // Don't sanitize, we expect it to contain dots. + + var segments = relativePath.Split(PathSeparators, StringSplitOptions.RemoveEmptyEntries); + + // Skip the last segment because it's the FileName. + for (var i = 0; i < segments.Length - 1; i++) + { + builder.Append('.'); + builder.Append(CSharpIdentifier.SanitizeClassName(segments[i])); + } + + @namespace = builder.ToString(); + @class = CSharpIdentifier.SanitizeClassName(Path.GetFileNameWithoutExtension(relativePath)); + + return true; + } + #region Workaround // This is a workaround for the fact that the base class doesn't provide good support // for replacing the IntermediateNodeWriter when building the code target. diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/EliminateMethodBodyPass.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/EliminateMethodBodyPass.cs new file mode 100644 index 0000000000..313ecfe7a1 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/EliminateMethodBodyPass.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; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.AspNetCore.Blazor.Razor +{ + internal class EliminateMethodBodyPass : IntermediateNodePassBase, IRazorOptimizationPass + { + // Run early in the optimization phase + public override int Order => Int32.MinValue; + + protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode) + { + if (codeDocument == null) + { + throw new ArgumentNullException(nameof(codeDocument)); + } + + if (documentNode == null) + { + throw new ArgumentNullException(nameof(documentNode)); + } + + var method = documentNode.FindPrimaryMethod(); + method.Children.Clear(); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ImplementsDirective.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ImplementsDirective.cs index 014f4987fd..8fab91d03f 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ImplementsDirective.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ImplementsDirective.cs @@ -28,16 +28,5 @@ namespace Microsoft.AspNetCore.Blazor.Razor builder.AddDirective(Directive); builder.Features.Add(new ImplementsDirectivePass()); } - - public static void Register(IRazorEngineBuilder builder) - { - if (builder == null) - { - throw new ArgumentNullException(nameof(builder)); - } - - builder.AddDirective(Directive); - builder.Features.Add(new ImplementsDirectivePass()); - } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/InjectDirective.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/InjectDirective.cs index 9d8b5dab4a..43fcfe5841 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/InjectDirective.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/InjectDirective.cs @@ -35,12 +35,6 @@ namespace Microsoft.AspNetCore.Blazor.Razor builder.Features.Add(new Pass()); } - public static void Register(IRazorEngineBuilder builder) - { - builder.AddDirective(Directive); - builder.Features.Add(new Pass()); - } - private class Pass : IntermediateNodePassBase, IRazorDirectiveClassifierPass { protected override void ExecuteCore( diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/LayoutDirective.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/LayoutDirective.cs index 2721b0981b..e645fb3408 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/LayoutDirective.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/LayoutDirective.cs @@ -28,16 +28,5 @@ namespace Microsoft.AspNetCore.Blazor.Razor builder.AddDirective(Directive); builder.Features.Add(new LayoutDirectivePass()); } - - public static void Register(IRazorEngineBuilder builder) - { - if (builder == null) - { - throw new ArgumentNullException(nameof(builder)); - } - - builder.AddDirective(Directive); - builder.Features.Add(new LayoutDirectivePass()); - } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Properties/AssemblyInfo.cs index 843209b4d1..2b56c392ae 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/Properties/AssemblyInfo.cs @@ -6,5 +6,7 @@ using Microsoft.AspNetCore.Blazor.Razor; using Microsoft.AspNetCore.Razor.Language; [assembly: ProvideRazorExtensionInitializer("Blazor-0.1", typeof(BlazorExtensionInitializer))] +[assembly: ProvideRazorExtensionInitializer("BlazorDeclaration-0.1", typeof(BlazorExtensionInitializer))] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Blazor.Build.Test")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Blazor.Razor.Extensions.Test")] \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RazorCompilerDiagnostic.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RazorCompilerDiagnostic.cs deleted file mode 100644 index f5b41a545a..0000000000 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RazorCompilerDiagnostic.cs +++ /dev/null @@ -1,40 +0,0 @@ -// 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. - -namespace Microsoft.AspNetCore.Blazor.Razor -{ - public class RazorCompilerDiagnostic - { - public DiagnosticType Type { get; } - public string SourceFilePath { get; } - public int Line { get; } - public int Column { get; } - public string Message { get; } - - public RazorCompilerDiagnostic( - DiagnosticType type, - string sourceFilePath, - int line, - int column, - string message) - { - Type = type; - SourceFilePath = sourceFilePath; - Line = line; - Column = column; - Message = message; - } - - public enum DiagnosticType - { - Warning, - Error - } - - public string FormatForConsole() - => $"{SourceFilePath}({Line},{Column}): {FormatTypeAndCodeForConsole()}: {Message}"; - - private string FormatTypeAndCodeForConsole() - => $"{Type.ToString().ToLowerInvariant()} Blazor"; - } -} diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RazorCompilerException.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RazorCompilerException.cs index 4e5d7a95dd..00a7ad7546 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RazorCompilerException.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/RazorCompilerException.cs @@ -12,25 +12,11 @@ namespace Microsoft.AspNetCore.Blazor.Razor /// public class RazorCompilerException : Exception { - private readonly int _line; - private readonly int _column; - - public RazorCompilerException(string message) : this(message, null) + public RazorCompilerException(RazorDiagnostic diagnostic) { + Diagnostic = diagnostic; } - public RazorCompilerException(string message, SourceSpan? source) : base(message) - { - _line = source.HasValue ? (source.Value.LineIndex + 1) : 1; - _column = source.HasValue ? (source.Value.CharacterIndex + 1) : 1; - } - - public RazorCompilerDiagnostic ToDiagnostic(string sourceFilePath) - => new RazorCompilerDiagnostic( - RazorCompilerDiagnostic.DiagnosticType.Error, - sourceFilePath, - line: _line, - column: _column, - message: Message); + public RazorDiagnostic Diagnostic { get; } } } diff --git a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs index 79895409e1..a950a989e9 100644 --- a/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs +++ b/src/Microsoft.AspNetCore.Blazor.Razor.Extensions/ScopeStack.cs @@ -29,15 +29,15 @@ namespace Microsoft.AspNetCore.Blazor.Razor { if (_stack.Count == 0) { - throw new RazorCompilerException( - $"Unexpected closing tag '{tagName}' with no matching start tag.", source); + var diagnostic = BlazorDiagnosticFactory.Create_UnexpectedClosingTag(source ?? SourceSpan.Undefined, tagName); + throw new RazorCompilerException(diagnostic); } var currentScope = _stack.Pop(); if (!tagName.Equals(currentScope.TagName, StringComparison.Ordinal)) { - throw new RazorCompilerException( - $"Mismatching closing tag. Found '{tagName}' but expected '{currentScope.TagName}'.", source); + var diagnostic = BlazorDiagnosticFactory.Create_MismatchedClosingTag(source, currentScope.TagName, tagName); + throw new RazorCompilerException(diagnostic); } // Note: there's no unit test to cover the following, because there's no known way of @@ -45,8 +45,10 @@ namespace Microsoft.AspNetCore.Blazor.Razor // just in case one day it turns out there is some way of causing this error. if (isComponent != currentScope.IsComponent) { - throw new RazorCompilerException( - $"Mismatching closing tag. Found '{tagName}' of type '{(isComponent ? "component" : "element")}' but expected type '{(currentScope.IsComponent ? "component" : "element")}'.", source); + var kind = isComponent ? "component" : "element"; + var expectedKind = currentScope.IsComponent ? "component" : "element"; + var diagnostic = BlazorDiagnosticFactory.Create_MismatchedClosingTagKind(source, tagName, kind, expectedKind); + throw new RazorCompilerException(diagnostic); } // When closing the scope for a component with children, it's time to close the lambda diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.cs new file mode 100644 index 0000000000..71c260e254 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/ComponentRenderingRazorIntegrationTest.cs @@ -0,0 +1,131 @@ +// 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.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 Xunit; + +namespace Microsoft.AspNetCore.Blazor.Build.Test +{ + public class ComponentRenderingRazorIntegrationTest : RazorIntegrationTestBase + { + [Fact] + public void SupportsChildComponentsViaTemporarySyntax() + { + // Arrange/Act + var testComponentTypeName = FullTypeName(); + var component = CompileToComponent($""); + var frames = GetRenderTree(component); + + // Assert + Assert.Collection(frames, + frame => AssertFrame.Component(frame, 1, 0)); + } + + [Fact] + public void CanPassParametersToComponents() + { + // 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($""); + var frames = GetRenderTree(component); + + // Assert + Assert.Collection(frames, + frame => AssertFrame.Component(frame, 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); + }); + } + + [Fact] + public void CanIncludeChildrenInComponents() + { + // Arrange/Act + var testComponentTypeName = FullTypeName(); + var component = CompileToComponent($"" + + $"Some text" + + $"Nested text" + + $""); + var frames = GetRenderTree(component); + + // Assert: component frames are correct + Assert.Collection(frames, + frame => AssertFrame.Component(frame, 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, + frame => AssertFrame.Text(frame, "Some text", 3), + frame => AssertFrame.Element(frame, "some-child", 3, 4), + frame => AssertFrame.Attribute(frame, "a", "1", 5), + frame => AssertFrame.Text(frame, "Nested text", 6)); + } + + [Fact] + public void CanNestComponentChildContent() + { + // Arrange/Act + var testComponentTypeName = FullTypeName(); + var component = CompileToComponent( + $"" + + $"" + + $"Some text" + + $"" + + $""); + var frames = GetRenderTree(component); + + // Assert: outer component frames are correct + Assert.Collection(frames, + frame => AssertFrame.Component(frame, 2, 0), + frame => AssertFrame.Attribute(frame, RenderTreeBuilder.ChildContent, 1)); + + // Assert: first level of ChildContent is correct + // Note that we don't really need the sequence numbers to continue on from the + // sequence numbers at the parent level. All that really matters is that they are + // correct relative to each other (i.e., incrementing) within the nesting level. + // 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), + frame => AssertFrame.Attribute(frame, RenderTreeBuilder.ChildContent, 3)); + + // Assert: second level of ChildContent is correct + 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 new file mode 100644 index 0000000000..9b0f109a92 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/DeclarationRazorIntegrationTest.cs @@ -0,0 +1,112 @@ +// 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.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; + +namespace Microsoft.AspNetCore.Blazor.Build.Test +{ + public class DeclarationRazorIntegrationTest : RazorIntegrationTestBase + { + internal override RazorConfiguration Configuration => BlazorExtensionInitializer.DeclarationConfiguration; + + [Fact] + public void DeclarationConfiguration_IncludesFunctions() + { + // Arrange & Act + var component = CompileToComponent(@" +@functions { + public string Value { get; set; } +}"); + + // Assert + var property = component.GetType().GetProperty("Value"); + Assert.NotNull(property); + Assert.Same(typeof(string), property.PropertyType); + } + + [Fact] + public void DeclarationConfiguration_IncludesInject() + { + // Arrange & Act + var component = CompileToComponent(@" +@inject string Value +"); + + // Assert + var property = component.GetType().GetProperty("Value", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(property); + Assert.Same(typeof(string), property.PropertyType); + } + + [Fact] + public void DeclarationConfiguration_IncludesUsings() + { + // Arrange & Act + var component = CompileToComponent(@" +@using System.Text +@inject StringBuilder Value +"); + + // Assert + var property = component.GetType().GetProperty("Value", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(property); + Assert.Same(typeof(StringBuilder), property.PropertyType); + } + + [Fact] + public void DeclarationConfiguration_IncludesInherits() + { + // Arrange & Act + var component = CompileToComponent($@" +@inherits {FullTypeName()} +"); + + // Assert + Assert.Same(typeof(BaseClass), component.GetType().BaseType); + } + + [Fact] + public void DeclarationConfiguration_IncludesImplements() + { + // Arrange & Act + var component = CompileToComponent($@" +@implements {FullTypeName()} +"); + + // Assert + var type = component.GetType(); + Assert.Contains(typeof(IDoCoolThings), component.GetType().GetInterfaces()); + } + + [Fact] + public void DeclarationConfiguration_RenderMethodIsEmpty() + { + // Arrange & Act + var component = CompileToComponent(@" + +@{ var message = ""hi""; } +@message + +"); + + var frames = GetRenderTree(component); + + // Assert + Assert.Empty(frames); + } + + public class BaseClass : BlazorComponent + { + } + + public interface IDoCoolThings + { + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/DiagnosticRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/DiagnosticRazorIntegrationTest.cs new file mode 100644 index 0000000000..be38679114 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/DiagnosticRazorIntegrationTest.cs @@ -0,0 +1,89 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.Blazor.Build.Test +{ + public class DiagnosticRazorIntegrationTest : RazorIntegrationTestBase + { + [Fact] + public void TemporaryComponentSyntaxRejectsParametersExpressedAsPlainHtmlAttributes() + { + // This is a temporary syntax restriction. Currently you can write: + // + // ... but are *not* allowed to write: + // + // This is because until we get the improved taghelper-based tooling, + // we're using AngleSharp to parse the plain HTML attributes, and it + // suffers from limitations: + // * Loses the casing of attribute names (MyParam becomes myparam) + // * Doesn't recognize MyBool=true as an bool (becomes mybool="true"), + // plus equivalent for other primitives like enum values + // So to avoid people getting runtime errors, we're currently imposing + // the compile-time restriction that component params have to be given + // as C# expressions, e.g., MyBool=@true and MyString=@("Hello") + + // Arrange/Act + var result = CompileToCSharp( + $"Line 1\n" + + $"Some text "); + + // Assert + Assert.Collection( + result.Diagnostics, + item => + { + Assert.Equal("BL9980", item.Id); + Assert.Equal( + $"Wrong syntax for 'myparam' on 'c:MyComponent': As a temporary " + + $"limitation, component attributes must be expressed with C# syntax. For " + + $"example, SomeParam=@(\"Some value\") is allowed, but SomeParam=\"Some value\" " + + $"is not.", item.GetMessage()); + Assert.Equal(1, item.Span.LineIndex); + Assert.Equal(10, item.Span.CharacterIndex); + }); + } + + [Fact] + public void RejectsEndTagWithNoStartTag() + { + // Arrange/Act + var result = CompileToCSharp( + "Line1\nLine2\nLine3"); + + // Assert + Assert.Collection(result.Diagnostics, + item => + { + Assert.Equal("BL9981", item.Id); + Assert.Equal("Unexpected closing tag 'mytag' with no matching start tag.", item.GetMessage()); + }); + } + + [Fact] + public void RejectsEndTagWithDifferentNameToStartTag() + { + // Arrange/Act + var result = CompileToCSharp( + $"@{{\n" + + $" var abc = 123;\n" + + $"}}\n" + + $"\n" + + $" \n" + + $" text\n" + + $" more text\n" + + $"\n"); + + // Assert + Assert.Collection(result.Diagnostics, + item => + { + Assert.Equal("BL9982", item.Id); + Assert.Equal("Mismatching closing tag. Found 'child' but expected 'root'.", item.GetMessage()); + Assert.Equal(6, item.Span.LineIndex); + Assert.Equal(20, item.Span.CharacterIndex); + }); + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/DirectiveRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/DirectiveRazorIntegrationTest.cs new file mode 100644 index 0000000000..6e30602dfa --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/DirectiveRazorIntegrationTest.cs @@ -0,0 +1,144 @@ +// 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 System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Blazor.Components; +using Microsoft.AspNetCore.Blazor.Layouts; +using Microsoft.AspNetCore.Blazor.Test.Helpers; +using Xunit; + +namespace Microsoft.AspNetCore.Blazor.Build.Test +{ + // Integration tests for Blazor's directives + public class DirectiveRazorIntegrationTest : RazorIntegrationTestBase + { + [Fact] + public void ComponentsDoNotHaveLayoutAttributeByDefault() + { + // Arrange/Act + var component = CompileToComponent($"Hello"); + + // Assert + Assert.Null(component.GetType().GetCustomAttribute()); + } + + [Fact] + public void SupportsLayoutDeclarations() + { + // Arrange/Act + var testComponentTypeName = FullTypeName(); + var component = CompileToComponent( + $"@layout {testComponentTypeName}\n" + + $"Hello"); + var frames = GetRenderTree(component); + + // Assert + var layoutAttribute = component.GetType().GetCustomAttribute(); + Assert.NotNull(layoutAttribute); + Assert.Equal(typeof(TestLayout), layoutAttribute.LayoutType); + Assert.Collection(frames, + frame => AssertFrame.Text(frame, "Hello")); + } + + [Fact] + public void SupportsImplementsDeclarations() + { + // Arrange/Act + var testInterfaceTypeName = FullTypeName(); + var component = CompileToComponent( + $"@implements {testInterfaceTypeName}\n" + + $"Hello"); + var frames = GetRenderTree(component); + + // Assert + Assert.IsAssignableFrom(component); + Assert.Collection(frames, + frame => AssertFrame.Text(frame, "Hello")); + } + + [Fact] + public void SupportsInheritsDirective() + { + // Arrange/Act + var testBaseClassTypeName = FullTypeName(); + var component = CompileToComponent( + $"@inherits {testBaseClassTypeName}" + Environment.NewLine + + $"Hello"); + var frames = GetRenderTree(component); + + // Assert + Assert.IsAssignableFrom(component); + Assert.Collection(frames, + frame => AssertFrame.Text(frame, "Hello")); + } + + [Fact] + public void SupportsInjectDirective() + { + // Arrange/Act 1: Compilation + var componentType = CompileToComponent( + $"@inject {FullTypeName()} MyService1\n" + + $"@inject {FullTypeName()} MyService2\n" + + $"Hello from @MyService1 and @MyService2").GetType(); + + // Assert 1: Compiled type has correct properties + var propertyFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.NonPublic; + var injectableProperties = componentType.GetProperties(propertyFlags) + .Where(p => p.GetCustomAttribute() != null); + Assert.Collection(injectableProperties.OrderBy(p => p.Name), + property => + { + Assert.Equal("MyService1", property.Name); + Assert.Equal(typeof(IMyService1), property.PropertyType); + Assert.False(property.GetMethod.IsPublic); + Assert.False(property.SetMethod.IsPublic); + }, + property => + { + Assert.Equal("MyService2", property.Name); + Assert.Equal(typeof(IMyService2), property.PropertyType); + Assert.False(property.GetMethod.IsPublic); + Assert.False(property.SetMethod.IsPublic); + }); + + // Arrange/Act 2: DI-supplied component has correct behavior + var serviceProvider = new TestServiceProvider(); + serviceProvider.AddService(new MyService1Impl()); + serviceProvider.AddService(new MyService2Impl()); + var componentFactory = new ComponentFactory(serviceProvider); + var component = componentFactory.InstantiateComponent(componentType); + var frames = GetRenderTree(component); + + // Assert 2: Rendered component behaves correctly + Assert.Collection(frames, + frame => AssertFrame.Text(frame, "Hello from "), + frame => AssertFrame.Text(frame, typeof(MyService1Impl).FullName), + frame => AssertFrame.Text(frame, " and "), + frame => AssertFrame.Text(frame, typeof(MyService2Impl).FullName)); + } + + public class TestLayout : ILayoutComponent + { + public RenderFragment Body { get; set; } + + public void Init(RenderHandle renderHandle) + { + } + + public void SetParameters(ParameterCollection parameters) + { + } + } + + public interface ITestInterface { } + + public class TestBaseClass : BlazorComponent { } + + public interface IMyService1 { } + public interface IMyService2 { } + public class MyService1Impl : IMyService1 { } + public class MyService2Impl : IMyService2 { } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/FilePathRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/FilePathRazorIntegrationTest.cs new file mode 100644 index 0000000000..755a56e049 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/FilePathRazorIntegrationTest.cs @@ -0,0 +1,48 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.Blazor.Build.Test +{ + // Integration tests focused on file path handling for class/namespace names + public class FilePathRazorIntegrationTest : RazorIntegrationTestBase + { + [Fact] + public void FileNameIsInvalidClasName_SanitizesInvalidClassName() + { + // Arrange + + // Act + var result = CompileToAssembly("Filename with spaces.cshtml", ""); + + // Assert + Assert.Empty(result.Diagnostics); + + var type = Assert.Single(result.Assembly.GetTypes()); + Assert.Equal(DefaultBaseNamespace, type.Namespace); + Assert.Equal("Filename_with_spaces", type.Name); + } + + [Theory] + [InlineData("ItemAtRoot.cs", "Test", "ItemAtRoot")] + [InlineData("Dir1\\MyFile.cs", "Test.Dir1", "MyFile")] + [InlineData("Dir1\\Dir2\\MyFile.cs", "Test.Dir1.Dir2", "MyFile")] + public void CreatesClassWithCorrectNameAndNamespace(string relativePath, string expectedNamespace, string expectedClassName) + { + // Arrange + relativePath = relativePath.Replace('\\', Path.DirectorySeparatorChar); + + // Act + var result = CompileToAssembly(relativePath, ""); + + // Assert + Assert.Empty(result.Diagnostics); + + var type = Assert.Single(result.Assembly.GetTypes()); + Assert.Equal(expectedNamespace, type.Namespace); + Assert.Equal(expectedClassName, type.Name); + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/NotFoundProjectItem.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/NotFoundProjectItem.cs new file mode 100644 index 0000000000..ec5359db65 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/NotFoundProjectItem.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 System.IO; + +namespace Microsoft.AspNetCore.Razor.Language +{ + /// + /// A that does not exist. + /// + internal class NotFoundProjectItem : RazorProjectItem + { + /// + /// Initializes a new instance of . + /// + /// The base path. + /// The path. + public NotFoundProjectItem(string basePath, string path) + { + BasePath = basePath; + FilePath = path; + } + + /// + public override string BasePath { get; } + + /// + public override string FilePath { get; } + + /// + public override bool Exists => false; + + /// + public override string PhysicalPath => throw new NotSupportedException(); + + /// + public override Stream Read() => throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/VirtualProjectFileSystem.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/VirtualProjectFileSystem.cs new file mode 100644 index 0000000000..a20288c1fb --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/VirtualProjectFileSystem.cs @@ -0,0 +1,215 @@ +// 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 System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.AspNetCore.Razor.Language +{ + internal class VirtualRazorProjectFileSystem : RazorProjectFileSystem + { + private readonly DirectoryNode _root = new DirectoryNode("/"); + + public override IEnumerable EnumerateItems(string basePath) + { + basePath = NormalizeAndEnsureValidPath(basePath); + var directory = _root.GetDirectory(basePath); + return directory?.EnumerateItems() ?? Enumerable.Empty(); + } + + public override RazorProjectItem GetItem(string path) + { + path = NormalizeAndEnsureValidPath(path); + return _root.GetItem(path) ?? new NotFoundProjectItem(string.Empty, path); + } + + public void Add(RazorProjectItem projectItem) + { + if (projectItem == null) + { + throw new ArgumentNullException(nameof(projectItem)); + } + + var filePath = NormalizeAndEnsureValidPath(projectItem.FilePath); + _root.AddFile(new FileNode(filePath, projectItem)); + } + + // Internal for testing + [DebuggerDisplay("{Path}")] + internal class DirectoryNode + { + public DirectoryNode(string path) + { + Path = path; + } + + public string Path { get; } + + public List Directories { get; } = new List(); + + public List Files { get; } = new List(); + + public void AddFile(FileNode fileNode) + { + var filePath = fileNode.Path; + if (!filePath.StartsWith(Path, StringComparison.OrdinalIgnoreCase)) + { + var message = "Error"; + throw new InvalidOperationException(message); + } + + // Look for the first / that appears in the path after the current directory path. + var directoryPath = GetDirectoryPath(filePath); + var directory = GetOrAddDirectory(this, directoryPath, createIfNotExists: true); + Debug.Assert(directory != null); + directory.Files.Add(fileNode); + } + + public DirectoryNode GetDirectory(string path) + { + if (!path.StartsWith(Path, StringComparison.OrdinalIgnoreCase)) + { + var message = "Error"; + throw new InvalidOperationException(message); + } + + return GetOrAddDirectory(this, path); + } + + public IEnumerable EnumerateItems() + { + foreach (var file in Files) + { + yield return file.ProjectItem; + } + + foreach (var directory in Directories) + { + foreach (var file in directory.EnumerateItems()) + { + yield return file; + } + } + } + + public RazorProjectItem GetItem(string path) + { + if (!path.StartsWith(Path, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Error"); + } + + var directoryPath = GetDirectoryPath(path); + var directory = GetOrAddDirectory(this, directoryPath); + if (directory == null) + { + return null; + } + + foreach (var file in directory.Files) + { + var filePath = file.Path; + var directoryLength = directory.Path.Length; + + // path, filePath -> /Views/Home/Index.cshtml + // directory.Path -> /Views/Home/ + // We only need to match the file name portion since we've already matched the directory segment. + if (string.Compare(path, directoryLength, filePath, directoryLength, path.Length - directoryLength, StringComparison.OrdinalIgnoreCase) == 0) + { + return file.ProjectItem; + } + } + + return null; + } + + private static string GetDirectoryPath(string path) + { + // /dir1/dir2/file.cshtml -> /dir1/dir2/ + var fileNameIndex = path.LastIndexOf('/'); + if (fileNameIndex == -1) + { + return path; + } + + return path.Substring(0, fileNameIndex + 1); + } + + private static DirectoryNode GetOrAddDirectory( + DirectoryNode directory, + string path, + bool createIfNotExists = false) + { + Debug.Assert(!string.IsNullOrEmpty(path)); + if (path[path.Length - 1] != '/') + { + path += '/'; + } + + int index; + while ((index = path.IndexOf('/', directory.Path.Length)) != -1 && index != path.Length) + { + var subDirectory = FindSubDirectory(directory, path); + + if (subDirectory == null) + { + if (createIfNotExists) + { + var directoryPath = path.Substring(0, index + 1); // + 1 to include trailing slash + subDirectory = new DirectoryNode(directoryPath); + directory.Directories.Add(subDirectory); + } + else + { + return null; + } + } + + directory = subDirectory; + } + + return directory; + } + + private static DirectoryNode FindSubDirectory(DirectoryNode parentDirectory, string path) + { + for (var i = 0; i < parentDirectory.Directories.Count; i++) + { + // ParentDirectory.Path -> /Views/Home/ + // CurrentDirectory.Path -> /Views/Home/SubDir/ + // Path -> /Views/Home/SubDir/MorePath/File.cshtml + // Each invocation of FindSubDirectory returns the immediate subdirectory along the path to the file. + + var currentDirectory = parentDirectory.Directories[i]; + var directoryPath = currentDirectory.Path; + var startIndex = parentDirectory.Path.Length; + var directoryNameLength = directoryPath.Length - startIndex; + + if (string.Compare(path, startIndex, directoryPath, startIndex, directoryPath.Length - startIndex, StringComparison.OrdinalIgnoreCase) == 0) + { + return currentDirectory; + } + } + + return null; + } + } + + // Internal for testing + [DebuggerDisplay("{Path}")] + internal struct FileNode + { + public FileNode(string path, RazorProjectItem projectItem) + { + Path = path; + ProjectItem = projectItem; + } + + public string Path { get; } + + public RazorProjectItem ProjectItem { get; } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/VirtualProjectItem.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/VirtualProjectItem.cs new file mode 100644 index 0000000000..cf00b888a0 --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/Razor/VirtualProjectItem.cs @@ -0,0 +1,36 @@ +// 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; + +namespace Microsoft.AspNetCore.Razor.Language +{ + internal class VirtualProjectItem : RazorProjectItem + { + private readonly byte[] _content; + + public VirtualProjectItem(string basePath, string filePath, string physicalPath, string relativePhysicalPath, byte[] content) + { + BasePath = basePath; + FilePath = filePath; + PhysicalPath = physicalPath; + RelativePhysicalPath = relativePhysicalPath; + _content = content; + } + + public override string BasePath { get; } + + public override string RelativePhysicalPath { get; } + + public override string FilePath { get; } + + public override string PhysicalPath { get; } + + public override bool Exists => true; + + public override Stream Read() + { + return new MemoryStream(_content); + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs deleted file mode 100644 index 92d6a38032..0000000000 --- a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorCompilerTest.cs +++ /dev/null @@ -1,1000 +0,0 @@ -// 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.Build.Core.RazorCompilation; -using Microsoft.AspNetCore.Blazor.Components; -using Microsoft.AspNetCore.Blazor.Layouts; -using Microsoft.AspNetCore.Blazor.Razor; -using Microsoft.AspNetCore.Blazor.Rendering; -using Microsoft.AspNetCore.Blazor.RenderTree; -using Microsoft.AspNetCore.Blazor.Test.Helpers; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.InteropServices; -using System.Text; -using Xunit; - -namespace Microsoft.AspNetCore.Blazor.Build.Test -{ - public class RazorCompilerTest - { - [Fact] - public void RejectsInvalidClassName() - { - // Arrange/Act - var result = CompileToCSharp( - GetArbitraryPlatformValidDirectoryPath(), - "Filename with spaces.cshtml", - "ignored code", - "ignored namespace"); - - // Assert - Assert.Collection(result.Diagnostics, - item => - { - Assert.Equal(RazorCompilerDiagnostic.DiagnosticType.Error, item.Type); - Assert.StartsWith($"Invalid name 'Filename with spaces'", item.Message); - }); - } - - [Theory] - [InlineData("\\unrelated.cs")] - [InlineData("..\\outsideroot.cs")] - public void RejectsFilenameOutsideRoot(string filename) - { - // Arrange/Act - filename = filename.Replace('\\', Path.DirectorySeparatorChar); - var result = CompileToCSharp( - GetArbitraryPlatformValidDirectoryPath(), - filename, - "ignored code", - "ignored namespace"); - - // Assert - Assert.Collection(result.Diagnostics, - item => - { - Assert.Equal(RazorCompilerDiagnostic.DiagnosticType.Error, item.Type); - Assert.StartsWith($"File is not within source root directory: '{filename}'", item.Message); - }); - } - - [Theory] - [InlineData("ItemAtRoot.cs", "Test.Base", "ItemAtRoot")] - [InlineData(".\\ItemAtRoot.cs", "Test.Base", "ItemAtRoot")] - [InlineData("x:\\dir\\subdir\\ItemAtRoot.cs", "Test.Base", "ItemAtRoot")] - [InlineData("Dir1\\MyFile.cs", "Test.Base.Dir1", "MyFile")] - [InlineData("Dir1\\Dir2\\MyFile.cs", "Test.Base.Dir1.Dir2", "MyFile")] - public void CreatesClassWithCorrectNameAndNamespace(string relativePath, string expectedNamespace, string expectedClassName) - { - // Arrange/Act - relativePath = relativePath.Replace(ArbitraryWindowsPath, GetArbitraryPlatformValidDirectoryPath()); - relativePath = relativePath.Replace('\\', Path.DirectorySeparatorChar); - var result = CompileToAssembly( - GetArbitraryPlatformValidDirectoryPath(), - relativePath, - "{* No code *}", - "Test.Base"); - - // Assert - Assert.Empty(result.Diagnostics); - Assert.Collection(result.Assembly.GetTypes(), - type => - { - Assert.Equal(expectedNamespace, type.Namespace); - Assert.Equal(expectedClassName, type.Name); - }); - } - - [Fact] - public void SupportsPlainText() - { - // Arrange/Act - var component = CompileToComponent("Some plain text"); - var frames = GetRenderTree(component); - - // Assert - Assert.Collection(frames, - frame => AssertFrame.Text(frame, "Some plain text", 0)); - } - - [Fact] - public void SupportsCSharpExpressions() - { - // Arrange/Act - var component = CompileToComponent(@" - @(""Hello"") - @((object)null) - @(123) - @(new object()) - "); - - // 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)); - } - - [Fact] - public void SupportsCSharpFunctionsBlock() - { - // Arrange/Act - var component = CompileToComponent(@" - @foreach(var item in items) { - @item - } - @functions { - string[] items = new[] { ""First"", ""Second"", ""Third"" }; - } - "); - - // 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)); - } - - [Fact] - public void SupportsElements() - { - // Arrange/Act - var component = CompileToComponent("Hello"); - - // Assert - Assert.Collection(GetRenderTree(component), - frame => AssertFrame.Element(frame, "myelem", 2, 0), - frame => AssertFrame.Text(frame, "Hello", 1)); - } - - [Fact] - public void SupportsSelfClosingElements() - { - // Arrange/Act - var component = CompileToComponent("Some text so elem isn't at position 0 "); - - // Assert - Assert.Collection(GetRenderTree(component), - frame => AssertFrame.Text(frame, "Some text so elem isn't at position 0 ", 0), - frame => AssertFrame.Element(frame, "myelem", 1, 1)); - } - - [Fact] - public void SupportsVoidHtmlElements() - { - // Arrange/Act - var component = CompileToComponent("Some text so elem isn't at position 0 "); - - // Assert - Assert.Collection(GetRenderTree(component), - frame => AssertFrame.Text(frame, "Some text so elem isn't at position 0 ", 0), - frame => AssertFrame.Element(frame, "img", 1, 1)); - } - - [Fact] - public void SupportsComments() - { - // Arrange/Act - var component = CompileToComponent("StartEnd"); - var frames = GetRenderTree(component); - - // Assert - Assert.Collection(frames, - frame => AssertFrame.Text(frame, "Start", 0), - frame => AssertFrame.Text(frame, "End", 1)); - } - - [Fact] - public void SupportsAttributesWithLiteralValues() - { - // Arrange/Act - var component = CompileToComponent(""); - - // Assert - Assert.Collection(GetRenderTree(component), - frame => AssertFrame.Element(frame, "elem", 3, 0), - frame => AssertFrame.Attribute(frame, "attrib-one", "Value 1", 1), - frame => AssertFrame.Attribute(frame, "a2", "v2", 2)); - } - - [Fact] - public void SupportsAttributesWithStringExpressionValues() - { - // Arrange/Act - var component = CompileToComponent( - "@{ var myValue = \"My string\"; }" - + ""); - - // Assert - Assert.Collection(GetRenderTree(component), - frame => AssertFrame.Element(frame, "elem", 2, 0), - frame => AssertFrame.Attribute(frame, "attr", "My string", 1)); - } - - [Fact] - public void SupportsAttributesWithNonStringExpressionValues() - { - // Arrange/Act - var component = CompileToComponent( - "@{ var myValue = 123; }" - + ""); - - // Assert - Assert.Collection(GetRenderTree(component), - frame => AssertFrame.Element(frame, "elem", 2, 0), - frame => AssertFrame.Attribute(frame, "attr", "123", 1)); - } - - [Fact] - public void SupportsAttributesWithInterpolatedStringExpressionValues() - { - // Arrange/Act - var component = CompileToComponent( - "@{ var myValue = \"world\"; var myNum=123; }" - + ""); - - // Assert - Assert.Collection(GetRenderTree(component), - frame => AssertFrame.Element(frame, "elem", 2, 0), - frame => AssertFrame.Attribute(frame, "attr", "Hello, WORLD with number 246!", 1)); - } - - [Fact] - public void SupportsAttributesWithEventHandlerValues() - { - // Arrange/Act - var component = CompileToComponent( - @" - @functions { - public bool HandlerWasCalled { get; set; } = false; - - void MyHandleEvent(Microsoft.AspNetCore.Blazor.UIEventArgs eventArgs) - { - HandlerWasCalled = true; - } - }"); - var handlerWasCalledProperty = component.GetType().GetProperty("HandlerWasCalled"); - - // Assert - Assert.False((bool)handlerWasCalledProperty.GetValue(component)); - Assert.Collection(GetRenderTree(component), - frame => AssertFrame.Element(frame, "elem", 2, 0), - frame => - { - Assert.Equal(RenderTreeFrameType.Attribute, frame.FrameType); - Assert.Equal(1, frame.Sequence); - Assert.NotNull(frame.AttributeValue); - - ((UIEventHandler)frame.AttributeValue)(null); - Assert.True((bool)handlerWasCalledProperty.GetValue(component)); - }, - frame => AssertFrame.Whitespace(frame, 2)); - } - - [Fact] - public void SupportsAttributesWithCSharpCodeBlockValues() - { - // Arrange/Act - var component = CompileToComponent( - @" - @functions { - public bool DidInvokeCode { get; set; } = false; - }"); - var didInvokeCodeProperty = component.GetType().GetProperty("DidInvokeCode"); - var frames = GetRenderTree(component); - - // Assert - Assert.False((bool)didInvokeCodeProperty.GetValue(component)); - Assert.Collection(frames, - frame => AssertFrame.Element(frame, "elem", 2, 0), - frame => - { - Assert.Equal(RenderTreeFrameType.Attribute, frame.FrameType); - Assert.NotNull(frame.AttributeValue); - Assert.Equal(1, frame.Sequence); - - ((UIEventHandler)frame.AttributeValue)(null); - Assert.True((bool)didInvokeCodeProperty.GetValue(component)); - }, - frame => AssertFrame.Whitespace(frame, 2)); - } - - [Fact] - public void SupportsUsingStatements() - { - // Arrange/Act - var component = CompileToComponent( - @"@using System.Collections.Generic - @(typeof(List).FullName)"); - var frames = GetRenderTree(component); - - // Assert - Assert.Collection(frames, - frame => AssertFrame.Whitespace(frame, 0), - frame => AssertFrame.Text(frame, typeof(List).FullName, 1)); - } - - [Fact] - public void SupportsAttributeFramesEvaluatedInline() - { - // Arrange/Act - var component = CompileToComponent( - @" - @functions { - public bool DidInvokeCode { get; set; } = false; - void MyHandler() - { - DidInvokeCode = true; - } - }"); - var didInvokeCodeProperty = component.GetType().GetProperty("DidInvokeCode"); - - // Assert - Assert.False((bool)didInvokeCodeProperty.GetValue(component)); - Assert.Collection(GetRenderTree(component), - frame => AssertFrame.Element(frame, "elem", 2, 0), - frame => - { - Assert.Equal(RenderTreeFrameType.Attribute, frame.FrameType); - Assert.NotNull(frame.AttributeValue); - Assert.Equal(1, frame.Sequence); - - ((UIEventHandler)frame.AttributeValue)(null); - Assert.True((bool)didInvokeCodeProperty.GetValue(component)); - }, - frame => AssertFrame.Whitespace(frame, 2)); - } - - [Fact] - public void SupportsTwoWayBindingForTextboxes() - { - // Arrange/Act - var component = CompileToComponent( - @" - @functions { - public string MyValue { get; set; } = ""Initial value""; - }"); - var myValueProperty = component.GetType().GetProperty("MyValue"); - - // Assert - var frames = GetRenderTree(component); - Assert.Collection(frames, - frame => AssertFrame.Element(frame, "input", 3, 0), - frame => AssertFrame.Attribute(frame, "value", "Initial value", 1), - frame => - { - AssertFrame.Attribute(frame, "onchange", 2); - - // Trigger the change event to show it updates the property - ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs - { - Value = "Modified value" - }); - Assert.Equal("Modified value", myValueProperty.GetValue(component)); - }, - frame => AssertFrame.Text(frame, "\n", 3)); - } - - [Fact] - public void SupportsTwoWayBindingForDateValues() - { - // Arrange/Act - var component = CompileToComponent( - @" - @functions { - public DateTime MyDate { get; set; } = new DateTime(2018, 3, 4, 1, 2, 3); - }"); - var myDateProperty = component.GetType().GetProperty("MyDate"); - - // Assert - var frames = GetRenderTree(component); - Assert.Collection(frames, - frame => AssertFrame.Element(frame, "input", 3, 0), - frame => AssertFrame.Attribute(frame, "value", new DateTime(2018, 3, 4, 1, 2, 3).ToString(), 1), - frame => - { - AssertFrame.Attribute(frame, "onchange", 2); - - // Trigger the change event to show it updates the property - var newDateValue = new DateTime(2018, 3, 5, 4, 5, 6); - ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs - { - Value = newDateValue.ToString() - }); - Assert.Equal(newDateValue, myDateProperty.GetValue(component)); - }, - frame => AssertFrame.Text(frame, "\n", 3)); - } - - [Fact] - public void SupportsTwoWayBindingForDateValuesWithFormatString() - { - // Arrange/Act - var component = CompileToComponent( - @" - @functions { - public DateTime MyDate { get; set; } = new DateTime(2018, 3, 4); - }"); - var myDateProperty = component.GetType().GetProperty("MyDate"); - - // Assert - var frames = GetRenderTree(component); - Assert.Collection(frames, - frame => AssertFrame.Element(frame, "input", 3, 0), - frame => AssertFrame.Attribute(frame, "value", "Sun 2018-03-04", 1), - frame => - { - AssertFrame.Attribute(frame, "onchange", 2); - - // Trigger the change event to show it updates the property - ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs - { - Value = "Mon 2018-03-05" - }); - Assert.Equal(new DateTime(2018, 3, 5), myDateProperty.GetValue(component)); - }, - frame => AssertFrame.Text(frame, "\n", 3)); - } - - [Fact] - public void SupportsTwoWayBindingForBoolValues() - { - // Arrange/Act - var component = CompileToComponent( - @" - @functions { - public bool MyValue { get; set; } = true; - }"); - var myValueProperty = component.GetType().GetProperty("MyValue"); - - // Assert - var frames = GetRenderTree(component); - Assert.Collection(frames, - frame => AssertFrame.Element(frame, "input", 3, 0), - frame => AssertFrame.Attribute(frame, "value", "True", 1), - frame => - { - AssertFrame.Attribute(frame, "onchange", 2); - - // Trigger the change event to show it updates the property - ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs - { - Value = false - }); - Assert.False((bool)myValueProperty.GetValue(component)); - }, - frame => AssertFrame.Text(frame, "\n", 3)); - } - - [Fact] - public void SupportsTwoWayBindingForEnumValues() - { - // Arrange/Act - var myEnumType = FullTypeName(); - var component = CompileToComponent( - $@" - @functions {{ - public {myEnumType} MyValue {{ get; set; }} = {myEnumType}.{nameof(MyEnum.FirstValue)}; - }}"); - var myValueProperty = component.GetType().GetProperty("MyValue"); - - // Assert - var frames = GetRenderTree(component); - Assert.Collection(frames, - frame => AssertFrame.Element(frame, "input", 3, 0), - frame => AssertFrame.Attribute(frame, "value", MyEnum.FirstValue.ToString(), 1), - frame => - { - AssertFrame.Attribute(frame, "onchange", 2); - - // Trigger the change event to show it updates the property - ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs - { - Value = MyEnum.SecondValue.ToString() - }); - Assert.Equal(MyEnum.SecondValue, (MyEnum)myValueProperty.GetValue(component)); - }, - frame => AssertFrame.Text(frame, "\n", 3)); - } - - [Fact] - public void SupportsChildComponentsViaTemporarySyntax() - { - // Arrange/Act - var testComponentTypeName = FullTypeName(); - var component = CompileToComponent($""); - var frames = GetRenderTree(component); - - // Assert - Assert.Collection(frames, - frame => AssertFrame.Component(frame, 1, 0)); - } - - [Fact] - public void CanPassParametersToComponents() - { - // 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($""); - var frames = GetRenderTree(component); - - // Assert - Assert.Collection(frames, - frame => AssertFrame.Component(frame, 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); - }); - } - - [Fact] - public void TemporaryComponentSyntaxRejectsParametersExpressedAsPlainHtmlAttributes() - { - // This is a temporary syntax restriction. Currently you can write: - // - // ... but are *not* allowed to write: - // - // This is because until we get the improved taghelper-based tooling, - // we're using AngleSharp to parse the plain HTML attributes, and it - // suffers from limitations: - // * Loses the casing of attribute names (MyParam becomes myparam) - // * Doesn't recognize MyBool=true as an bool (becomes mybool="true"), - // plus equivalent for other primitives like enum values - // So to avoid people getting runtime errors, we're currently imposing - // the compile-time restriction that component params have to be given - // as C# expressions, e.g., MyBool=@true and MyString=@("Hello") - - // Arrange/Act - var result = CompileToCSharp( - $"Line 1\n" + - $"Some text "); - - // Assert - Assert.Collection(result.Diagnostics, - item => - { - Assert.Equal(RazorCompilerDiagnostic.DiagnosticType.Error, item.Type); - Assert.StartsWith($"Wrong syntax for 'myparam' on 'c:MyComponent': As a temporary " + - $"limitation, component attributes must be expressed with C# syntax. For " + - $"example, SomeParam=@(\"Some value\") is allowed, but SomeParam=\"Some value\" " + - $"is not.", item.Message); - Assert.Equal(2, item.Line); - Assert.Equal(11, item.Column); - }); - } - - [Fact] - public void CanIncludeChildrenInComponents() - { - // Arrange/Act - var testComponentTypeName = FullTypeName(); - var component = CompileToComponent($"" + - $"Some text" + - $"Nested text" + - $""); - var frames = GetRenderTree(component); - - // Assert: component frames are correct - Assert.Collection(frames, - frame => AssertFrame.Component(frame, 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, - frame => AssertFrame.Text(frame, "Some text", 3), - frame => AssertFrame.Element(frame, "some-child", 3, 4), - frame => AssertFrame.Attribute(frame, "a", "1", 5), - frame => AssertFrame.Text(frame, "Nested text", 6)); - } - - [Fact] - public void CanNestComponentChildContent() - { - // Arrange/Act - var testComponentTypeName = FullTypeName(); - var component = CompileToComponent( - $"" + - $"" + - $"Some text" + - $"" + - $""); - var frames = GetRenderTree(component); - - // Assert: outer component frames are correct - Assert.Collection(frames, - frame => AssertFrame.Component(frame, 2, 0), - frame => AssertFrame.Attribute(frame, RenderTreeBuilder.ChildContent, 1)); - - // Assert: first level of ChildContent is correct - // Note that we don't really need the sequence numbers to continue on from the - // sequence numbers at the parent level. All that really matters is that they are - // correct relative to each other (i.e., incrementing) within the nesting level. - // 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), - frame => AssertFrame.Attribute(frame, RenderTreeBuilder.ChildContent, 3)); - - // Assert: second level of ChildContent is correct - Assert.Collection(GetFrames((RenderFragment)innerFrames[1].AttributeValue), - frame => AssertFrame.Text(frame, "Some text", 4)); - } - - [Fact] - public void ComponentsDoNotHaveLayoutAttributeByDefault() - { - // Arrange/Act - var component = CompileToComponent($"Hello"); - - // Assert - Assert.Null(component.GetType().GetCustomAttribute()); - } - - [Fact] - public void SupportsLayoutDeclarations() - { - // Arrange/Act - var testComponentTypeName = FullTypeName(); - var component = CompileToComponent( - $"@layout {testComponentTypeName}\n" + - $"Hello"); - var frames = GetRenderTree(component); - - // Assert - var layoutAttribute = component.GetType().GetCustomAttribute(); - Assert.NotNull(layoutAttribute); - Assert.Equal(typeof(TestLayout), layoutAttribute.LayoutType); - Assert.Collection(frames, - frame => AssertFrame.Text(frame, "Hello")); - } - - [Fact] - public void SupportsImplementsDeclarations() - { - // Arrange/Act - var testInterfaceTypeName = FullTypeName(); - var component = CompileToComponent( - $"@implements {testInterfaceTypeName}\n" + - $"Hello"); - var frames = GetRenderTree(component); - - // Assert - Assert.IsAssignableFrom(component); - Assert.Collection(frames, - frame => AssertFrame.Text(frame, "Hello")); - } - - [Fact] - public void SupportsInheritsDirective() - { - // Arrange/Act - var testBaseClassTypeName = FullTypeName(); - var component = CompileToComponent( - $"@inherits {testBaseClassTypeName}" + Environment.NewLine + - $"Hello"); - var frames = GetRenderTree(component); - - // Assert - Assert.IsAssignableFrom(component); - Assert.Collection(frames, - frame => AssertFrame.Text(frame, "Hello")); - } - - [Fact] - public void SurfacesCSharpCompilationErrors() - { - // Arrange/Act - var result = CompileToAssembly( - GetArbitraryPlatformValidDirectoryPath(), - "file.cshtml", - "@invalidVar", - "Test.Base"); - - // Assert - Assert.Collection(result.Diagnostics, - diagnostic => Assert.Contains("'invalidVar'", diagnostic.GetMessage())); - } - - [Fact] - public void RejectsEndTagWithNoStartTag() - { - // Arrange/Act - var result = CompileToCSharp( - "Line1\nLine2\nLine3"); - - // Assert - Assert.Collection(result.Diagnostics, - item => - { - Assert.Equal(RazorCompilerDiagnostic.DiagnosticType.Error, item.Type); - Assert.StartsWith("Unexpected closing tag 'mytag' with no matching start tag.", item.Message); - }); - } - - [Fact] - public void RejectsEndTagWithDifferentNameToStartTag() - { - // Arrange/Act - var result = CompileToCSharp( - $"@{{\n" + - $" var abc = 123;\n" + - $"}}\n" + - $"\n" + - $" \n" + - $" text\n" + - $" more text\n" + - $"\n"); - - // Assert - Assert.Collection(result.Diagnostics, - item => - { - Assert.Equal(RazorCompilerDiagnostic.DiagnosticType.Error, item.Type); - Assert.StartsWith("Mismatching closing tag. Found 'root' but expected 'child'.", item.Message); - Assert.Equal(7, item.Line); - Assert.Equal(21, item.Column); - }); - } - - [Fact] - public void SupportsInjectDirective() - { - // Arrange/Act 1: Compilation - var componentType = CompileToComponent( - $"@inject {FullTypeName()} MyService1\n" + - $"@inject {FullTypeName()} MyService2\n" + - $"Hello from @MyService1 and @MyService2").GetType(); - - // Assert 1: Compiled type has correct properties - var propertyFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.NonPublic; - var injectableProperties = componentType.GetProperties(propertyFlags) - .Where(p => p.GetCustomAttribute() != null); - Assert.Collection(injectableProperties.OrderBy(p => p.Name), - property => - { - Assert.Equal("MyService1", property.Name); - Assert.Equal(typeof(IMyService1), property.PropertyType); - Assert.False(property.GetMethod.IsPublic); - Assert.False(property.SetMethod.IsPublic); - }, - property => - { - Assert.Equal("MyService2", property.Name); - Assert.Equal(typeof(IMyService2), property.PropertyType); - Assert.False(property.GetMethod.IsPublic); - Assert.False(property.SetMethod.IsPublic); - }); - - // Arrange/Act 2: DI-supplied component has correct behavior - var serviceProvider = new TestServiceProvider(); - serviceProvider.AddService(new MyService1Impl()); - serviceProvider.AddService(new MyService2Impl()); - var componentFactory = new ComponentFactory(serviceProvider); - var component = componentFactory.InstantiateComponent(componentType); - var frames = GetRenderTree(component); - - // Assert 2: Rendered component behaves correctly - Assert.Collection(frames, - frame => AssertFrame.Text(frame, "Hello from "), - frame => AssertFrame.Text(frame, typeof(MyService1Impl).FullName), - frame => AssertFrame.Text(frame, " and "), - frame => AssertFrame.Text(frame, typeof(MyService2Impl).FullName)); - } - - private static RenderTreeFrame[] GetRenderTree(IComponent component) - { - var renderer = new TestRenderer(); - renderer.AttachComponent(component); - component.SetParameters(ParameterCollection.Empty); - return renderer.LatestBatchReferenceFrames; - } - - private static IComponent CompileToComponent(string cshtmlSource) - { - var testComponentTypeName = "TestComponent"; - var testComponentNamespace = "Test"; - var assemblyResult = CompileToAssembly(GetArbitraryPlatformValidDirectoryPath(), $"{testComponentTypeName}.cshtml", cshtmlSource, testComponentNamespace); - Assert.Empty(assemblyResult.Diagnostics); - var testComponentType = assemblyResult.Assembly.GetType($"{testComponentNamespace}.{testComponentTypeName}"); - return (IComponent)Activator.CreateInstance(testComponentType); - } - - const string ArbitraryWindowsPath = "x:\\dir\\subdir"; - const string ArbitraryMacLinuxPath = "/dir/subdir"; - private static string GetArbitraryPlatformValidDirectoryPath() - => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ArbitraryWindowsPath : ArbitraryMacLinuxPath; - - private static CompileToAssemblyResult CompileToAssembly(string cshtmlRootPath, string cshtmlRelativePath, string cshtmlContent, string outputNamespace) - { - var csharpResult = CompileToCSharp(cshtmlRootPath, cshtmlRelativePath, cshtmlContent, outputNamespace); - if (csharpResult.Diagnostics.Any()) - { - var diagnosticsLog = string.Join(Environment.NewLine, - csharpResult.Diagnostics.Select(d => d.FormatForConsole()).ToArray()); - throw new InvalidOperationException($"Aborting compilation to assembly because RazorCompiler returned nonempty diagnostics: {diagnosticsLog}"); - } - - var syntaxTrees = new[] - { - CSharpSyntaxTree.ParseText(csharpResult.Code) - }; - var referenceAssembliesContainingTypes = new[] - { - typeof(System.Runtime.AssemblyTargetedPatchBandAttribute), // System.Runtime - typeof(BlazorComponent), - typeof(RazorCompilerTest), // Reference this assembly, so that we can refer to test component types - }; - var references = referenceAssembliesContainingTypes - .SelectMany(type => type.Assembly.GetReferencedAssemblies().Concat(new[] { type.Assembly.GetName() })) - .Distinct() - .Select(Assembly.Load) - .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) - .ToList(); - var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); - var assemblyName = "TestAssembly" + Guid.NewGuid().ToString("N"); - var compilation = CSharpCompilation.Create(assemblyName, - syntaxTrees, - references, - options); - - using (var peStream = new MemoryStream()) - { - compilation.Emit(peStream); - - var diagnostics = compilation - .GetDiagnostics() - .Where(d => d.Severity != DiagnosticSeverity.Hidden); - return new CompileToAssemblyResult - { - Diagnostics = diagnostics, - VerboseLog = csharpResult.VerboseLog, - Assembly = diagnostics.Any() ? null : Assembly.Load(peStream.ToArray()) - }; - } - } - - private static CompileToCSharpResult CompileToCSharp(string cshtmlContent) - => CompileToCSharp( - GetArbitraryPlatformValidDirectoryPath(), - "test.cshtml", - cshtmlContent, - "TestNamespace"); - - private static CompileToCSharpResult CompileToCSharp(string cshtmlRootPath, string cshtmlRelativePath, string cshtmlContent, string outputNamespace) - { - using (var resultStream = new MemoryStream()) - using (var resultWriter = new StreamWriter(resultStream)) - using (var verboseLogStream = new MemoryStream()) - using (var verboseWriter = new StreamWriter(verboseLogStream)) - using (var inputContents = new MemoryStream(Encoding.UTF8.GetBytes(cshtmlContent))) - { - var diagnostics = new RazorCompiler().CompileSingleFile( - cshtmlRootPath, - cshtmlRelativePath, - inputContents, - outputNamespace, - resultWriter, - verboseWriter); - - resultWriter.Flush(); - verboseWriter.Flush(); - return new CompileToCSharpResult - { - Code = Encoding.UTF8.GetString(resultStream.ToArray()), - VerboseLog = Encoding.UTF8.GetString(verboseLogStream.ToArray()), - Diagnostics = diagnostics - }; - } - } - - private ArrayRange GetFrames(RenderFragment fragment) - { - var builder = new RenderTreeBuilder(new TestRenderer()); - fragment(builder); - return builder.GetFrames(); - } - - private class CompileToCSharpResult - { - public string Code { get; set; } - public string VerboseLog { get; set; } - public IEnumerable Diagnostics { get; set; } - } - - private class CompileToAssemblyResult - { - public Assembly Assembly { get; set; } - public string VerboseLog { get; set; } - public IEnumerable Diagnostics { get; set; } - } - - private class TestRenderer : Renderer - { - public TestRenderer() : base(new TestServiceProvider()) - { - } - - public RenderTreeFrame[] LatestBatchReferenceFrames { get; private set; } - - public void AttachComponent(IComponent component) - => AssignComponentId(component); - - protected override void UpdateDisplay(RenderBatch renderBatch) - { - LatestBatchReferenceFrames = renderBatch.ReferenceFrames.ToArray(); - } - } - - public class TestComponent : IComponent - { - public void Init(RenderHandle renderHandle) - { - } - - public void SetParameters(ParameterCollection parameters) - { - } - } - - public class TestLayout : ILayoutComponent - { - public RenderFragment Body { get; set; } - - public void Init(RenderHandle renderHandle) - { - } - - public void SetParameters(ParameterCollection parameters) - { - } - } - - public interface ITestInterface { } - - public class TestBaseClass : BlazorComponent { } - - public class SomeType { } - - public interface IMyService1 { } - public interface IMyService2 { } - public class MyService1Impl : IMyService1 { } - public class MyService2Impl : IMyService2 { } - - public enum MyEnum { FirstValue, SecondValue } - - private static string FullTypeName() - => typeof(T).FullName.Replace('+', '.'); - } -} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorIntegrationTestBase.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorIntegrationTestBase.cs new file mode 100644 index 0000000000..810c69f17a --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RazorIntegrationTestBase.cs @@ -0,0 +1,240 @@ +// 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 System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.AspNetCore.Blazor.Components; +using Microsoft.AspNetCore.Blazor.Razor; +using Microsoft.AspNetCore.Blazor.Rendering; +using Microsoft.AspNetCore.Blazor.RenderTree; +using Microsoft.AspNetCore.Blazor.Test.Helpers; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Blazor.Build.Test +{ + public class RazorIntegrationTestBase + { + internal const string ArbitraryWindowsPath = "x:\\dir\\subdir\\Test"; + internal const string ArbitraryMacLinuxPath = "/dir/subdir/Test"; + + private RazorProjectEngine _projectEngine; + + public RazorIntegrationTestBase() + { + Configuration = BlazorExtensionInitializer.DefaultConfiguration; + FileSystem = new VirtualRazorProjectFileSystem(); + WorkingDirectory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ArbitraryWindowsPath : ArbitraryMacLinuxPath; + + DefaultBaseNamespace = "Test"; // Matches the default working directory + DefaultFileName = "TestComponent.cshtml"; + } + + internal virtual RazorConfiguration Configuration { get; } + + internal virtual string DefaultBaseNamespace { get; } + + internal virtual string DefaultFileName { get; } + + internal virtual VirtualRazorProjectFileSystem FileSystem { get; } + + internal virtual RazorProjectEngine ProjectEngine + { + get + { + if (_projectEngine == null) + { + _projectEngine = CreateProjectEngine(); + } + + return _projectEngine; + } + } + + internal virtual string WorkingDirectory { get; } + + internal RazorProjectEngine CreateProjectEngine() + { + return RazorProjectEngine.Create(Configuration, FileSystem, b => + { + BlazorExtensionInitializer.Register(b); + }); + } + + 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) + { + // FilePaths in Razor are **always** are of the form '/a/b/c.cshtml' + var filePath = cshtmlRelativePath.Replace('\\', '/'); + if (!filePath.StartsWith('/')) + { + filePath = '/' + filePath; + } + + var projectItem = new VirtualProjectItem( + cshtmlRootPath, + filePath, + Path.Combine(cshtmlRootPath, cshtmlRelativePath), + cshtmlRelativePath, + Encoding.UTF8.GetBytes(cshtmlContent)); + + var codeDocument = ProjectEngine.Process(projectItem); + return new CompileToCSharpResult + { + CodeDocument = codeDocument, + Code = codeDocument.GetCSharpDocument().GeneratedCode, + Diagnostics = codeDocument.GetCSharpDocument().Diagnostics, + }; + } + + 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); + return CompileToAssembly(cSharpResult); + } + + protected CompileToAssemblyResult CompileToAssembly(CompileToCSharpResult cSharpResult) + { + if (cSharpResult.Diagnostics.Any()) + { + var diagnosticsLog = string.Join(Environment.NewLine, cSharpResult.Diagnostics.Select(d => d.ToString()).ToArray()); + throw new InvalidOperationException($"Aborting compilation to assembly because RazorCompiler returned nonempty diagnostics: {diagnosticsLog}"); + } + + var syntaxTrees = new[] + { + CSharpSyntaxTree.ParseText(cSharpResult.Code) + }; + var referenceAssembliesContainingTypes = new[] + { + typeof(System.Runtime.AssemblyTargetedPatchBandAttribute), // System.Runtime + typeof(BlazorComponent), + typeof(RazorIntegrationTestBase), // Reference this assembly, so that we can refer to test component types + }; + var references = referenceAssembliesContainingTypes + .SelectMany(type => type.Assembly.GetReferencedAssemblies().Concat(new[] { type.Assembly.GetName() })) + .Distinct() + .Select(Assembly.Load) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .ToList(); + var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); + var assemblyName = "TestAssembly" + Guid.NewGuid().ToString("N"); + var compilation = CSharpCompilation.Create(assemblyName, + syntaxTrees, + references, + options); + + using (var peStream = new MemoryStream()) + { + compilation.Emit(peStream); + + var diagnostics = compilation + .GetDiagnostics() + .Where(d => d.Severity != DiagnosticSeverity.Hidden); + return new CompileToAssemblyResult + { + Diagnostics = diagnostics, + Assembly = diagnostics.Any() ? null : Assembly.Load(peStream.ToArray()) + }; + } + } + + protected IComponent CompileToComponent(string cshtmlSource) + { + var assemblyResult = CompileToAssembly(WorkingDirectory, DefaultFileName, cshtmlSource); + + var componentFullTypeName = $"{DefaultBaseNamespace}.{Path.GetFileNameWithoutExtension(DefaultFileName)}"; + return CompileToComponent(assemblyResult, componentFullTypeName); + } + + protected IComponent CompileToComponent(CompileToCSharpResult cSharpResult, string fullTypeName) + { + return CompileToComponent(CompileToAssembly(cSharpResult), fullTypeName); + } + + protected IComponent CompileToComponent(CompileToAssemblyResult assemblyResult, string fullTypeName) + { + Assert.Empty(assemblyResult.Diagnostics); + + var componentType = assemblyResult.Assembly.GetType(fullTypeName); + if (componentType == null) + { + throw new XunitException( + $"Failed to find component type '{fullTypeName}'. Found types:" + Environment.NewLine + + string.Join(Environment.NewLine, assemblyResult.Assembly.ExportedTypes.Select(t => t.FullName))); + } + + return (IComponent)Activator.CreateInstance(componentType); + } + + protected static string FullTypeName() => typeof(T).FullName.Replace('+', '.'); + + protected RenderTreeFrame[] GetRenderTree(IComponent component) + { + var renderer = new TestRenderer(); + renderer.AttachComponent(component); + component.SetParameters(ParameterCollection.Empty); + return renderer.LatestBatchReferenceFrames; + } + + protected ArrayRange GetFrames(RenderFragment fragment) + { + var builder = new RenderTreeBuilder(new TestRenderer()); + fragment(builder); + return builder.GetFrames(); + } + + protected class CompileToCSharpResult + { + public RazorCodeDocument CodeDocument { get; set; } + public string Code { get; set; } + public IEnumerable Diagnostics { get; set; } + } + + protected class CompileToAssemblyResult + { + public Assembly Assembly { get; set; } + public string VerboseLog { get; set; } + public IEnumerable Diagnostics { get; set; } + } + + private class TestRenderer : Renderer + { + public TestRenderer() : base(new TestServiceProvider()) + { + } + + public RenderTreeFrame[] LatestBatchReferenceFrames { get; private set; } + + public void AttachComponent(IComponent component) + => AssignComponentId(component); + + protected override void UpdateDisplay(RenderBatch renderBatch) + { + LatestBatchReferenceFrames = renderBatch.ReferenceFrames.ToArray(); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs b/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs new file mode 100644 index 0000000000..7356feee8a --- /dev/null +++ b/test/Microsoft.AspNetCore.Blazor.Build.Test/RenderingRazorIntegrationTest.cs @@ -0,0 +1,445 @@ +// 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 System.Collections.Generic; +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 Xunit; + +namespace Microsoft.AspNetCore.Blazor.Build.Test +{ + // Integration tests for the end-to-end of successful Razor compilation of component definitions + // Includes running the component code to verify the output. + public class RenderingRazorIntegrationTest : RazorIntegrationTestBase + { + [Fact] + public void SupportsPlainText() + { + // Arrange/Act + var component = CompileToComponent("Some plain text"); + var frames = GetRenderTree(component); + + // Assert + Assert.Collection(frames, + frame => AssertFrame.Text(frame, "Some plain text", 0)); + } + + [Fact] + public void SupportsCSharpExpressions() + { + // Arrange/Act + var component = CompileToComponent(@" + @(""Hello"") + @((object)null) + @(123) + @(new object()) + "); + + // 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)); + } + + [Fact] + public void SupportsCSharpFunctionsBlock() + { + // Arrange/Act + var component = CompileToComponent(@" + @foreach(var item in items) { + @item + } + @functions { + string[] items = new[] { ""First"", ""Second"", ""Third"" }; + } + "); + + // 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)); + } + + [Fact] + public void SupportsElements() + { + // Arrange/Act + var component = CompileToComponent("Hello"); + + // Assert + Assert.Collection(GetRenderTree(component), + frame => AssertFrame.Element(frame, "myelem", 2, 0), + frame => AssertFrame.Text(frame, "Hello", 1)); + } + + [Fact] + public void SupportsSelfClosingElements() + { + // Arrange/Act + var component = CompileToComponent("Some text so elem isn't at position 0 "); + + // Assert + Assert.Collection(GetRenderTree(component), + frame => AssertFrame.Text(frame, "Some text so elem isn't at position 0 ", 0), + frame => AssertFrame.Element(frame, "myelem", 1, 1)); + } + + [Fact] + public void SupportsVoidHtmlElements() + { + // Arrange/Act + var component = CompileToComponent("Some text so elem isn't at position 0 "); + + // Assert + Assert.Collection(GetRenderTree(component), + frame => AssertFrame.Text(frame, "Some text so elem isn't at position 0 ", 0), + frame => AssertFrame.Element(frame, "img", 1, 1)); + } + + [Fact] + public void SupportsComments() + { + // Arrange/Act + var component = CompileToComponent("StartEnd"); + var frames = GetRenderTree(component); + + // Assert + Assert.Collection(frames, + frame => AssertFrame.Text(frame, "Start", 0), + frame => AssertFrame.Text(frame, "End", 1)); + } + + [Fact] + public void SupportsAttributesWithLiteralValues() + { + // Arrange/Act + var component = CompileToComponent(""); + + // Assert + Assert.Collection(GetRenderTree(component), + frame => AssertFrame.Element(frame, "elem", 3, 0), + frame => AssertFrame.Attribute(frame, "attrib-one", "Value 1", 1), + frame => AssertFrame.Attribute(frame, "a2", "v2", 2)); + } + + [Fact] + public void SupportsAttributesWithStringExpressionValues() + { + // Arrange/Act + var component = CompileToComponent( + "@{ var myValue = \"My string\"; }" + + ""); + + // Assert + Assert.Collection(GetRenderTree(component), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => AssertFrame.Attribute(frame, "attr", "My string", 1)); + } + + [Fact] + public void SupportsAttributesWithNonStringExpressionValues() + { + // Arrange/Act + var component = CompileToComponent( + "@{ var myValue = 123; }" + + ""); + + // Assert + Assert.Collection(GetRenderTree(component), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => AssertFrame.Attribute(frame, "attr", "123", 1)); + } + + [Fact] + public void SupportsAttributesWithInterpolatedStringExpressionValues() + { + // Arrange/Act + var component = CompileToComponent( + "@{ var myValue = \"world\"; var myNum=123; }" + + ""); + + // Assert + Assert.Collection(GetRenderTree(component), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => AssertFrame.Attribute(frame, "attr", "Hello, WORLD with number 246!", 1)); + } + + [Fact] + public void SupportsAttributesWithEventHandlerValues() + { + // Arrange/Act + var component = CompileToComponent( + @" + @functions { + public bool HandlerWasCalled { get; set; } = false; + + void MyHandleEvent(Microsoft.AspNetCore.Blazor.UIEventArgs eventArgs) + { + HandlerWasCalled = true; + } + }"); + var handlerWasCalledProperty = component.GetType().GetProperty("HandlerWasCalled"); + + // Assert + Assert.False((bool)handlerWasCalledProperty.GetValue(component)); + Assert.Collection(GetRenderTree(component), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => + { + Assert.Equal(RenderTreeFrameType.Attribute, frame.FrameType); + Assert.Equal(1, frame.Sequence); + Assert.NotNull(frame.AttributeValue); + + ((UIEventHandler)frame.AttributeValue)(null); + Assert.True((bool)handlerWasCalledProperty.GetValue(component)); + }, + frame => AssertFrame.Whitespace(frame, 2)); + } + + [Fact] + public void SupportsAttributesWithCSharpCodeBlockValues() + { + // Arrange/Act + var component = CompileToComponent( + @" + @functions { + public bool DidInvokeCode { get; set; } = false; + }"); + var didInvokeCodeProperty = component.GetType().GetProperty("DidInvokeCode"); + var frames = GetRenderTree(component); + + // Assert + Assert.False((bool)didInvokeCodeProperty.GetValue(component)); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => + { + Assert.Equal(RenderTreeFrameType.Attribute, frame.FrameType); + Assert.NotNull(frame.AttributeValue); + Assert.Equal(1, frame.Sequence); + + ((UIEventHandler)frame.AttributeValue)(null); + Assert.True((bool)didInvokeCodeProperty.GetValue(component)); + }, + frame => AssertFrame.Whitespace(frame, 2)); + } + + [Fact] + public void SupportsUsingStatements() + { + // Arrange/Act + var component = CompileToComponent( + @"@using System.Collections.Generic + @(typeof(List).FullName)"); + var frames = GetRenderTree(component); + + // Assert + Assert.Collection(frames, + frame => AssertFrame.Whitespace(frame, 0), + frame => AssertFrame.Text(frame, typeof(List).FullName, 1)); + } + + [Fact] + public void SupportsAttributeFramesEvaluatedInline() + { + // Arrange/Act + var component = CompileToComponent( + @" + @functions { + public bool DidInvokeCode { get; set; } = false; + void MyHandler() + { + DidInvokeCode = true; + } + }"); + var didInvokeCodeProperty = component.GetType().GetProperty("DidInvokeCode"); + + // Assert + Assert.False((bool)didInvokeCodeProperty.GetValue(component)); + Assert.Collection(GetRenderTree(component), + frame => AssertFrame.Element(frame, "elem", 2, 0), + frame => + { + Assert.Equal(RenderTreeFrameType.Attribute, frame.FrameType); + Assert.NotNull(frame.AttributeValue); + Assert.Equal(1, frame.Sequence); + + ((UIEventHandler)frame.AttributeValue)(null); + Assert.True((bool)didInvokeCodeProperty.GetValue(component)); + }, + frame => AssertFrame.Whitespace(frame, 2)); + } + + [Fact] + public void SupportsTwoWayBindingForTextboxes() + { + // Arrange/Act + var component = CompileToComponent( + @" + @functions { + public string MyValue { get; set; } = ""Initial value""; + }"); + var myValueProperty = component.GetType().GetProperty("MyValue"); + + // Assert + var frames = GetRenderTree(component); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "input", 3, 0), + frame => AssertFrame.Attribute(frame, "value", "Initial value", 1), + frame => + { + AssertFrame.Attribute(frame, "onchange", 2); + + // Trigger the change event to show it updates the property + ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs + { + Value = "Modified value" + }); + Assert.Equal("Modified value", myValueProperty.GetValue(component)); + }, + frame => AssertFrame.Text(frame, "\n", 3)); + } + + [Fact] + public void SupportsTwoWayBindingForDateValues() + { + // Arrange/Act + var component = CompileToComponent( + @" + @functions { + public DateTime MyDate { get; set; } = new DateTime(2018, 3, 4, 1, 2, 3); + }"); + var myDateProperty = component.GetType().GetProperty("MyDate"); + + // Assert + var frames = GetRenderTree(component); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "input", 3, 0), + frame => AssertFrame.Attribute(frame, "value", new DateTime(2018, 3, 4, 1, 2, 3).ToString(), 1), + frame => + { + AssertFrame.Attribute(frame, "onchange", 2); + + // Trigger the change event to show it updates the property + var newDateValue = new DateTime(2018, 3, 5, 4, 5, 6); + ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs + { + Value = newDateValue.ToString() + }); + Assert.Equal(newDateValue, myDateProperty.GetValue(component)); + }, + frame => AssertFrame.Text(frame, "\n", 3)); + } + + [Fact] + public void SupportsTwoWayBindingForDateValuesWithFormatString() + { + // Arrange/Act + var component = CompileToComponent( + @" + @functions { + public DateTime MyDate { get; set; } = new DateTime(2018, 3, 4); + }"); + var myDateProperty = component.GetType().GetProperty("MyDate"); + + // Assert + var frames = GetRenderTree(component); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "input", 3, 0), + frame => AssertFrame.Attribute(frame, "value", "Sun 2018-03-04", 1), + frame => + { + AssertFrame.Attribute(frame, "onchange", 2); + + // Trigger the change event to show it updates the property + ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs + { + Value = "Mon 2018-03-05" + }); + Assert.Equal(new DateTime(2018, 3, 5), myDateProperty.GetValue(component)); + }, + frame => AssertFrame.Text(frame, "\n", 3)); + } + + [Fact] + public void SupportsTwoWayBindingForBoolValues() + { + // Arrange/Act + var component = CompileToComponent( + @" + @functions { + public bool MyValue { get; set; } = true; + }"); + var myValueProperty = component.GetType().GetProperty("MyValue"); + + // Assert + var frames = GetRenderTree(component); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "input", 3, 0), + frame => AssertFrame.Attribute(frame, "value", "True", 1), + frame => + { + AssertFrame.Attribute(frame, "onchange", 2); + + // Trigger the change event to show it updates the property + ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs + { + Value = false + }); + Assert.False((bool)myValueProperty.GetValue(component)); + }, + frame => AssertFrame.Text(frame, "\n", 3)); + } + + [Fact] + public void SupportsTwoWayBindingForEnumValues() + { + // Arrange/Act + var myEnumType = FullTypeName(); + var component = CompileToComponent( + $@" + @functions {{ + public {myEnumType} MyValue {{ get; set; }} = {myEnumType}.{nameof(MyEnum.FirstValue)}; + }}"); + var myValueProperty = component.GetType().GetProperty("MyValue"); + + // Assert + var frames = GetRenderTree(component); + Assert.Collection(frames, + frame => AssertFrame.Element(frame, "input", 3, 0), + frame => AssertFrame.Attribute(frame, "value", MyEnum.FirstValue.ToString(), 1), + frame => + { + AssertFrame.Attribute(frame, "onchange", 2); + + // Trigger the change event to show it updates the property + ((UIEventHandler)frame.AttributeValue)(new UIChangeEventArgs + { + Value = MyEnum.SecondValue.ToString() + }); + Assert.Equal(MyEnum.SecondValue, (MyEnum)myValueProperty.GetValue(component)); + }, + frame => AssertFrame.Text(frame, "\n", 3)); + } + + public enum MyEnum { FirstValue, SecondValue } + } +} diff --git a/tooling/Microsoft.VisualStudio.LanguageServices.Blazor/BlazorProjectEngineFactory.cs b/tooling/Microsoft.VisualStudio.LanguageServices.Blazor/BlazorProjectEngineFactory.cs index 2b283624db..e9b51245ae 100644 --- a/tooling/Microsoft.VisualStudio.LanguageServices.Blazor/BlazorProjectEngineFactory.cs +++ b/tooling/Microsoft.VisualStudio.LanguageServices.Blazor/BlazorProjectEngineFactory.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Linq; using Microsoft.AspNetCore.Blazor.Razor; using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis.Razor; @@ -17,6 +18,9 @@ namespace Microsoft.VisualStudio.LanguageServices.Blazor { configure?.Invoke(b); new BlazorExtensionInitializer().Initialize(b); + + var classifier = b.Features.OfType().Single(); + classifier.MangleClassNames = true; }); } }