diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/HtmlNodeOptimizationPass.cs b/src/Microsoft.AspNetCore.Razor.Evolution/HtmlNodeOptimizationPass.cs new file mode 100644 index 0000000000..21562dbfa6 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Evolution/HtmlNodeOptimizationPass.cs @@ -0,0 +1,26 @@ +// 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.Evolution.Legacy; + +namespace Microsoft.AspNetCore.Razor.Evolution +{ + internal class HtmlNodeOptimizationPass : IRazorSyntaxTreePass + { + public RazorEngine Engine { get; set; } + + public int Order => 150; + + public RazorSyntaxTree Execute(RazorCodeDocument codeDocument, RazorSyntaxTree syntaxTree) + { + var conditionalAttributeCollapser = new ConditionalAttributeCollapser(); + var rewritten = conditionalAttributeCollapser.Rewrite(syntaxTree.Root); + + var whitespaceRewriter = new WhiteSpaceRewriter(); + rewritten = whitespaceRewriter.Rewrite(rewritten); + + var rewrittenSyntaxTree = RazorSyntaxTree.Create(rewritten, syntaxTree.Diagnostics); + return rewrittenSyntaxTree; + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/ConditionalAttributeCollapser.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/ConditionalAttributeCollapser.cs new file mode 100644 index 0000000000..4ba54c662c --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/ConditionalAttributeCollapser.cs @@ -0,0 +1,67 @@ +// 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.Diagnostics; +using System.Text; + +namespace Microsoft.AspNetCore.Razor.Evolution.Legacy +{ + internal class ConditionalAttributeCollapser : MarkupRewriter + { + protected override bool CanRewrite(Block block) + { + var generator = block.ChunkGenerator as AttributeBlockChunkGenerator; + if (generator != null && block.Children.Count > 0) + { + // Perf: Avoid allocating an enumerator. + for (var i = 0; i < block.Children.Count; i++) + { + if (!IsLiteralAttributeValue(block.Children[i])) + { + return false; + } + } + + return true; + } + + return false; + } + + protected override SyntaxTreeNode RewriteBlock(BlockBuilder parent, Block block) + { + // Collect the content of this node + var builder = new StringBuilder(); + for (var i = 0; i < block.Children.Count; i++) + { + var childSpan = (Span)block.Children[i]; + builder.Append(childSpan.Content); + } + + // Create a new span containing this content + var span = new SpanBuilder(); + + span.EditHandler = SpanEditHandler.CreateDefault(HtmlLanguageCharacteristics.Instance.TokenizeString); + Debug.Assert(block.Children.Count > 0); + var start = ((Span)block.Children[0]).Start; + FillSpan(span, start, builder.ToString()); + return span.Build(); + } + + private bool IsLiteralAttributeValue(SyntaxTreeNode node) + { + if (node.IsBlock) + { + return false; + } + + var span = node as Span; + Debug.Assert(span != null); + + return span != null && + (span.ChunkGenerator is LiteralAttributeChunkGenerator || + span.ChunkGenerator is MarkupChunkGenerator || + span.ChunkGenerator == SpanChunkGenerator.Null); + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/LiteralAttributeChunkGenerator.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/LiteralAttributeChunkGenerator.cs index 4dc7f66db0..26472c4cca 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/LiteralAttributeChunkGenerator.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/LiteralAttributeChunkGenerator.cs @@ -8,14 +8,6 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy { internal class LiteralAttributeChunkGenerator : SpanChunkGenerator { - public LiteralAttributeChunkGenerator( - LocationTagged prefix, - LocationTagged valueGenerator) - { - Prefix = prefix; - ValueGenerator = valueGenerator; - } - public LiteralAttributeChunkGenerator(LocationTagged prefix, LocationTagged value) { Prefix = prefix; @@ -26,8 +18,6 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy public LocationTagged Value { get; } - public LocationTagged ValueGenerator { get; } - public override void Accept(ParserVisitor visitor, Span span) { visitor.VisitLiteralAttributeSpan(this, span); @@ -53,14 +43,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy public override string ToString() { - if (ValueGenerator == null) - { - return string.Format(CultureInfo.CurrentCulture, "LitAttr:{0:F},{1:F}", Prefix, Value); - } - else - { - return string.Format(CultureInfo.CurrentCulture, "LitAttr:{0:F},", Prefix, ValueGenerator); - } + return string.Format(CultureInfo.CurrentCulture, "LitAttr:{0:F}", Prefix); } public override bool Equals(object obj) @@ -68,8 +51,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy var other = obj as LiteralAttributeChunkGenerator; return other != null && Equals(other.Prefix, Prefix) && - Equals(other.Value, Value) && - Equals(other.ValueGenerator, ValueGenerator); + Equals(other.Value, Value); } public override int GetHashCode() @@ -78,7 +60,6 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy hashCodeCombiner.Add(Prefix); hashCodeCombiner.Add(Value); - hashCodeCombiner.Add(ValueGenerator); return hashCodeCombiner; } diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/MarkupRewriter.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/MarkupRewriter.cs new file mode 100644 index 0000000000..09a54d596b --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/MarkupRewriter.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 System.Collections.Generic; +using System.Diagnostics; + +namespace Microsoft.AspNetCore.Razor.Evolution.Legacy +{ + internal abstract class MarkupRewriter : ParserVisitor + { + private Stack _blocks; + + protected MarkupRewriter() + { + _blocks = new Stack(); + } + + protected BlockBuilder Parent => _blocks.Count > 0 ? _blocks.Peek() : null; + + public Block Rewrite(Block root) + { + root.Accept(this); + Debug.Assert(_blocks.Count == 1); + var rewrittenRoot = _blocks.Pop().Build(); + + return rewrittenRoot; + } + + public override void VisitBlock(Block block) + { + if (CanRewrite(block)) + { + var newNode = RewriteBlock(Parent, block); + if (newNode != null) + { + Parent.Children.Add(newNode); + } + } + else + { + // Not rewritable. + var builder = new BlockBuilder(block); + builder.Children.Clear(); + _blocks.Push(builder); + base.VisitBlock(block); + Debug.Assert(ReferenceEquals(builder, Parent)); + + if (_blocks.Count > 1) + { + _blocks.Pop(); + Parent.Children.Add(builder.Build()); + } + } + } + + protected abstract bool CanRewrite(Block block); + + protected abstract SyntaxTreeNode RewriteBlock(BlockBuilder parent, Block block); + + public override void VisitSpan(Span span) + { + Parent.Children.Add(span); + } + + protected void FillSpan(SpanBuilder builder, SourceLocation start, string content) + { + builder.Kind = SpanKind.Markup; + builder.ChunkGenerator = new MarkupChunkGenerator(); + + foreach (ISymbol sym in HtmlLanguageCharacteristics.Instance.TokenizeString(start, content)) + { + builder.Accept(sym); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperBlockRewriter.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperBlockRewriter.cs index baa2c90834..9e43f7c2cb 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperBlockRewriter.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperBlockRewriter.cs @@ -560,14 +560,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy if (literalGenerator != null) { - if (literalGenerator.ValueGenerator == null || literalGenerator.ValueGenerator.Value == null) - { - newChunkGenerator = new MarkupChunkGenerator(); - } - else - { - newChunkGenerator = literalGenerator.ValueGenerator.Value; - } + newChunkGenerator = new MarkupChunkGenerator(); } else if (isDynamic && childSpan.ChunkGenerator == SpanChunkGenerator.Null) { diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/WhiteSpaceRewriter.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/WhiteSpaceRewriter.cs new file mode 100644 index 0000000000..9e9c7c47f1 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/WhiteSpaceRewriter.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.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.Razor.Evolution.Legacy +{ + internal class WhiteSpaceRewriter : MarkupRewriter + { + protected override bool CanRewrite(Block block) + { + return block.Type == BlockType.Expression && Parent != null; + } + + protected override SyntaxTreeNode RewriteBlock(BlockBuilder parent, Block block) + { + var newBlock = new BlockBuilder(block); + newBlock.Children.Clear(); + var ws = block.Children.FirstOrDefault() as Span; + IEnumerable newNodes = block.Children; + if (ws.Content.All(char.IsWhiteSpace)) + { + // Add this node to the parent + var builder = new SpanBuilder(ws); + builder.ClearSymbols(); + FillSpan(builder, ws.Start, ws.Content); + parent.Children.Add(builder.Build()); + + // Remove the old whitespace node + newNodes = block.Children.Skip(1); + } + + foreach (SyntaxTreeNode node in newNodes) + { + newBlock.Children.Add(node); + } + return newBlock.Build(); + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/RazorEngine.cs b/src/Microsoft.AspNetCore.Razor.Evolution/RazorEngine.cs index c028e901a2..b9b2a8750c 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/RazorEngine.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/RazorEngine.cs @@ -35,6 +35,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution builder.Phases.Add(new DefaultRazorIRLoweringPhase()); builder.Features.Add(new TagHelperBinderSyntaxTreePass()); + builder.Features.Add(new HtmlNodeOptimizationPass()); } public abstract IReadOnlyList Features { get; } diff --git a/src/Microsoft.AspNetCore.Razor/Parser/WhitespaceRewriter.cs b/src/Microsoft.AspNetCore.Razor/Parser/WhitespaceRewriter.cs index f59fbe74cc..b6a613e777 100644 --- a/src/Microsoft.AspNetCore.Razor/Parser/WhitespaceRewriter.cs +++ b/src/Microsoft.AspNetCore.Razor/Parser/WhitespaceRewriter.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Microsoft.AspNetCore.Razor.Parser.Internal; using Microsoft.AspNetCore.Razor.Parser.SyntaxTree; namespace Microsoft.AspNetCore.Razor.Parser @@ -29,21 +28,21 @@ namespace Microsoft.AspNetCore.Razor.Parser { var newBlock = new BlockBuilder(block); newBlock.Children.Clear(); - var ws = block.Children.FirstOrDefault() as Span; + var whitespace = block.Children.FirstOrDefault() as Span; IEnumerable newNodes = block.Children; - if (ws.Content.All(Char.IsWhiteSpace)) + if (whitespace.Content.All(char.IsWhiteSpace)) { // Add this node to the parent - var builder = new SpanBuilder(ws); + var builder = new SpanBuilder(whitespace); builder.ClearSymbols(); - FillSpan(builder, ws.Start, ws.Content); + FillSpan(builder, whitespace.Start, whitespace.Content); parent.Children.Add(builder.Build()); // Remove the old whitespace node newNodes = block.Children.Skip(1); } - foreach (SyntaxTreeNode node in newNodes) + foreach (var node in newNodes) { newBlock.Children.Add(node); } diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/HtmlNodeOptimizationPassTest.cs b/test/Microsoft.AspNetCore.Razor.Evolution.Test/HtmlNodeOptimizationPassTest.cs new file mode 100644 index 0000000000..724c2e9312 --- /dev/null +++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/HtmlNodeOptimizationPassTest.cs @@ -0,0 +1,53 @@ +// 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 Microsoft.AspNetCore.Razor.Evolution.Legacy; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Evolution +{ + public class HtmlNodeOptimizationPassTest + { + [Fact] + public void Execute_CollapsesConditionalAttributes() + { + // Assert + var content = ""; + var sourceDocument = TestRazorSourceDocument.Create(content); + var originalTree = RazorSyntaxTree.Parse(sourceDocument); + var pass = new HtmlNodeOptimizationPass(); + var codeDocument = RazorCodeDocument.Create(sourceDocument); + + // Act + var outputTree = pass.Execute(codeDocument, originalTree); + + // Assert + var tag = Assert.Single(outputTree.Root.Children); + var tagBlock = Assert.IsType(tag); + Assert.Equal(BlockType.Tag, tagBlock.Type); + Assert.Equal(3, tagBlock.Children.Count); + Assert.IsType(tagBlock.Children[1]); + } + + [Fact] + public void Execute_RewritesWhitespace() + { + // Assert + var content = Environment.NewLine + " @true"; + var sourceDocument = TestRazorSourceDocument.Create(content); + var originalTree = RazorSyntaxTree.Parse(sourceDocument); + var pass = new HtmlNodeOptimizationPass(); + var codeDocument = RazorCodeDocument.Create(sourceDocument); + + // Act + var outputTree = pass.Execute(codeDocument, originalTree); + + // Assert + Assert.Equal(4, outputTree.Root.Children.Count); + var whitespace = Assert.IsType(outputTree.Root.Children[1]); + Assert.True(whitespace.Content.All(char.IsWhiteSpace)); + } + } +} diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/HtmlAttributeTest.cs b/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/HtmlAttributeTest.cs new file mode 100644 index 0000000000..ee068d49b3 --- /dev/null +++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/HtmlAttributeTest.cs @@ -0,0 +1,683 @@ +// 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 Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Evolution.Legacy +{ + public class HtmlAttributeTest : CsHtmlMarkupParserTestBase + { + public static TheoryData SymbolBoundAttributeNames + { + get + { + return new TheoryData + { + "[item]", + "[(item,", + "(click)", + "(^click)", + "*something", + "#local", + }; + } + } + + [Theory] + [MemberData(nameof(SymbolBoundAttributeNames))] + public void SymbolBoundAttributes_BeforeEqualWhitespace(string attributeName) + { + // Arrange + var attributeNameLength = attributeName.Length; + var newlineLength = Environment.NewLine.Length; + var prefixLocation1 = new SourceLocation( + absoluteIndex: 2, + lineIndex: 0, + characterIndex: 2); + var suffixLocation1 = new SourceLocation( + absoluteIndex: 8 + newlineLength + attributeNameLength, + lineIndex: 1, + characterIndex: 5 + attributeNameLength); + var valueLocation1 = new SourceLocation( + absoluteIndex: 5 + attributeNameLength + newlineLength, + lineIndex: 1, + characterIndex: 2 + attributeNameLength); + var prefixLocation2 = SourceLocation.Advance(suffixLocation1, "'"); + var suffixLocation2 = new SourceLocation( + absoluteIndex: 15 + attributeNameLength * 2 + newlineLength * 2, + lineIndex: 2, + characterIndex: 4); + var valueLocation2 = new SourceLocation( + absoluteIndex: 12 + attributeNameLength * 2 + newlineLength * 2, + lineIndex: 2, + characterIndex: 1); + + // Act & Assert + ParseBlockTest( + $"", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("( + $" {attributeName}{Environment.NewLine}='", prefixLocation1), + suffix: new LocationTagged("'", suffixLocation1)), + Factory.Markup($" {attributeName}{Environment.NewLine}='").With(SpanChunkGenerator.Null), + Factory.Markup("Foo").With( + new LiteralAttributeChunkGenerator( + prefix: new LocationTagged(string.Empty, valueLocation1), + value: new LocationTagged("Foo", valueLocation1))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + new MarkupBlock( + new AttributeBlockChunkGenerator( + attributeName, + prefix: new LocationTagged( + $"\t{attributeName}={Environment.NewLine}'", prefixLocation2), + suffix: new LocationTagged("'", suffixLocation2)), + Factory.Markup($"\t{attributeName}={Environment.NewLine}'").With(SpanChunkGenerator.Null), + Factory.Markup("Bar").With( + new LiteralAttributeChunkGenerator( + prefix: new LocationTagged(string.Empty, valueLocation2), + value: new LocationTagged("Bar", valueLocation2))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Theory] + [MemberData(nameof(SymbolBoundAttributeNames))] + public void SymbolBoundAttributes_Whitespace(string attributeName) + { + // Arrange + var attributeNameLength = attributeName.Length; + var newlineLength = Environment.NewLine.Length; + var prefixLocation1 = new SourceLocation( + absoluteIndex: 2, + lineIndex: 0, + characterIndex: 2); + var suffixLocation1 = new SourceLocation( + absoluteIndex: 10 + newlineLength + attributeNameLength, + lineIndex: 1, + characterIndex: 5 + attributeNameLength + newlineLength); + var valueLocation1 = new SourceLocation( + absoluteIndex: 7 + attributeNameLength + newlineLength, + lineIndex: 1, + characterIndex: 4 + attributeNameLength); + var prefixLocation2 = SourceLocation.Advance(suffixLocation1, "'"); + var suffixLocation2 = new SourceLocation( + absoluteIndex: 17 + attributeNameLength * 2 + newlineLength * 2, + lineIndex: 2, + characterIndex: 5 + attributeNameLength); + var valueLocation2 = new SourceLocation( + absoluteIndex: 14 + attributeNameLength * 2 + newlineLength * 2, + lineIndex: 2, + characterIndex: 2 + attributeNameLength); + + // Act & Assert + ParseBlockTest( + $"", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("( + $" {Environment.NewLine} {attributeName}='", prefixLocation1), + suffix: new LocationTagged("'", suffixLocation1)), + Factory.Markup($" {Environment.NewLine} {attributeName}='").With(SpanChunkGenerator.Null), + Factory.Markup("Foo").With( + new LiteralAttributeChunkGenerator( + prefix: new LocationTagged(string.Empty, valueLocation1), + value: new LocationTagged("Foo", valueLocation1))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + new MarkupBlock( + new AttributeBlockChunkGenerator( + attributeName, + prefix: new LocationTagged( + $"\t{Environment.NewLine}{attributeName}='", prefixLocation2), + suffix: new LocationTagged("'", suffixLocation2)), + Factory.Markup($"\t{Environment.NewLine}{attributeName}='").With(SpanChunkGenerator.Null), + Factory.Markup("Bar").With( + new LiteralAttributeChunkGenerator( + prefix: new LocationTagged(string.Empty, valueLocation2), + value: new LocationTagged("Bar", valueLocation2))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Theory] + [MemberData(nameof(SymbolBoundAttributeNames))] + public void SymbolBoundAttributes(string attributeName) + { + // Arrange + var attributeNameLength = attributeName.Length; + var suffixLocation = 8 + attributeNameLength; + var valueLocation = 5 + attributeNameLength; + + // Act & Assert + ParseBlockTest($"", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("($" {attributeName}='", 2, 0, 2), + suffix: new LocationTagged("'", suffixLocation, 0, suffixLocation)), + Factory.Markup($" {attributeName}='").With(SpanChunkGenerator.Null), + Factory.Markup("Foo").With( + new LiteralAttributeChunkGenerator( + prefix: new LocationTagged(string.Empty, valueLocation, 0, valueLocation), + value: new LocationTagged("Foo", valueLocation, 0, valueLocation))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void SimpleLiteralAttribute() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" href='", 2, 0, 2), suffix: new LocationTagged("'", 12, 0, 12)), + Factory.Markup(" href='").With(SpanChunkGenerator.Null), + Factory.Markup("Foo").With( + new LiteralAttributeChunkGenerator( + prefix: new LocationTagged(string.Empty, 9, 0, 9), value: new LocationTagged("Foo", 9, 0, 9))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void SimpleLiteralAttributeWithWhitespaceSurroundingEquals() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" href \f\r\n= \t\n'", 2, 0, 2), + suffix: new LocationTagged("'", 19, 2, 4)), + Factory.Markup(" href \f\r\n= \t\n'").With(SpanChunkGenerator.Null), + Factory.Markup("Foo").With( + new LiteralAttributeChunkGenerator( + prefix: new LocationTagged(string.Empty, 16, 2, 1), value: new LocationTagged("Foo", 16, 2, 1))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void DynamicAttributeWithWhitespaceSurroundingEquals() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" href \n= \r\n'", 2, 0, 2), + suffix: new LocationTagged("'", 18, 2, 5)), + Factory.Markup(" href \n= \r\n'").With(SpanChunkGenerator.Null), + new MarkupBlock(new DynamicAttributeBlockChunkGenerator(new LocationTagged(string.Empty, 14, 2, 1), 14, 2, 1), + new ExpressionBlock( + Factory.CodeTransition(), + Factory.Code("Foo") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharacters.NonWhiteSpace))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void MultiPartLiteralAttribute() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" href='", 2, 0, 2), suffix: new LocationTagged("'", 20, 0, 20)), + Factory.Markup(" href='").With(SpanChunkGenerator.Null), + Factory.Markup("Foo").With(new LiteralAttributeChunkGenerator(prefix: new LocationTagged(string.Empty, 9, 0, 9), value: new LocationTagged("Foo", 9, 0, 9))), + Factory.Markup(" Bar").With(new LiteralAttributeChunkGenerator(prefix: new LocationTagged(" ", 12, 0, 12), value: new LocationTagged("Bar", 13, 0, 13))), + Factory.Markup(" Baz").With(new LiteralAttributeChunkGenerator(prefix: new LocationTagged(" ", 16, 0, 16), value: new LocationTagged("Baz", 17, 0, 17))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void DoubleQuotedLiteralAttribute() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" href=\"", 2, 0, 2), suffix: new LocationTagged("\"", 20, 0, 20)), + Factory.Markup(" href=\"").With(SpanChunkGenerator.Null), + Factory.Markup("Foo").With(new LiteralAttributeChunkGenerator(prefix: new LocationTagged(string.Empty, 9, 0, 9), value: new LocationTagged("Foo", 9, 0, 9))), + Factory.Markup(" Bar").With(new LiteralAttributeChunkGenerator(prefix: new LocationTagged(" ", 12, 0, 12), value: new LocationTagged("Bar", 13, 0, 13))), + Factory.Markup(" Baz").With(new LiteralAttributeChunkGenerator(prefix: new LocationTagged(" ", 16, 0, 16), value: new LocationTagged("Baz", 17, 0, 17))), + Factory.Markup("\"").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void NewLinePrecedingAttribute() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("("\r\nhref='", 2, 0, 2), + suffix: new LocationTagged("'", 13, 1, 9)), + Factory.Markup("\r\nhref='").With(SpanChunkGenerator.Null), + Factory.Markup("Foo").With( + new LiteralAttributeChunkGenerator( + prefix: new LocationTagged(string.Empty, 10, 1, 6), + value: new LocationTagged("Foo", 10, 1, 6))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void NewLineBetweenAttributes() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("("\nhref='", 2, 0, 2), + suffix: new LocationTagged("'", 12, 1, 9)), + Factory.Markup("\nhref='").With(SpanChunkGenerator.Null), + Factory.Markup("Foo").With( + new LiteralAttributeChunkGenerator( + prefix: new LocationTagged(string.Empty, 9, 1, 6), + value: new LocationTagged("Foo", 9, 1, 6))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + new MarkupBlock( + new AttributeBlockChunkGenerator( + name: "abcd", + prefix: new LocationTagged("\r\nabcd='", 13, 1, 10), + suffix: new LocationTagged("'", 24, 2, 9)), + Factory.Markup("\r\nabcd='").With(SpanChunkGenerator.Null), + Factory.Markup("Bar").With( + new LiteralAttributeChunkGenerator( + prefix: new LocationTagged(string.Empty, 21, 2, 6), + value: new LocationTagged("Bar", 21, 2, 6))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void WhitespaceAndNewLinePrecedingAttribute() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" \t\r\nhref='", 2, 0, 2), + suffix: new LocationTagged("'", 15, 1, 9)), + Factory.Markup(" \t\r\nhref='").With(SpanChunkGenerator.Null), + Factory.Markup("Foo").With( + new LiteralAttributeChunkGenerator( + prefix: new LocationTagged(string.Empty, 12, 1, 6), + value: new LocationTagged("Foo", 12, 1, 6))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void UnquotedLiteralAttribute() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" href=", 2, 0, 2), suffix: new LocationTagged(string.Empty, 11, 0, 11)), + Factory.Markup(" href=").With(SpanChunkGenerator.Null), + Factory.Markup("Foo").With(new LiteralAttributeChunkGenerator(prefix: new LocationTagged(string.Empty, 8, 0, 8), value: new LocationTagged("Foo", 8, 0, 8)))), + new MarkupBlock(Factory.Markup(" Bar")), + new MarkupBlock(Factory.Markup(" Baz")), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void SimpleExpressionAttribute() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" href='", 2, 0, 2), suffix: new LocationTagged("'", 13, 0, 13)), + Factory.Markup(" href='").With(SpanChunkGenerator.Null), + new MarkupBlock(new DynamicAttributeBlockChunkGenerator(new LocationTagged(string.Empty, 9, 0, 9), 9, 0, 9), + new ExpressionBlock( + Factory.CodeTransition(), + Factory.Code("foo") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharacters.NonWhiteSpace))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void MultiValueExpressionAttribute() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" href='", 2, 0, 2), suffix: new LocationTagged("'", 22, 0, 22)), + Factory.Markup(" href='").With(SpanChunkGenerator.Null), + new MarkupBlock(new DynamicAttributeBlockChunkGenerator(new LocationTagged(string.Empty, 9, 0, 9), 9, 0, 9), + new ExpressionBlock( + Factory.CodeTransition(), + Factory.Code("foo") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharacters.NonWhiteSpace))), + Factory.Markup(" bar").With(new LiteralAttributeChunkGenerator(new LocationTagged(" ", 13, 0, 13), new LocationTagged("bar", 14, 0, 14))), + new MarkupBlock(new DynamicAttributeBlockChunkGenerator(new LocationTagged(" ", 17, 0, 17), 18, 0, 18), + Factory.Markup(" ").With(SpanChunkGenerator.Null), + new ExpressionBlock( + Factory.CodeTransition(), + Factory.Code("baz") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharacters.NonWhiteSpace))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void VirtualPathAttributesWorkWithConditionalAttributes() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" href='", 2, 0, 2), suffix: new LocationTagged("'", 23, 0, 23)), + Factory.Markup(" href='").With(SpanChunkGenerator.Null), + new MarkupBlock(new DynamicAttributeBlockChunkGenerator(new LocationTagged(string.Empty, 9, 0, 9), 9, 0, 9), + new ExpressionBlock( + Factory.CodeTransition(), + Factory.Code("foo") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharacters.NonWhiteSpace))), + Factory.Markup(" ~/Foo/Bar") + .With(new LiteralAttributeChunkGenerator( + new LocationTagged(" ", 13, 0, 13), + new LocationTagged("~/Foo/Bar", 14, 0, 14))), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void UnquotedAttributeWithCodeWithSpacesInBlock() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" value=", 6, 0, 6), suffix: new LocationTagged(string.Empty, 17, 0, 17)), + Factory.Markup(" value=").With(SpanChunkGenerator.Null), + new MarkupBlock(new DynamicAttributeBlockChunkGenerator(new LocationTagged(string.Empty, 13, 0, 13), 13, 0, 13), + new ExpressionBlock( + Factory.CodeTransition(), + Factory.Code("foo") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharacters.NonWhiteSpace)))), + Factory.Markup(" />").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void UnquotedAttributeWithCodeWithSpacesInDocument() + { + ParseDocumentTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" value=", 6, 0, 6), suffix: new LocationTagged(string.Empty, 17, 0, 17)), + Factory.Markup(" value=").With(SpanChunkGenerator.Null), + new MarkupBlock(new DynamicAttributeBlockChunkGenerator(new LocationTagged(string.Empty, 13, 0, 13), 13, 0, 13), + new ExpressionBlock( + Factory.CodeTransition(), + Factory.Code("foo") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharacters.NonWhiteSpace)))), + Factory.Markup(" />")))); + } + + [Fact] + public void ConditionalAttributeCollapserDoesNotRewriteEscapedTransitions() + { + // Act + var results = ParseDocument(""); + var attributeCollapser = new ConditionalAttributeCollapser(); + var rewritten = attributeCollapser.Rewrite(results.Root); + + // Assert + EvaluateParseTree(rewritten, + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("(" foo='", 5, 0, 5), new LocationTagged("'", 13, 0, 13)), + Factory.Markup(" foo='").With(SpanChunkGenerator.Null), + new MarkupBlock( + Factory.Markup("@").With(new LiteralAttributeChunkGenerator(new LocationTagged(string.Empty, 11, 0, 11), new LocationTagged("@", 11, 0, 11))).Accepts(AcceptedCharacters.None), + Factory.Markup("@").With(SpanChunkGenerator.Null).Accepts(AcceptedCharacters.None)), + Factory.Markup("'").With(SpanChunkGenerator.Null)), + Factory.Markup(" />")))); + } + + [Fact] + public void ConditionalAttributesDoNotCreateExtraDataForEntirelyLiteralAttribute() + { + // Arrange + const string code = + @"
+

Title

+

+ As the author, you can edit + or remove this photo. +

+
+
Description
+
+ The uploader did not provide a description for this photo. +
+
Uploaded by
+
user.DisplayName
+
Upload date
+
photo.UploadDate
+
Gallery
+
gallery.Name
+
Tags
+
+
    +
  • This photo has no tags.
  • +
+ edit tags +
+
+ +

+ Download full photo ((photo.FileSize / 1024) KB) +

+
+
+ +

Nobody has commented on this photo

+
    +
  1. +

    + comment.DisplayName commented at comment.CommentDate: +

    +

    comment.CommentText

    +
  2. +
+ +
+
+ Post new comment +
    +
  1. + + +
  2. +
+

+ +

+
+
+
"; + + // Act + var results = ParseDocument(code); + var attributeCollapser = new ConditionalAttributeCollapser(); + var rewritten = attributeCollapser.Rewrite(results.Root); + + // Assert + Assert.Equal(rewritten.Children.Count(), results.Root.Children.Count()); + } + + [Fact] + public void ConditionalAttributesAreDisabledForDataAttributesInBlock() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("").Accepts(AcceptedCharacters.None)), + new MarkupTagBlock( + Factory.Markup("
").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void ConditionalAttributesWithWeirdSpacingAreDisabledForDataAttributesInBlock() + { + ParseBlockTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("").Accepts(AcceptedCharacters.None)), + new MarkupTagBlock( + Factory.Markup("
").Accepts(AcceptedCharacters.None)))); + } + + [Fact] + public void ConditionalAttributesAreDisabledForDataAttributesInDocument() + { + ParseDocumentTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("")), + new MarkupTagBlock( + Factory.Markup("")))); + } + + [Fact] + public void ConditionalAttributesWithWeirdSpacingAreDisabledForDataAttributesInDocument() + { + ParseDocumentTest("", + new MarkupBlock( + new MarkupTagBlock( + Factory.Markup("")), + new MarkupTagBlock( + Factory.Markup("")))); + } + + private class EmptyTestDocument : ITextDocument + { + public int Length + { + get + { + throw new NotImplementedException(); + } + } + + public SourceLocation Location + { + get + { + throw new NotImplementedException(); + } + } + + public int Position + { + get + { + throw new NotImplementedException(); + } + + set + { + throw new NotImplementedException(); + } + } + + public int Peek() + { + throw new NotImplementedException(); + } + + public int Read() + { + throw new NotImplementedException(); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/WhiteSpaceRewriterTest.cs b/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/WhiteSpaceRewriterTest.cs new file mode 100644 index 0000000000..7d55e7ce57 --- /dev/null +++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/WhiteSpaceRewriterTest.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 Xunit; + +namespace Microsoft.AspNetCore.Razor.Evolution.Legacy +{ + public class WhiteSpaceRewriterTest + { + [Fact] + public void Rewrite_Moves_Whitespace_Preceeding_ExpressionBlock_To_Parent_Block() + { + // Arrange + var factory = new SpanFactory(); + var start = new MarkupBlock( + factory.Markup("test"), + new ExpressionBlock( + factory.Code(" ").AsExpression(), + factory.CodeTransition(SyntaxConstants.TransitionString), + factory.Code("foo").AsExpression()), + factory.Markup("test")); + var rewriter = new WhiteSpaceRewriter(); + + // Act + var rewritten = rewriter.Rewrite(start); + factory.Reset(); + + // Assert + ParserTestBase.EvaluateParseTree( + rewritten, + new MarkupBlock( + factory.Markup("test"), + factory.Markup(" "), + new ExpressionBlock( + factory.CodeTransition(SyntaxConstants.TransitionString), + factory.Code("foo").AsExpression()), + factory.Markup("test"))); + } + } +} diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/RazorEngineTest.cs b/test/Microsoft.AspNetCore.Razor.Evolution.Test/RazorEngineTest.cs index 7c6642b967..7d2499bf34 100644 --- a/test/Microsoft.AspNetCore.Razor.Evolution.Test/RazorEngineTest.cs +++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/RazorEngineTest.cs @@ -76,7 +76,8 @@ namespace Microsoft.AspNetCore.Razor.Evolution { Assert.Collection( features, - feature => Assert.IsType(feature)); + feature => Assert.IsType(feature), + feature => Assert.IsType(feature)); } private static void AssertDefaultPhases(IReadOnlyList phases) @@ -88,4 +89,4 @@ namespace Microsoft.AspNetCore.Razor.Evolution phase => Assert.IsType(phase)); } } -} +} \ No newline at end of file