From 90b48347a5dfc23defea3759d324d4671c454c0e Mon Sep 17 00:00:00 2001
From: Ryan Nowak
Date: Wed, 15 Mar 2017 15:05:41 -0700
Subject: [PATCH] Port the legacy RazorEditorParser
---
.../Legacy/BackgroundParser.cs | 424 +++++
.../Legacy/Block.cs | 32 +
.../Legacy/DocumentParseCompleteEventArgs.cs | 28 +
.../Legacy/ITextBuffer.cs | 2 +-
.../Legacy/ImplicitExpressionEditHandler.cs | 60 +-
.../Legacy/LegacySourceDocument.cs | 79 +
.../Legacy/PartialParseResult.cs | 2 +-
.../Legacy/RazorEditorParser.cs | 260 +++
.../Legacy/Span.cs | 6 +
.../Legacy/SpanEditHandler.cs | 2 +-
.../Legacy/TagHelperBlock.cs | 39 +
.../Legacy/TextChange.cs | 11 +-
...icrosoft.AspNetCore.Razor.Evolution.csproj | 4 +
.../Legacy/RazorEditorParserTest.cs | 1462 +++++++++++++++++
.../TestFiles/DesignTime/Simple.cshtml | 16 +
.../TestFiles/DesignTime/Simple.txt | 49 +
.../TestRazorProject.cs | 5 +
17 files changed, 2467 insertions(+), 14 deletions(-)
create mode 100644 src/Microsoft.AspNetCore.Razor.Evolution/Legacy/BackgroundParser.cs
create mode 100644 src/Microsoft.AspNetCore.Razor.Evolution/Legacy/DocumentParseCompleteEventArgs.cs
create mode 100644 src/Microsoft.AspNetCore.Razor.Evolution/Legacy/LegacySourceDocument.cs
create mode 100644 src/Microsoft.AspNetCore.Razor.Evolution/Legacy/RazorEditorParser.cs
create mode 100644 test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/RazorEditorParserTest.cs
create mode 100644 test/Microsoft.AspNetCore.Razor.Evolution.Test/TestFiles/DesignTime/Simple.cshtml
create mode 100644 test/Microsoft.AspNetCore.Razor.Evolution.Test/TestFiles/DesignTime/Simple.txt
diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/BackgroundParser.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/BackgroundParser.cs
new file mode 100644
index 0000000000..41921ca2f7
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/BackgroundParser.cs
@@ -0,0 +1,424 @@
+// 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.IO;
+using System.Linq;
+using System.Threading;
+
+namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
+{
+ internal class BackgroundParser : IDisposable
+ {
+ private MainThreadState _main;
+ private BackgroundThread _bg;
+
+ public BackgroundParser(RazorTemplateEngine templateEngine, string fileName)
+ {
+ _main = new MainThreadState(fileName);
+ _bg = new BackgroundThread(_main, templateEngine, fileName);
+
+ _main.ResultsReady += (sender, args) => OnResultsReady(args);
+ }
+
+ ///
+ /// Fired on the main thread.
+ ///
+ public event EventHandler ResultsReady;
+
+ public bool IsIdle
+ {
+ get { return _main.IsIdle; }
+ }
+
+ public void Start()
+ {
+ _bg.Start();
+ }
+
+ public void Cancel()
+ {
+ _main.Cancel();
+ }
+
+ public void QueueChange(TextChange change)
+ {
+ _main.QueueChange(change);
+ }
+
+ public void Dispose()
+ {
+ _main.Cancel();
+ }
+
+ public IDisposable SynchronizeMainThreadState()
+ {
+ return _main.Lock();
+ }
+
+ protected virtual void OnResultsReady(DocumentParseCompleteEventArgs args)
+ {
+ var handler = ResultsReady;
+ if (handler != null)
+ {
+ handler(this, args);
+ }
+ }
+
+ internal static bool TreesAreDifferent(RazorSyntaxTree leftTree, RazorSyntaxTree rightTree, IEnumerable changes)
+ {
+ return TreesAreDifferent(leftTree, rightTree, changes, CancellationToken.None);
+ }
+
+ internal static bool TreesAreDifferent(RazorSyntaxTree leftTree, RazorSyntaxTree rightTree, IEnumerable changes, CancellationToken cancelToken)
+ {
+ return TreesAreDifferent(leftTree.Root, rightTree.Root, changes, cancelToken);
+ }
+
+ internal static bool TreesAreDifferent(Block leftTree, Block rightTree, IEnumerable changes)
+ {
+ return TreesAreDifferent(leftTree, rightTree, changes, CancellationToken.None);
+ }
+
+ internal static bool TreesAreDifferent(Block leftTree, Block rightTree, IEnumerable changes, CancellationToken cancelToken)
+ {
+ // Apply all the pending changes to the original tree
+ // PERF: If this becomes a bottleneck, we can probably do it the other way around,
+ // i.e. visit the tree and find applicable changes for each node.
+ foreach (TextChange change in changes)
+ {
+ cancelToken.ThrowIfCancellationRequested();
+ var changeOwner = leftTree.LocateOwner(change);
+
+ // Apply the change to the tree
+ if (changeOwner == null)
+ {
+ return true;
+ }
+ var result = changeOwner.EditHandler.ApplyChange(changeOwner, change, force: true);
+ changeOwner.ReplaceWith(result.EditedSpan);
+ }
+
+ // Now compare the trees
+ var treesDifferent = !leftTree.EquivalentTo(rightTree);
+ return treesDifferent;
+ }
+
+ private abstract class ThreadStateBase
+ {
+#if DEBUG
+ private int _id = -1;
+#endif
+ protected ThreadStateBase()
+ {
+ }
+
+ [Conditional("DEBUG")]
+ protected void SetThreadId(int id)
+ {
+#if DEBUG
+ _id = id;
+#endif
+ }
+
+ [Conditional("DEBUG")]
+ protected void EnsureOnThread()
+ {
+#if DEBUG
+ Debug.Assert(_id != -1, "SetThreadId was never called!");
+ Debug.Assert(Thread.CurrentThread.ManagedThreadId == _id, "Called from an unexpected thread!");
+#endif
+ }
+
+ [Conditional("DEBUG")]
+ protected void EnsureNotOnThread()
+ {
+#if DEBUG
+ Debug.Assert(_id != -1, "SetThreadId was never called!");
+ Debug.Assert(Thread.CurrentThread.ManagedThreadId != _id, "Called from an unexpected thread!");
+#endif
+ }
+ }
+
+ private class MainThreadState : ThreadStateBase, IDisposable
+ {
+ private readonly CancellationTokenSource _cancelSource = new CancellationTokenSource();
+ private readonly ManualResetEventSlim _hasParcel = new ManualResetEventSlim(false);
+ private CancellationTokenSource _currentParcelCancelSource;
+
+ private string _fileName;
+ private readonly object _stateLock = new object();
+ private IList _changes = new List();
+
+ public MainThreadState(string fileName)
+ {
+ _fileName = fileName;
+
+ SetThreadId(Thread.CurrentThread.ManagedThreadId);
+ }
+
+ public event EventHandler ResultsReady;
+
+ public CancellationToken CancelToken
+ {
+ get { return _cancelSource.Token; }
+ }
+
+ public bool IsIdle
+ {
+ get
+ {
+ lock (_stateLock)
+ {
+ return _currentParcelCancelSource == null;
+ }
+ }
+ }
+
+ public void Cancel()
+ {
+ EnsureOnThread();
+ _cancelSource.Cancel();
+ }
+
+ public IDisposable Lock()
+ {
+ Monitor.Enter(_stateLock);
+ return new DisposableAction(() => Monitor.Exit(_stateLock));
+ }
+
+ public void QueueChange(TextChange change)
+ {
+ EnsureOnThread();
+ lock (_stateLock)
+ {
+ // CurrentParcel token source is not null ==> There's a parse underway
+ if (_currentParcelCancelSource != null)
+ {
+ _currentParcelCancelSource.Cancel();
+ }
+
+ _changes.Add(change);
+ _hasParcel.Set();
+ }
+ }
+
+ public WorkParcel GetParcel()
+ {
+ EnsureNotOnThread(); // Only the background thread can get a parcel
+ _hasParcel.Wait(_cancelSource.Token);
+ _hasParcel.Reset();
+ lock (_stateLock)
+ {
+ // Create a cancellation source for this parcel
+ _currentParcelCancelSource = new CancellationTokenSource();
+
+ var changes = _changes;
+ _changes = new List();
+ return new WorkParcel(changes, _currentParcelCancelSource.Token);
+ }
+ }
+
+ public void ReturnParcel(DocumentParseCompleteEventArgs args)
+ {
+ lock (_stateLock)
+ {
+ // Clear the current parcel cancellation source
+ if (_currentParcelCancelSource != null)
+ {
+ _currentParcelCancelSource.Dispose();
+ _currentParcelCancelSource = null;
+ }
+
+ // If there are things waiting to be parsed, just don't fire the event because we're already out of date
+ if (_changes.Any())
+ {
+ return;
+ }
+ }
+ var handler = ResultsReady;
+ if (handler != null)
+ {
+ handler(this, args);
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ if (_currentParcelCancelSource != null)
+ {
+ _currentParcelCancelSource.Dispose();
+ _currentParcelCancelSource = null;
+ }
+ _cancelSource.Dispose();
+ _hasParcel.Dispose();
+ }
+ }
+ }
+
+ private class BackgroundThread : ThreadStateBase
+ {
+ private MainThreadState _main;
+ private Thread _backgroundThread;
+ private CancellationToken _shutdownToken;
+ private RazorTemplateEngine _templateEngine;
+ private string _fileName;
+ private RazorSyntaxTree _currentSyntaxTree;
+ private IList _previouslyDiscarded = new List();
+
+ public BackgroundThread(MainThreadState main, RazorTemplateEngine templateEngine, string fileName)
+ {
+ // Run on MAIN thread!
+ _main = main;
+ _shutdownToken = _main.CancelToken;
+ _templateEngine = templateEngine;
+ _fileName = fileName;
+
+ _backgroundThread = new Thread(WorkerLoop);
+ SetThreadId(_backgroundThread.ManagedThreadId);
+ }
+
+ // **** ANY THREAD ****
+ public void Start()
+ {
+ _backgroundThread.Start();
+ }
+
+ // **** BACKGROUND THREAD ****
+ private void WorkerLoop()
+ {
+ var fileNameOnly = Path.GetFileName(_fileName);
+
+ try
+ {
+ EnsureOnThread();
+
+#if NETSTANDARD1_3
+ var spinWait = new SpinWait();
+#endif
+
+ while (!_shutdownToken.IsCancellationRequested)
+ {
+ // Grab the parcel of work to do
+ var parcel = _main.GetParcel();
+ if (parcel.Changes.Any())
+ {
+ try
+ {
+ DocumentParseCompleteEventArgs args = null;
+ using (var linkedCancel = CancellationTokenSource.CreateLinkedTokenSource(_shutdownToken, parcel.CancelToken))
+ {
+ if (!linkedCancel.IsCancellationRequested)
+ {
+ // Collect ALL changes
+ List allChanges;
+
+ if (_previouslyDiscarded != null)
+ {
+ allChanges = Enumerable.Concat(_previouslyDiscarded, parcel.Changes).ToList();
+ }
+ else
+ {
+ allChanges = parcel.Changes.ToList();
+ }
+
+ var finalChange = allChanges.Last();
+
+ var results = ParseChange(finalChange.NewBuffer, linkedCancel.Token);
+
+ if (results != null && !linkedCancel.IsCancellationRequested)
+ {
+ // Clear discarded changes list
+ _previouslyDiscarded = null;
+
+ var treeStructureChanged = _currentSyntaxTree == null || TreesAreDifferent(_currentSyntaxTree, results.GetSyntaxTree(), allChanges, parcel.CancelToken);
+ _currentSyntaxTree = results.GetSyntaxTree();
+
+ // Build Arguments
+ args = new DocumentParseCompleteEventArgs()
+ {
+ GeneratorResults = results,
+ SourceChange = finalChange,
+ TreeStructureChanged = treeStructureChanged
+ };
+ }
+ else
+ {
+ // Parse completed but we were cancelled in the mean time. Add these to the discarded changes set
+ _previouslyDiscarded = allChanges;
+ }
+ }
+ }
+ if (args != null)
+ {
+ _main.ReturnParcel(args);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
+ else
+ {
+#if NETSTANDARD1_3
+ // This does the equivalent of thread.yield under the covers.
+ spinWait.SpinOnce();
+#else
+ // No Yield in CoreCLR
+
+ Thread.Yield();
+#endif
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Do nothing. Just shut down.
+ }
+ finally
+ {
+ // Clean up main thread resources
+ _main.Dispose();
+ }
+ }
+
+ private RazorCodeDocument ParseChange(ITextBuffer buffer, CancellationToken token)
+ {
+ EnsureOnThread();
+
+ // Seek the buffer to the beginning
+ buffer.Position = 0;
+
+ var sourceDocument = LegacySourceDocument.Create(buffer, _fileName);
+ var imports = _templateEngine.GetImports(_fileName);
+
+ var codeDocument = RazorCodeDocument.Create(sourceDocument, imports);
+
+ _templateEngine.GenerateCode(codeDocument);
+ return codeDocument;
+ }
+ }
+
+ private class WorkParcel
+ {
+ public WorkParcel(IList changes, CancellationToken cancelToken)
+ {
+ Changes = changes;
+ CancelToken = cancelToken;
+ }
+
+ public CancellationToken CancelToken { get; private set; }
+ public IList Changes { get; private set; }
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/Block.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/Block.cs
index c6a7e6f1b9..57505f39bf 100644
--- a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/Block.cs
+++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/Block.cs
@@ -102,6 +102,38 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
return current as Span;
}
+ public virtual Span LocateOwner(TextChange change) => LocateOwner(change, Children);
+
+ protected static Span LocateOwner(TextChange change, IEnumerable elements)
+ {
+ // Ask each child recursively
+ Span owner = null;
+ foreach (var element in elements)
+ {
+ var span = element as Span;
+ if (span == null)
+ {
+ owner = ((Block)element).LocateOwner(change);
+ }
+ else
+ {
+ if (change.OldPosition < span.Start.AbsoluteIndex)
+ {
+ // Early escape for cases where changes overlap multiple spans
+ // In those cases, the span will return false, and we don't want to search the whole tree
+ // So if the current span starts after the change, we know we've searched as far as we need to
+ break;
+ }
+ owner = span.EditHandler.OwnsChange(span, change) ? span : owner;
+ }
+
+ if (owner != null)
+ {
+ break;
+ }
+ }
+ return owner;
+ }
public override string ToString()
{
return string.Format(
diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/DocumentParseCompleteEventArgs.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/DocumentParseCompleteEventArgs.cs
new file mode 100644
index 0000000000..9f6fc7da62
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/DocumentParseCompleteEventArgs.cs
@@ -0,0 +1,28 @@
+// 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;
+
+namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
+{
+ ///
+ /// Arguments for the DocumentParseComplete event in RazorEditorParser
+ ///
+ public class DocumentParseCompleteEventArgs : EventArgs
+ {
+ ///
+ /// Indicates if the tree structure has actually changed since the previous re-parse.
+ ///
+ public bool TreeStructureChanged { get; set; }
+
+ ///
+ /// The result of the parsing and code generation.
+ ///
+ public RazorCodeDocument GeneratorResults { get; set; }
+
+ ///
+ /// The TextChange which triggered the re-parse
+ ///
+ public TextChange SourceChange { get; set; }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/ITextBuffer.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/ITextBuffer.cs
index a9a92d0c6c..ce47d88a28 100644
--- a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/ITextBuffer.cs
+++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/ITextBuffer.cs
@@ -3,7 +3,7 @@
namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
{
- internal interface ITextBuffer
+ public interface ITextBuffer
{
int Length { get; }
int Position { get; set; }
diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/ImplicitExpressionEditHandler.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/ImplicitExpressionEditHandler.cs
index 0a053c9229..1fbdbd5061 100644
--- a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/ImplicitExpressionEditHandler.cs
+++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/ImplicitExpressionEditHandler.cs
@@ -80,6 +80,11 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
return HandleDotlessCommitInsertion(target);
}
+ if (IsAcceptableIdentifierReplacement(target, normalizedChange))
+ {
+ return TryAcceptChange(target, normalizedChange);
+ }
+
if (IsAcceptableReplace(target, normalizedChange))
{
return HandleReplacement(target, normalizedChange);
@@ -150,6 +155,59 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
(change.IsReplace && RemainingIsWhitespace(target, change));
}
+ private bool IsAcceptableIdentifierReplacement(Span target, TextChange change)
+ {
+ if (!change.IsReplace)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < target.Symbols.Count; i++)
+ {
+ var symbol = target.Symbols[i] as CSharpSymbol;
+
+ if (symbol == null)
+ {
+ break;
+ }
+
+ var symbolStartIndex = symbol.Start.AbsoluteIndex;
+ var symbolEndIndex = symbolStartIndex + symbol.Content.Length;
+
+ // We're looking for the first symbol that contains the TextChange.
+ if (symbolEndIndex > change.OldPosition)
+ {
+ if (symbolEndIndex >= change.OldPosition + change.OldLength && symbol.Type == CSharpSymbolType.Identifier)
+ {
+ // The symbol we're changing happens to be an identifier. Need to check if its transformed state is also one.
+ // We do this transformation logic to capture the case that the new text change happens to not be an identifier;
+ // i.e. "5". Alone, it's numeric, within an identifier it's classified as identifier.
+ var transformedContent = change.ApplyChange(symbol.Content, symbolStartIndex);
+ var newSymbols = Tokenizer(transformedContent);
+
+ if (newSymbols.Count() != 1)
+ {
+ // The transformed content resulted in more than one symbol; we can only replace a single identifier with
+ // another single identifier.
+ break;
+ }
+
+ var newSymbol = (CSharpSymbol)newSymbols.First();
+ if (newSymbol.Type == CSharpSymbolType.Identifier)
+ {
+ return true;
+ }
+ }
+
+ // Change is touching a non-identifier symbol or spans multiple symbols.
+
+ break;
+ }
+ }
+
+ return false;
+ }
+
private static bool IsAcceptableDeletion(Span target, TextChange change)
{
return IsEndDeletion(target, change) ||
@@ -300,7 +358,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
private PartialParseResult TryAcceptChange(Span target, TextChange change, PartialParseResult acceptResult = PartialParseResult.Accepted)
{
- var content = change.ApplyChange(target);
+ var content = change.ApplyChange(target.Content, target.Start.AbsoluteIndex);
if (StartsWithKeyword(content))
{
return PartialParseResult.Rejected | PartialParseResult.SpanContextChanged;
diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/LegacySourceDocument.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/LegacySourceDocument.cs
new file mode 100644
index 0000000000..f2b640d575
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/LegacySourceDocument.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Text;
+// 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;
+
+namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
+{
+ internal class LegacySourceDocument : RazorSourceDocument
+ {
+ private readonly ITextBuffer _buffer;
+ private readonly string _filename;
+ private readonly RazorSourceLineCollection _lines;
+
+ public static RazorSourceDocument Create(ITextBuffer buffer, string filename)
+ {
+ return new LegacySourceDocument(buffer, filename);
+ }
+
+ private LegacySourceDocument(ITextBuffer buffer, string filename)
+ {
+ _buffer = buffer;
+ _filename = filename;
+
+ _lines = new DefaultRazorSourceLineCollection(this);
+ }
+
+ public override char this[int position]
+ {
+ get
+ {
+ _buffer.Position = position;
+ return (char)_buffer.Read();
+ }
+ }
+
+ public override Encoding Encoding => Encoding.UTF8;
+
+ public override string FileName => _filename;
+
+ public override int Length => _buffer.Length;
+
+ public override RazorSourceLineCollection Lines => _lines;
+
+ public override void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count)
+ {
+ if (destination == null)
+ {
+ throw new ArgumentNullException(nameof(destination));
+ }
+
+ if (sourceIndex < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(sourceIndex));
+ }
+
+ if (destinationIndex < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(destinationIndex));
+ }
+
+ if (count < 0 || count > Length - sourceIndex || count > destination.Length - destinationIndex)
+ {
+ throw new ArgumentOutOfRangeException(nameof(count));
+ }
+
+ if (count == 0)
+ {
+ return;
+ }
+
+ for (var i = 0; i < count; i++)
+ {
+ destination[destinationIndex + i] = this[sourceIndex + i];
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/PartialParseResult.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/PartialParseResult.cs
index 2facf21b84..b76ff1aba2 100644
--- a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/PartialParseResult.cs
+++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/PartialParseResult.cs
@@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
/// Provisional may NOT be set with Rejected and SpanContextChanged may NOT be set with Accepted.
///
[Flags]
- internal enum PartialParseResult
+ public enum PartialParseResult
{
///
/// Indicates that the edit could not be accepted and that a reparse is underway.
diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/RazorEditorParser.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/RazorEditorParser.cs
new file mode 100644
index 0000000000..2c7fb5fddc
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/RazorEditorParser.cs
@@ -0,0 +1,260 @@
+// 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.Diagnostics;
+using System.IO;
+
+namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
+{
+ ///
+ /// Parser used by editors to avoid reparsing the entire document on each text change.
+ ///
+ ///
+ ///
+ /// This parser is designed to allow editors to avoid having to worry about incremental parsing.
+ /// The method can be called with every change made by a user in an editor
+ /// and the parser will provide a result indicating if it was able to incrementally apply the change.
+ ///
+ ///
+ /// The general workflow for editors with this parser is:
+ ///
+ /// - User edits document.
+ /// - Editor builds a structure describing the edit and providing a
+ /// reference to the updated text buffer.
+ /// - Editor calls passing in that change.
+ ///
+ /// - Parser determines if the change can be simply applied to an existing parse tree node.
+ ///
+ ///
+ /// - If it can, the Parser updates its parse tree and returns
+ /// .
+ /// - If it cannot, the Parser starts a background parse task and returns
+ /// .
+ ///
+ ///
+ /// NOTE: Additional flags can be applied to the , see that enum for more
+ /// details. However, the or
+ /// flags will ALWAYS be present.
+ ///
+ ///
+ /// A change can only be incrementally parsed if a single, unique, span (seein the syntax tree can be identified
+ /// as owning the entire change. For example, if a change overlaps with multiple s, the change
+ /// cannot be parsed incrementally and a full reparse is necessary. A "owns" a change if the
+ /// change occurs either a) entirely within it's boundaries or b) it is a pure insertion
+ /// (see ) at the end of a whose can
+ /// accept the change (see ).
+ ///
+ ///
+ /// When the returns , it updates
+ /// immediately. However, the editor is expected to update it's own data structures
+ /// independently. It can use to do this, as soon as the editor returns from
+ /// , but it should (ideally) have logic for doing so without needing the new
+ /// tree.
+ ///
+ ///
+ /// When is returned by , a
+ /// background parse task has already been started. When that task finishes, the
+ /// event will be fired containing the new generated code, parse tree and a
+ /// reference to the original that caused the reparse, to allow the editor to resolve the
+ /// new tree against any changes made since calling .
+ ///
+ ///
+ /// If a call to occurs while a reparse is already in-progress, the reparse
+ /// is canceled IMMEDIATELY and is returned without attempting to
+ /// reparse. This means that if a consumer calls , which returns
+ /// , then calls it again before is
+ /// fired, it will only receive one event, for the second change.
+ ///
+ ///
+ public class RazorEditorParser : IDisposable
+ {
+ // Lock for this document
+ private Span _lastChangeOwner;
+ private Span _lastAutoCompleteSpan;
+ private BackgroundParser _parser;
+ private RazorSyntaxTree _currentSyntaxTree;
+
+ public RazorEditorParser(RazorTemplateEngine templateEngine, string sourceFileName)
+ {
+ if (templateEngine == null)
+ {
+ throw new ArgumentNullException(nameof(templateEngine));
+ }
+
+ if (string.IsNullOrEmpty(sourceFileName))
+ {
+ throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(sourceFileName));
+ }
+
+ TemplateEngine = templateEngine;
+ FileName = sourceFileName;
+ _parser = new BackgroundParser(templateEngine, sourceFileName);
+ _parser.ResultsReady += (sender, args) => OnDocumentParseComplete(args);
+ _parser.Start();
+ }
+
+ ///
+ /// Event fired when a full reparse of the document completes.
+ ///
+ public event EventHandler DocumentParseComplete;
+
+ public RazorTemplateEngine TemplateEngine { get; private set; }
+ public string FileName { get; private set; }
+ public bool LastResultProvisional { get; private set; }
+
+ public RazorSyntaxTree CurrentSyntaxTree => _currentSyntaxTree;
+
+ public virtual string GetAutoCompleteString()
+ {
+ if (_lastAutoCompleteSpan != null)
+ {
+ var editHandler = _lastAutoCompleteSpan.EditHandler as AutoCompleteEditHandler;
+ if (editHandler != null)
+ {
+ return editHandler.AutoCompleteString;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Determines if a change will cause a structural change to the document and if not, applies it to the
+ /// existing tree. If a structural change would occur, automatically starts a reparse.
+ ///
+ ///
+ /// NOTE: The initial incremental parsing check and actual incremental parsing (if possible) occurs
+ /// on the caller's thread. However, if a full reparse is needed, this occurs on a background thread.
+ ///
+ /// The change to apply to the parse tree.
+ /// A value indicating the result of the incremental parse.
+ public virtual PartialParseResult CheckForStructureChanges(TextChange change)
+ {
+ var result = PartialParseResult.Rejected;
+
+ using (_parser.SynchronizeMainThreadState())
+ {
+ // Check if we can partial-parse
+ if (CurrentSyntaxTree != null && _parser.IsIdle)
+ {
+ result = TryPartialParse(change);
+ }
+ }
+
+ // If partial parsing failed or there were outstanding parser tasks, start a full reparse
+ if ((result & PartialParseResult.Rejected) == PartialParseResult.Rejected)
+ {
+ _parser.QueueChange(change);
+ }
+
+ // Otherwise, remember if this was provisionally accepted for next partial parse
+ LastResultProvisional = (result & PartialParseResult.Provisional) == PartialParseResult.Provisional;
+ VerifyFlagsAreValid(result);
+
+ return result;
+ }
+
+ ///
+ /// Disposes of this parser. Should be called when the editor window is closed and the document is unloaded.
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _parser.Dispose();
+ }
+ }
+
+ private PartialParseResult TryPartialParse(TextChange change)
+ {
+ var result = PartialParseResult.Rejected;
+
+ // Try the last change owner
+ if (_lastChangeOwner != null && _lastChangeOwner.EditHandler.OwnsChange(_lastChangeOwner, change))
+ {
+ var editResult = _lastChangeOwner.EditHandler.ApplyChange(_lastChangeOwner, change);
+ result = editResult.Result;
+ if ((editResult.Result & PartialParseResult.Rejected) != PartialParseResult.Rejected)
+ {
+ _lastChangeOwner.ReplaceWith(editResult.EditedSpan);
+ }
+
+ return result;
+ }
+
+ // Locate the span responsible for this change
+ _lastChangeOwner = CurrentSyntaxTree.Root.LocateOwner(change);
+
+ if (LastResultProvisional)
+ {
+ // Last change owner couldn't accept this, so we must do a full reparse
+ result = PartialParseResult.Rejected;
+ }
+ else if (_lastChangeOwner != null)
+ {
+ var editResult = _lastChangeOwner.EditHandler.ApplyChange(_lastChangeOwner, change);
+ result = editResult.Result;
+ if ((editResult.Result & PartialParseResult.Rejected) != PartialParseResult.Rejected)
+ {
+ _lastChangeOwner.ReplaceWith(editResult.EditedSpan);
+ }
+ if ((result & PartialParseResult.AutoCompleteBlock) == PartialParseResult.AutoCompleteBlock)
+ {
+ _lastAutoCompleteSpan = _lastChangeOwner;
+ }
+ else
+ {
+ _lastAutoCompleteSpan = null;
+ }
+ }
+
+ return result;
+ }
+
+ private void OnDocumentParseComplete(DocumentParseCompleteEventArgs args)
+ {
+ using (_parser.SynchronizeMainThreadState())
+ {
+ _currentSyntaxTree = args.GeneratorResults.GetSyntaxTree();
+ _lastChangeOwner = null;
+ }
+
+ Debug.Assert(args != null, "Event arguments cannot be null");
+ EventHandler handler = DocumentParseComplete;
+ if (handler != null)
+ {
+ try
+ {
+ handler(this, args);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine("[RzEd] Document Parse Complete Handler Threw: " + ex.ToString());
+ }
+ }
+ }
+
+ [Conditional("DEBUG")]
+ private static void VerifyFlagsAreValid(PartialParseResult result)
+ {
+ Debug.Assert(((result & PartialParseResult.Accepted) == PartialParseResult.Accepted) ||
+ ((result & PartialParseResult.Rejected) == PartialParseResult.Rejected),
+ "Partial Parse result does not have either of Accepted or Rejected flags set");
+ Debug.Assert(((result & PartialParseResult.Rejected) == PartialParseResult.Rejected) ||
+ ((result & PartialParseResult.SpanContextChanged) != PartialParseResult.SpanContextChanged),
+ "Partial Parse result was Accepted AND had SpanContextChanged flag set");
+ Debug.Assert(((result & PartialParseResult.Rejected) == PartialParseResult.Rejected) ||
+ ((result & PartialParseResult.AutoCompleteBlock) != PartialParseResult.AutoCompleteBlock),
+ "Partial Parse result was Accepted AND had AutoCompleteBlock flag set");
+ Debug.Assert(((result & PartialParseResult.Accepted) == PartialParseResult.Accepted) ||
+ ((result & PartialParseResult.Provisional) != PartialParseResult.Provisional),
+ "Partial Parse result was Rejected AND had Provisional flag set");
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/Span.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/Span.cs
index 3a0e2ad63c..125f8b2ce6 100644
--- a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/Span.cs
+++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/Span.cs
@@ -69,6 +69,12 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
{
Kind = builder.Kind;
Symbols = builder.Symbols;
+
+ for (var i = 0; i bodyEndLocation)
+ {
+ // Change occurs after the TagHelpers body. End tags for TagHelpers cannot claim ownership of changes
+ // because any change to them impacts whether or not a tag is a TagHelper.
+ return null;
+ }
+
+ var startTagEndLocation = Start.AbsoluteIndex + SourceStartTag?.Length;
+ if (oldPosition < startTagEndLocation)
+ {
+ // Change occurs in the start tag.
+
+ var attributeElements = Attributes
+ .Select(attribute => attribute.Value)
+ .Where(value => value != null);
+
+ return LocateOwner(change, attributeElements);
+ }
+
+ if (oldPosition < bodyEndLocation)
+ {
+ // Change occurs in the body
+ return base.LocateOwner(change);
+ }
+
+ // TagHelper does not contain a Span that can claim ownership.
+ return null;
+ }
+
///
public override string ToString()
{
diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TextChange.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TextChange.cs
index 3fb0144489..5c9522a830 100644
--- a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TextChange.cs
+++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TextChange.cs
@@ -9,7 +9,7 @@ using Microsoft.Extensions.Internal;
namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
{
- internal struct TextChange
+ public struct TextChange
{
private string _newText;
private string _oldText;
@@ -165,15 +165,6 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
.Insert(changeRelativePosition, NewText);
}
- ///
- /// Applies the text change to the content of the span and returns the new content.
- /// This method doesn't update the span content.
- ///
- public string ApplyChange(Span span)
- {
- return ApplyChange(span.Content, span.Start.AbsoluteIndex);
- }
-
public override string ToString()
{
return string.Format(CultureInfo.CurrentCulture, "({0}:{1}) \"{3}\" -> ({0}:{2}) \"{4}\"", OldPosition, OldLength, NewLength, OldText, NewText);
diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Microsoft.AspNetCore.Razor.Evolution.csproj b/src/Microsoft.AspNetCore.Razor.Evolution/Microsoft.AspNetCore.Razor.Evolution.csproj
index 35aeb297fc..7bddfb1512 100644
--- a/src/Microsoft.AspNetCore.Razor.Evolution/Microsoft.AspNetCore.Razor.Evolution.csproj
+++ b/src/Microsoft.AspNetCore.Razor.Evolution/Microsoft.AspNetCore.Razor.Evolution.csproj
@@ -14,4 +14,8 @@
+
+
+
+
diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/RazorEditorParserTest.cs b/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/RazorEditorParserTest.cs
new file mode 100644
index 0000000000..ade7ce6e82
--- /dev/null
+++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/RazorEditorParserTest.cs
@@ -0,0 +1,1462 @@
+// 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.Threading;
+using Microsoft.AspNetCore.Testing;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Razor.Evolution.Legacy
+{
+ public class RazorEditorParserTest
+ {
+ private static readonly TestFile SimpleCSHTMLDocument = TestFile.Create("TestFiles/DesignTime/Simple.cshtml");
+ private static readonly TestFile SimpleCSHTMLDocumentGenerated = TestFile.Create("TestFiles/DesignTime/Simple.txt");
+ private const string TestLinePragmaFileName = "C:\\This\\Path\\Is\\Just\\For\\Line\\Pragmas.cshtml";
+
+ public static TheoryData TagHelperPartialParseRejectData
+ {
+ get
+ {
+ // change, (Block)expectedDocument
+ return new TheoryData
+ {
+ {
+ CreateInsertionChange("", 2, " "),
+ new MarkupBlock(
+ new MarkupTagHelperBlock("p"))
+ },
+ {
+ CreateInsertionChange("", 6, " "),
+ new MarkupBlock(
+ new MarkupTagHelperBlock("p"))
+ },
+ {
+ CreateInsertionChange("", 12, " "),
+ new MarkupBlock(
+ new MarkupTagHelperBlock(
+ "p",
+ attributes: new List
+ {
+ new TagHelperAttributeNode(
+ "some-attr",
+ value: null,
+ valueStyle: HtmlAttributeValueStyle.Minimized)
+ }))
+ },
+ {
+ CreateInsertionChange("", 12, "ibute"),
+ new MarkupBlock(
+ new MarkupTagHelperBlock(
+ "p",
+ attributes: new List
+ {
+ new TagHelperAttributeNode(
+ "some-attribute",
+ value: null,
+ valueStyle: HtmlAttributeValueStyle.Minimized)
+ }))
+ },
+ {
+ CreateInsertionChange("", 2, " before"),
+ new MarkupBlock(
+ new MarkupTagHelperBlock(
+ "p",
+ attributes: new List
+ {
+ new TagHelperAttributeNode(
+ "before",
+ value: null,
+ valueStyle: HtmlAttributeValueStyle.Minimized),
+ new TagHelperAttributeNode(
+ "some-attr",
+ value: null,
+ valueStyle: HtmlAttributeValueStyle.Minimized)
+ }))
+ },
+ };
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(TagHelperPartialParseRejectData))]
+ public void TagHelperTagBodiesRejectPartialChanges(TextChange change, object expectedDocument)
+ {
+ // Arrange
+ var descriptors = new[]
+ {
+ new TagHelperDescriptor
+ {
+ TagName = "p",
+ TypeName = "PTagHelper"
+ },
+ };
+
+ var parser = new RazorEditorParser(CreateTemplateEngine(@"C:\This\Is\A\Test\Path"), @"C:\This\Is\A\Test\Path");
+
+ using (var manager = new TestParserManager(parser))
+ {
+ manager.InitializeWithDocument(change.OldBuffer);
+
+ // Act
+ var result = manager.CheckForStructureChangesAndWait(change);
+
+ // Assert
+ Assert.Equal(PartialParseResult.Rejected, result);
+ Assert.Equal(2, manager.ParseCount);
+ }
+ }
+
+ public static TheoryData TagHelperAttributeAcceptData
+ {
+ get
+ {
+ var factory = new SpanFactory();
+
+ // change, (Block)expectedDocument, partialParseResult
+ return new TheoryData
+ {
+ {
+ CreateInsertionChange("", 22, "."),
+ new MarkupBlock(
+ new MarkupTagHelperBlock(
+ "p",
+ attributes: new List
+ {
+ new TagHelperAttributeNode(
+ "str-attr",
+ new MarkupBlock(
+ new MarkupBlock(
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory
+ .Code("DateTime.")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)))),
+ HtmlAttributeValueStyle.SingleQuotes)
+ })),
+ PartialParseResult.Accepted | PartialParseResult.Provisional
+ },
+ {
+ CreateInsertionChange("", 21, "."),
+ new MarkupBlock(
+ new MarkupTagHelperBlock(
+ "p",
+ attributes: new List
+ {
+ new TagHelperAttributeNode(
+ "obj-attr",
+ factory.CodeMarkup("DateTime."),
+ HtmlAttributeValueStyle.SingleQuotes)
+ })),
+ PartialParseResult.Accepted
+ },
+ {
+ CreateInsertionChange("", 25, "."),
+ new MarkupBlock(
+ new MarkupTagHelperBlock(
+ "p",
+ attributes: new List
+ {
+ new TagHelperAttributeNode(
+ "obj-attr",
+ factory.CodeMarkup("1 + DateTime."),
+ HtmlAttributeValueStyle.SingleQuotes)
+ })),
+ PartialParseResult.Accepted
+ },
+ {
+ CreateInsertionChange("", 34, "."),
+ new MarkupBlock(
+ new MarkupTagHelperBlock(
+ "p",
+ attributes: new List
+ {
+ new TagHelperAttributeNode(
+ "before-attr",
+ value: null,
+ valueStyle: HtmlAttributeValueStyle.Minimized),
+ new TagHelperAttributeNode(
+ "str-attr",
+ new MarkupBlock(
+ new MarkupBlock(
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory
+ .Code("DateTime.")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)))),
+ HtmlAttributeValueStyle.SingleQuotes),
+ new TagHelperAttributeNode(
+ "after-attr",
+ value: null,
+ valueStyle: HtmlAttributeValueStyle.Minimized),
+ })),
+ PartialParseResult.Accepted | PartialParseResult.Provisional
+ },
+ {
+ CreateInsertionChange("", 29, "."),
+ new MarkupBlock(
+ new MarkupTagHelperBlock(
+ "p",
+ attributes: new List
+ {
+ new TagHelperAttributeNode(
+ "str-attr",
+ new MarkupBlock(
+ factory.Markup("before"),
+ new MarkupBlock(
+ factory.Markup(" "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory
+ .Code("DateTime.")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords)
+ .Accepts(AcceptedCharacters.NonWhiteSpace))),
+ factory.Markup(" after")),
+ HtmlAttributeValueStyle.SingleQuotes)
+ })),
+ PartialParseResult.Accepted | PartialParseResult.Provisional
+ },
+ };
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(TagHelperAttributeAcceptData))]
+ public void TagHelperAttributesAreLocatedAndAcceptChangesCorrectly(
+ TextChange change,
+ object expectedDocument,
+ PartialParseResult partialParseResult)
+ {
+ // Arrange
+ var descriptors = new[]
+ {
+ new TagHelperDescriptor
+ {
+ TagName = "p",
+ TypeName = "PTagHelper",
+ AssemblyName = "Test",
+ Attributes = new[]
+ {
+ new TagHelperAttributeDescriptor
+ {
+ Name = "obj-attr",
+ TypeName = typeof(object).FullName,
+ PropertyName = "ObjectAttribute",
+ },
+ new TagHelperAttributeDescriptor
+ {
+ Name = "str-attr",
+ TypeName = typeof(string).FullName,
+ PropertyName = "StringAttribute",
+ },
+ }
+ },
+ };
+
+ var parser = new RazorEditorParser(CreateTemplateEngine(@"C:\This\Is\A\Test\Path", descriptors), @"C:\This\Is\A\Test\Path");
+
+ using (var manager = new TestParserManager(parser))
+ {
+ manager.InitializeWithDocument(change.OldBuffer);
+
+ // Act
+ var result = manager.CheckForStructureChangesAndWait(change);
+
+ // Assert
+ Assert.Equal(partialParseResult, result);
+ Assert.Equal(1, manager.ParseCount);
+ }
+ }
+
+ [Fact]
+ public void ConstructorRequiresNonNullPhysicalPath()
+ {
+ Assert.Throws("sourceFileName", () => new RazorEditorParser(CreateTemplateEngine(), null));
+ }
+
+ [Fact]
+ public void ConstructorRequiresNonEmptyPhysicalPath()
+ {
+ Assert.Throws("sourceFileName", () => new RazorEditorParser(CreateTemplateEngine(), string.Empty));
+ }
+
+ [Theory]
+ [InlineData(" ")]
+ [InlineData("\r\n")]
+ [InlineData("abcdefg")]
+ [InlineData("\f\r\n abcd \t")]
+ public void TreesAreDifferentReturnsFalseForAddedContent(string content)
+ {
+ // Arrange
+ var factory = new SpanFactory();
+ var blockFactory = new BlockFactory(factory);
+ var original = new MarkupBlock(
+ blockFactory.MarkupTagBlock(""),
+ blockFactory.TagHelperBlock(
+ tagName: "div",
+ tagMode: TagMode.StartTagAndEndTag,
+ start: new SourceLocation(3, 0, 3),
+ startTag: blockFactory.MarkupTagBlock("
"),
+ children: new SyntaxTreeNode[]
+ {
+ factory.Markup($"{Environment.NewLine}{Environment.NewLine}")
+ },
+ endTag: blockFactory.MarkupTagBlock("
")),
+ blockFactory.MarkupTagBlock("
"));
+
+ factory.Reset();
+
+ var modified = new MarkupBlock(
+ blockFactory.MarkupTagBlock(""),
+ blockFactory.TagHelperBlock(
+ tagName: "div",
+ tagMode: TagMode.StartTagAndEndTag,
+ start: new SourceLocation(3, 0, 3),
+ startTag: blockFactory.MarkupTagBlock("
"),
+ children: new SyntaxTreeNode[]
+ {
+ factory.Markup($"{Environment.NewLine}{content}{Environment.NewLine}")
+ },
+ endTag: blockFactory.MarkupTagBlock("
")),
+ blockFactory.MarkupTagBlock(""));
+ original.LinkNodes();
+ modified.LinkNodes();
+
+ var oldBuffer = new StringTextBuffer($"{Environment.NewLine}{Environment.NewLine}
");
+ var newBuffer = new StringTextBuffer(
+ $"{Environment.NewLine}{content}{Environment.NewLine}
");
+
+ // Act
+ var treesAreDifferent = BackgroundParser.TreesAreDifferent(
+ original,
+ modified,
+ new[]
+ {
+ new TextChange(
+ position: 8 + Environment.NewLine.Length,
+ oldLength: 0,
+ oldBuffer: oldBuffer,
+ newLength: content.Length,
+ newBuffer: newBuffer)
+ });
+
+ // Assert
+ Assert.False(treesAreDifferent);
+ }
+
+ [Fact]
+ public void TreesAreDifferentReturnsTrueIfTreeStructureIsDifferent()
+ {
+ var factory = new SpanFactory();
+ var original = new MarkupBlock(
+ factory.Markup(""),
+ new ExpressionBlock(
+ factory.CodeTransition()),
+ factory.Markup("
"));
+ var modified = new MarkupBlock(
+ factory.Markup(""),
+ new ExpressionBlock(
+ factory.CodeTransition("@"),
+ factory.Code("f")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: false)),
+ factory.Markup("
"));
+ var oldBuffer = new StringTextBuffer("@
");
+ var newBuffer = new StringTextBuffer("@f
");
+ Assert.True(BackgroundParser.TreesAreDifferent(
+ original,
+ modified,
+ new[]
+ {
+ new TextChange(position: 4, oldLength: 0, oldBuffer: oldBuffer, newLength: 1, newBuffer: newBuffer)
+ }));
+ }
+
+ [Fact]
+ public void TreesAreDifferentReturnsFalseIfTreeStructureIsSame()
+ {
+ var factory = new SpanFactory();
+ var original = new MarkupBlock(
+ factory.Markup(""),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("f")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: false)),
+ factory.Markup("
"));
+ factory.Reset();
+ var modified = new MarkupBlock(
+ factory.Markup(""),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("foo")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: false)),
+ factory.Markup("
"));
+ original.LinkNodes();
+ modified.LinkNodes();
+ var oldBuffer = new StringTextBuffer("@f
");
+ var newBuffer = new StringTextBuffer("@foo
");
+ Assert.False(BackgroundParser.TreesAreDifferent(
+ original,
+ modified,
+ new[]
+ {
+ new TextChange(position: 5, oldLength: 0, oldBuffer: oldBuffer, newLength: 2, newBuffer: newBuffer)
+ }));
+ }
+
+ [Fact]
+ [ReplaceCulture]
+ public void CheckForStructureChangesStartsReparseAndFiresDocumentParseCompletedEventIfNoAdditionalChangesQueued()
+ {
+ // Arrange
+ using (var parser = new RazorEditorParser(CreateTemplateEngine(), TestLinePragmaFileName))
+ {
+ var input = new StringTextBuffer(SimpleCSHTMLDocument.ReadAllText());
+
+ DocumentParseCompleteEventArgs capturedArgs = null;
+ var parseComplete = new ManualResetEventSlim(false);
+
+ parser.DocumentParseComplete += (sender, args) =>
+ {
+ capturedArgs = args;
+ parseComplete.Set();
+ };
+
+ // Act
+ parser.CheckForStructureChanges(new TextChange(0, 0, new StringTextBuffer(string.Empty), input.Length, input));
+
+ // Assert
+ MiscUtils.DoWithTimeoutIfNotDebugging(parseComplete.Wait);
+
+ Assert.Equal(
+ SimpleCSHTMLDocumentGenerated.ReadAllText().Replace("\r\n", "\n"),
+ capturedArgs.GeneratorResults.GetCSharpDocument().GeneratedCode.Replace("\r\n", "\n"));
+ }
+ }
+
+ [Fact]
+ public void CheckForStructureChangesStartsFullReparseIfChangeOverlapsMultipleSpans()
+ {
+ // Arrange
+ using (var parser = new RazorEditorParser(CreateTemplateEngine(), TestLinePragmaFileName))
+ {
+ var original = new StringTextBuffer("Foo @bar Baz");
+ var changed = new StringTextBuffer("Foo @bap Daz");
+ var change = new TextChange(7, 3, original, 3, changed);
+
+ var parseComplete = new ManualResetEventSlim();
+ var parseCount = 0;
+ parser.DocumentParseComplete += (sender, args) =>
+ {
+ Interlocked.Increment(ref parseCount);
+ parseComplete.Set();
+ };
+
+ Assert.Equal(PartialParseResult.Rejected, parser.CheckForStructureChanges(new TextChange(0, 0, new StringTextBuffer(string.Empty), 12, original)));
+ MiscUtils.DoWithTimeoutIfNotDebugging(parseComplete.Wait); // Wait for the parse to finish
+ parseComplete.Reset();
+
+ // Act
+ var result = parser.CheckForStructureChanges(change);
+
+ // Assert
+ Assert.Equal(PartialParseResult.Rejected, result);
+ MiscUtils.DoWithTimeoutIfNotDebugging(parseComplete.Wait);
+ Assert.Equal(2, parseCount);
+ }
+ }
+
+ [Fact]
+ public void AwaitPeriodInsertionAcceptedProvisionally()
+ {
+ // Arrange
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("foo @await Html. baz");
+ var old = new StringTextBuffer("foo @await Html baz");
+
+ // Act and Assert
+ RunPartialParseTest(new TextChange(15, 0, old, 1, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("await Html.").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.WhiteSpace | AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")), additionalFlags: PartialParseResult.Provisional);
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsInnerInsertionsInStatementBlock()
+ {
+ // Arrange
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("@{" + Environment.NewLine
+ + " @DateTime..Now" + Environment.NewLine
+ + "}");
+ var old = new StringTextBuffer("@{" + Environment.NewLine
+ + " @DateTime.Now" + Environment.NewLine
+ + "}");
+
+ // Act and Assert
+ RunPartialParseTest(new TextChange(17, 0, old, 1, changed),
+ new MarkupBlock(
+ factory.EmptyHtml(),
+ new StatementBlock(
+ factory.CodeTransition(),
+ factory.MetaCode("{").Accepts(AcceptedCharacters.None),
+ factory.Code(Environment.NewLine + " ")
+ .AsStatement()
+ .AutoCompleteWith(autoCompleteString: null),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("DateTime..Now")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Code(Environment.NewLine).AsStatement(),
+ factory.MetaCode("}").Accepts(AcceptedCharacters.None)),
+ factory.EmptyHtml()));
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsInnerInsertions()
+ {
+ // Arrange
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("foo @DateTime..Now baz");
+ var old = new StringTextBuffer("foo @DateTime.Now baz");
+
+ // Act and Assert
+ RunPartialParseTest(new TextChange(13, 0, old, 1, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("DateTime..Now").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")), additionalFlags: PartialParseResult.Provisional);
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsWholeIdentifierReplacement()
+ {
+ // Arrange
+ var factory = new SpanFactory();
+ var old = new StringTextBuffer("foo @date baz");
+ var changed = new StringTextBuffer("foo @DateTime baz");
+
+ // Act and Assert
+ RunPartialParseTest(new TextChange(5, 4, old, 8, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("DateTime").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")));
+ }
+
+ [Fact]
+ public void ImplicitExpressionRejectsWholeIdentifierReplacementToKeyword()
+ {
+ // Arrange
+ var parser = new RazorEditorParser(CreateTemplateEngine(@"C:\This\Is\A\Test\Path"), @"C:\This\Is\A\Test\Path");
+
+ using (var manager = new TestParserManager(parser))
+ {
+ var old = new StringTextBuffer("foo @date baz");
+ var changed = new StringTextBuffer("foo @if baz");
+ var textChange = new TextChange(5, 4, old, 2, changed);
+ manager.InitializeWithDocument(old);
+
+ // Act
+ var result = manager.CheckForStructureChangesAndWait(textChange);
+
+ // Assert
+ Assert.Equal(PartialParseResult.Rejected, result);
+ Assert.Equal(2, manager.ParseCount);
+ }
+ }
+
+ [Fact]
+ public void ImplicitExpressionRejectsWholeIdentifierReplacementToDirective()
+ {
+ // Arrange
+ var parser = new RazorEditorParser(CreateTemplateEngine(@"C:\This\Is\A\Test\Path"), @"C:\This\Is\A\Test\Path");
+
+ using (var manager = new TestParserManager(parser))
+ {
+ var old = new StringTextBuffer("foo @date baz");
+ var changed = new StringTextBuffer("foo @inherits baz");
+ var textChange = new TextChange(5, 4, old, 8, changed);
+ manager.InitializeWithDocument(old);
+
+ // Act
+ var result = manager.CheckForStructureChangesAndWait(textChange);
+
+ // Assert
+ Assert.Equal(PartialParseResult.Rejected | PartialParseResult.SpanContextChanged, result);
+ Assert.Equal(2, manager.ParseCount);
+ }
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsPrefixIdentifierReplacements_SingleSymbol()
+ {
+ // Arrange
+ var factory = new SpanFactory();
+ var old = new StringTextBuffer("foo @dTime baz");
+ var changed = new StringTextBuffer("foo @DateTime baz");
+
+ // Act and Assert
+ RunPartialParseTest(new TextChange(5, 1, old, 4, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("DateTime").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")));
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsPrefixIdentifierReplacements_MultipleSymbols()
+ {
+ // Arrange
+ var factory = new SpanFactory();
+ var old = new StringTextBuffer("foo @dTime.Now baz");
+ var changed = new StringTextBuffer("foo @DateTime.Now baz");
+
+ // Act and Assert
+ RunPartialParseTest(new TextChange(5, 1, old, 4, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("DateTime.Now").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")));
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsSuffixIdentifierReplacements_SingleSymbol()
+ {
+ // Arrange
+ var factory = new SpanFactory();
+ var old = new StringTextBuffer("foo @Datet baz");
+ var changed = new StringTextBuffer("foo @DateTime baz");
+
+ // Act and Assert
+ RunPartialParseTest(new TextChange(9, 1, old, 4, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("DateTime").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")));
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsSuffixIdentifierReplacements_MultipleSymbols()
+ {
+ // Arrange
+ var factory = new SpanFactory();
+ var old = new StringTextBuffer("foo @DateTime.n baz");
+ var changed = new StringTextBuffer("foo @DateTime.Now baz");
+
+ // Act and Assert
+ RunPartialParseTest(new TextChange(14, 1, old, 3, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("DateTime.Now").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")));
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsSurroundedIdentifierReplacements()
+ {
+ // Arrange
+ var factory = new SpanFactory();
+ var old = new StringTextBuffer("foo @DateTime.n.ToString() baz");
+ var changed = new StringTextBuffer("foo @DateTime.Now.ToString() baz");
+
+ // Act and Assert
+ RunPartialParseTest(new TextChange(14, 1, old, 3, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("DateTime.Now.ToString()").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")));
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsDotlessCommitInsertionsInStatementBlockAfterIdentifiers()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("@{" + Environment.NewLine
+ + " @DateTime." + Environment.NewLine
+ + "}");
+ var old = new StringTextBuffer("@{" + Environment.NewLine
+ + " @DateTime" + Environment.NewLine
+ + "}");
+
+ var textChange = new TextChange(15 + Environment.NewLine.Length, 0, old, 1, changed);
+ using (var manager = CreateParserManager())
+ {
+ Action applyAndVerifyPartialChange = (changeToApply, expectedResult, expectedCode) =>
+ {
+ var result = manager.CheckForStructureChangesAndWait(textChange);
+
+ // Assert
+ Assert.Equal(expectedResult, result);
+ Assert.Equal(1, manager.ParseCount);
+ ParserTestBase.EvaluateParseTree(manager.Parser.CurrentSyntaxTree.Root, new MarkupBlock(
+ factory.EmptyHtml(),
+ new StatementBlock(
+ factory.CodeTransition(),
+ factory.MetaCode("{").Accepts(AcceptedCharacters.None),
+ factory.Code(Environment.NewLine + " ")
+ .AsStatement()
+ .AutoCompleteWith(autoCompleteString: null),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code(expectedCode)
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Code(Environment.NewLine).AsStatement(),
+ factory.MetaCode("}").Accepts(AcceptedCharacters.None)),
+ factory.EmptyHtml()));
+ };
+
+ manager.InitializeWithDocument(textChange.OldBuffer);
+
+ // This is the process of a dotless commit when doing "." insertions to commit intellisense changes.
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted, "DateTime.");
+
+ old = changed;
+ changed = new StringTextBuffer("@{" + Environment.NewLine
+ + " @DateTime.." + Environment.NewLine
+ + "}");
+ textChange = new TextChange(16 + Environment.NewLine.Length, 0, old, 1, changed);
+
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted, "DateTime..");
+
+ old = changed;
+ changed = new StringTextBuffer("@{" + Environment.NewLine
+ + " @DateTime.Now." + Environment.NewLine
+ + "}");
+ textChange = new TextChange(16 + Environment.NewLine.Length, 0, old, 3, changed);
+
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted, "DateTime.Now.");
+ }
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsDotlessCommitInsertionsInStatementBlock()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("@{" + Environment.NewLine
+ + " @DateT." + Environment.NewLine
+ + "}");
+ var old = new StringTextBuffer("@{" + Environment.NewLine
+ + " @DateT" + Environment.NewLine
+ + "}");
+
+ var textChange = new TextChange(12 + Environment.NewLine.Length, 0, old, 1, changed);
+ using (var manager = CreateParserManager())
+ {
+ Action applyAndVerifyPartialChange = (changeToApply, expectedResult, expectedCode) =>
+ {
+ var result = manager.CheckForStructureChangesAndWait(textChange);
+
+ // Assert
+ Assert.Equal(expectedResult, result);
+ Assert.Equal(1, manager.ParseCount);
+ ParserTestBase.EvaluateParseTree(manager.Parser.CurrentSyntaxTree.Root, new MarkupBlock(
+ factory.EmptyHtml(),
+ new StatementBlock(
+ factory.CodeTransition(),
+ factory.MetaCode("{").Accepts(AcceptedCharacters.None),
+ factory.Code(Environment.NewLine + " ")
+ .AsStatement()
+ .AutoCompleteWith(autoCompleteString: null),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code(expectedCode)
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Code(Environment.NewLine).AsStatement(),
+ factory.MetaCode("}").Accepts(AcceptedCharacters.None)),
+ factory.EmptyHtml()));
+ };
+
+ manager.InitializeWithDocument(textChange.OldBuffer);
+
+ // This is the process of a dotless commit when doing "." insertions to commit intellisense changes.
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted, "DateT.");
+
+ old = changed;
+ changed = new StringTextBuffer("@{" + Environment.NewLine
+ + " @DateTime." + Environment.NewLine
+ + "}");
+ textChange = new TextChange(12 + Environment.NewLine.Length, 0, old, 3, changed);
+
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted, "DateTime.");
+ }
+ }
+
+ [Fact]
+ public void ImplicitExpressionProvisionallyAcceptsDotlessCommitInsertions()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("foo @DateT. baz");
+ var old = new StringTextBuffer("foo @DateT baz");
+ var textChange = new TextChange(10, 0, old, 1, changed);
+ using (var manager = CreateParserManager())
+ {
+ Action applyAndVerifyPartialChange = (changeToApply, expectedResult, expectedCode) =>
+ {
+ var result = manager.CheckForStructureChangesAndWait(textChange);
+
+ // Assert
+ Assert.Equal(expectedResult, result);
+ Assert.Equal(1, manager.ParseCount);
+
+ ParserTestBase.EvaluateParseTree(manager.Parser.CurrentSyntaxTree.Root, new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code(expectedCode).AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")));
+ };
+
+ manager.InitializeWithDocument(textChange.OldBuffer);
+
+ // This is the process of a dotless commit when doing "." insertions to commit intellisense changes.
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted | PartialParseResult.Provisional, "DateT.");
+
+ old = changed;
+ changed = new StringTextBuffer("foo @DateTime. baz");
+ textChange = new TextChange(10, 0, old, 3, changed);
+
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted | PartialParseResult.Provisional, "DateTime.");
+ }
+ }
+
+ [Fact]
+ public void ImplicitExpressionProvisionallyAcceptsDotlessCommitInsertionsAfterIdentifiers()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("foo @DateTime. baz");
+ var old = new StringTextBuffer("foo @DateTime baz");
+ var textChange = new TextChange(13, 0, old, 1, changed);
+ using (var manager = CreateParserManager())
+ {
+ Action applyAndVerifyPartialChange = (changeToApply, expectedResult, expectedCode) =>
+ {
+ var result = manager.CheckForStructureChangesAndWait(textChange);
+
+ // Assert
+ Assert.Equal(expectedResult, result);
+ Assert.Equal(1, manager.ParseCount);
+
+ ParserTestBase.EvaluateParseTree(manager.Parser.CurrentSyntaxTree.Root, new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code(expectedCode).AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")));
+ };
+
+ manager.InitializeWithDocument(textChange.OldBuffer);
+
+ // This is the process of a dotless commit when doing "." insertions to commit intellisense changes.
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted | PartialParseResult.Provisional, "DateTime.");
+
+ old = changed;
+ changed = new StringTextBuffer("foo @DateTime.. baz");
+ textChange = new TextChange(14, 0, old, 1, changed);
+
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted | PartialParseResult.Provisional, "DateTime..");
+
+ old = changed;
+ changed = new StringTextBuffer("foo @DateTime.Now. baz");
+ textChange = new TextChange(14, 0, old, 3, changed);
+
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted | PartialParseResult.Provisional, "DateTime.Now.");
+ }
+ }
+
+ [Fact]
+ public void ImplicitExpressionProvisionallyAcceptsCaseInsensitiveDotlessCommitInsertions_NewRoslynIntegration()
+ {
+ var factory = new SpanFactory();
+ var old = new StringTextBuffer("foo @date baz");
+ var changed = new StringTextBuffer("foo @date. baz");
+ var textChange = new TextChange(9, 0, old, 1, changed);
+ using (var manager = CreateParserManager())
+ {
+ Action applyAndVerifyPartialChange = (changeToApply, expectedResult, expectedCode) =>
+ {
+ var result = manager.CheckForStructureChangesAndWait(textChange);
+
+ // Assert
+ Assert.Equal(expectedResult, result);
+ Assert.Equal(1, manager.ParseCount);
+
+ ParserTestBase.EvaluateParseTree(manager.Parser.CurrentSyntaxTree.Root, new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code(expectedCode).AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")));
+ };
+
+ manager.InitializeWithDocument(textChange.OldBuffer);
+
+ // This is the process of a dotless commit when doing "." insertions to commit intellisense changes.
+
+ // @date => @date.
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted | PartialParseResult.Provisional, "date.");
+
+ old = changed;
+ changed = new StringTextBuffer("foo @date baz");
+ textChange = new TextChange(9, 1, old, 0, changed);
+
+ // @date. => @date
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted, "date");
+
+ old = changed;
+ changed = new StringTextBuffer("foo @DateTime baz");
+ textChange = new TextChange(5, 4, old, 8, changed);
+
+ // @date => @DateTime
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted, "DateTime");
+
+ old = changed;
+ changed = new StringTextBuffer("foo @DateTime. baz");
+ textChange = new TextChange(13, 0, old, 1, changed);
+
+ // @DateTime => @DateTime.
+ applyAndVerifyPartialChange(textChange, PartialParseResult.Accepted | PartialParseResult.Provisional, "DateTime.");
+ }
+ }
+
+ [Fact]
+ public void ImplicitExpressionProvisionallyAcceptsDeleteOfIdentifierPartsIfDotRemains()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("foo @User. baz");
+ var old = new StringTextBuffer("foo @User.Name baz");
+ RunPartialParseTest(new TextChange(10, 4, old, 0, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("User.").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")),
+ additionalFlags: PartialParseResult.Provisional);
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsDeleteOfIdentifierPartsIfSomeOfIdentifierRemains()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("foo @Us baz");
+ var old = new StringTextBuffer("foo @User baz");
+ RunPartialParseTest(new TextChange(7, 2, old, 0, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("Us").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")));
+ }
+
+ [Fact]
+ public void ImplicitExpressionProvisionallyAcceptsMultipleInsertionIfItCausesIdentifierExpansionAndTrailingDot()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("foo @User. baz");
+ var old = new StringTextBuffer("foo @U baz");
+ RunPartialParseTest(new TextChange(6, 0, old, 4, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("User.").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")),
+ additionalFlags: PartialParseResult.Provisional);
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsMultipleInsertionIfItOnlyCausesIdentifierExpansion()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("foo @barbiz baz");
+ var old = new StringTextBuffer("foo @bar baz");
+ RunPartialParseTest(new TextChange(8, 0, old, 3, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("barbiz").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" baz")));
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsIdentifierExpansionAtEndOfNonWhitespaceCharacters()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("@{" + Environment.NewLine
+ + " @food" + Environment.NewLine
+ + "}");
+ var old = new StringTextBuffer("@{" + Environment.NewLine
+ + " @foo" + Environment.NewLine
+ + "}");
+ RunPartialParseTest(new TextChange(10 + Environment.NewLine.Length, 0, old, 1, changed),
+ new MarkupBlock(
+ factory.EmptyHtml(),
+ new StatementBlock(
+ factory.CodeTransition(),
+ factory.MetaCode("{").Accepts(AcceptedCharacters.None),
+ factory.Code(Environment.NewLine + " ")
+ .AsStatement()
+ .AutoCompleteWith(autoCompleteString: null),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("food")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Code(Environment.NewLine).AsStatement(),
+ factory.MetaCode("}").Accepts(AcceptedCharacters.None)),
+ factory.EmptyHtml()));
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsIdentifierAfterDotAtEndOfNonWhitespaceCharacters()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("@{" + Environment.NewLine
+ + " @foo.d" + Environment.NewLine
+ + "}");
+ var old = new StringTextBuffer("@{" + Environment.NewLine
+ + " @foo." + Environment.NewLine
+ + "}");
+ RunPartialParseTest(new TextChange(11 + Environment.NewLine.Length, 0, old, 1, changed),
+ new MarkupBlock(
+ factory.EmptyHtml(),
+ new StatementBlock(
+ factory.CodeTransition(),
+ factory.MetaCode("{").Accepts(AcceptedCharacters.None),
+ factory.Code(Environment.NewLine + " ")
+ .AsStatement()
+ .AutoCompleteWith(autoCompleteString: null),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("foo.d")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Code(Environment.NewLine).AsStatement(),
+ factory.MetaCode("}").Accepts(AcceptedCharacters.None)),
+ factory.EmptyHtml()));
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsDotAtEndOfNonWhitespaceCharacters()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("@{" + Environment.NewLine
+ + " @foo." + Environment.NewLine
+ + "}");
+ var old = new StringTextBuffer("@{" + Environment.NewLine
+ + " @foo" + Environment.NewLine
+ + "}");
+ RunPartialParseTest(new TextChange(10 + Environment.NewLine.Length, 0, old, 1, changed),
+ new MarkupBlock(
+ factory.EmptyHtml(),
+ new StatementBlock(
+ factory.CodeTransition(),
+ factory.MetaCode("{").Accepts(AcceptedCharacters.None),
+ factory.Code(Environment.NewLine + " ")
+ .AsStatement()
+ .AutoCompleteWith(autoCompleteString: null),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code(@"foo.")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Code(Environment.NewLine).AsStatement(),
+ factory.MetaCode("}").Accepts(AcceptedCharacters.None)),
+ factory.EmptyHtml()));
+ }
+
+ [Fact]
+ public void ImplicitExpressionRejectsChangeWhichWouldHaveBeenAcceptedIfLastChangeWasProvisionallyAcceptedOnDifferentSpan()
+ {
+ var factory = new SpanFactory();
+
+ // Arrange
+ var dotTyped = new TextChange(8, 0, new StringTextBuffer("foo @foo @bar"), 1, new StringTextBuffer("foo @foo. @bar"));
+ var charTyped = new TextChange(14, 0, new StringTextBuffer("foo @foo. @bar"), 1, new StringTextBuffer("foo @foo. @barb"));
+ using (var manager = CreateParserManager())
+ {
+ manager.InitializeWithDocument(dotTyped.OldBuffer);
+
+ // Apply the dot change
+ Assert.Equal(PartialParseResult.Provisional | PartialParseResult.Accepted, manager.CheckForStructureChangesAndWait(dotTyped));
+
+ // Act (apply the identifier start char change)
+ var result = manager.CheckForStructureChangesAndWait(charTyped);
+
+ // Assert
+ Assert.Equal(PartialParseResult.Rejected, result);
+ Assert.False(manager.Parser.LastResultProvisional, "LastResultProvisional flag should have been cleared but it was not");
+ ParserTestBase.EvaluateParseTree(manager.Parser.CurrentSyntaxTree.Root,
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("foo")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(". "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("barb")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.EmptyHtml()));
+ }
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsIdentifierTypedAfterDotIfLastChangeWasProvisionalAcceptanceOfDot()
+ {
+ var factory = new SpanFactory();
+
+ // Arrange
+ var dotTyped = new TextChange(8, 0, new StringTextBuffer("foo @foo bar"), 1, new StringTextBuffer("foo @foo. bar"));
+ var charTyped = new TextChange(9, 0, new StringTextBuffer("foo @foo. bar"), 1, new StringTextBuffer("foo @foo.b bar"));
+ using (var manager = CreateParserManager())
+ {
+ manager.InitializeWithDocument(dotTyped.OldBuffer);
+
+ // Apply the dot change
+ Assert.Equal(PartialParseResult.Provisional | PartialParseResult.Accepted, manager.CheckForStructureChangesAndWait(dotTyped));
+
+ // Act (apply the identifier start char change)
+ var result = manager.CheckForStructureChangesAndWait(charTyped);
+
+ // Assert
+ Assert.Equal(PartialParseResult.Accepted, result);
+ Assert.False(manager.Parser.LastResultProvisional, "LastResultProvisional flag should have been cleared but it was not");
+ ParserTestBase.EvaluateParseTree(manager.Parser.CurrentSyntaxTree.Root,
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("foo.b")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" bar")));
+ }
+ }
+
+ [Fact]
+ public void ImplicitExpressionProvisionallyAcceptsDotAfterIdentifierInMarkup()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("foo @foo. bar");
+ var old = new StringTextBuffer("foo @foo bar");
+ RunPartialParseTest(new TextChange(8, 0, old, 1, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("foo.")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" bar")),
+ additionalFlags: PartialParseResult.Provisional);
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsAdditionalIdentifierCharactersIfEndOfSpanIsIdentifier()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("foo @foob bar");
+ var old = new StringTextBuffer("foo @foo bar");
+ RunPartialParseTest(new TextChange(8, 0, old, 1, changed),
+ new MarkupBlock(
+ factory.Markup("foo "),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("foob")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.Markup(" bar")));
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsAdditionalIdentifierStartCharactersIfEndOfSpanIsDot()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("@{@foo.b}");
+ var old = new StringTextBuffer("@{@foo.}");
+ RunPartialParseTest(new TextChange(7, 0, old, 1, changed),
+ new MarkupBlock(
+ factory.EmptyHtml(),
+ new StatementBlock(
+ factory.CodeTransition(),
+ factory.MetaCode("{").Accepts(AcceptedCharacters.None),
+ factory.EmptyCSharp()
+ .AsStatement()
+ .AutoCompleteWith(autoCompleteString: null),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("foo.b")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.EmptyCSharp().AsStatement(),
+ factory.MetaCode("}").Accepts(AcceptedCharacters.None)),
+ factory.EmptyHtml()));
+ }
+
+ [Fact]
+ public void ImplicitExpressionAcceptsDotIfTrailingDotsAreAllowed()
+ {
+ var factory = new SpanFactory();
+ var changed = new StringTextBuffer("@{@foo.}");
+ var old = new StringTextBuffer("@{@foo}");
+ RunPartialParseTest(new TextChange(6, 0, old, 1, changed),
+ new MarkupBlock(
+ factory.EmptyHtml(),
+ new StatementBlock(
+ factory.CodeTransition(),
+ factory.MetaCode("{").Accepts(AcceptedCharacters.None),
+ factory.EmptyCSharp()
+ .AsStatement()
+ .AutoCompleteWith(autoCompleteString: null),
+ new ExpressionBlock(
+ factory.CodeTransition(),
+ factory.Code("foo.")
+ .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true)
+ .Accepts(AcceptedCharacters.NonWhiteSpace)),
+ factory.EmptyCSharp().AsStatement(),
+ factory.MetaCode("}").Accepts(AcceptedCharacters.None)),
+ factory.EmptyHtml()));
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfIfKeywordTyped()
+ {
+ RunTypeKeywordTest("if");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfDoKeywordTyped()
+ {
+ RunTypeKeywordTest("do");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfTryKeywordTyped()
+ {
+ RunTypeKeywordTest("try");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfForKeywordTyped()
+ {
+ RunTypeKeywordTest("for");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfForEachKeywordTyped()
+ {
+ RunTypeKeywordTest("foreach");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfWhileKeywordTyped()
+ {
+ RunTypeKeywordTest("while");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfSwitchKeywordTyped()
+ {
+ RunTypeKeywordTest("switch");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfLockKeywordTyped()
+ {
+ RunTypeKeywordTest("lock");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfUsingKeywordTyped()
+ {
+ RunTypeKeywordTest("using");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfSectionKeywordTyped()
+ {
+ RunTypeKeywordTest("section");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfInheritsKeywordTyped()
+ {
+ RunTypeKeywordTest("inherits");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfFunctionsKeywordTyped()
+ {
+ RunTypeKeywordTest("functions");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfNamespaceKeywordTyped()
+ {
+ RunTypeKeywordTest("namespace");
+ }
+
+ [Fact]
+ public void ImplicitExpressionCorrectlyTriggersReparseIfClassKeywordTyped()
+ {
+ RunTypeKeywordTest("class");
+ }
+
+ private static TextChange CreateInsertionChange(string initialText, int insertionLocation, string insertionText)
+ {
+ var changedText = initialText.Insert(insertionLocation, insertionText);
+
+ var original = new StringTextBuffer(initialText);
+ var changed = new StringTextBuffer(changedText);
+ return new TextChange(insertionLocation, 0, original, insertionText.Length, changed);
+ }
+
+ private static void RunFullReparseTest(TextChange change, PartialParseResult additionalFlags = (PartialParseResult)0)
+ {
+ // Arrange
+ using (var manager = CreateParserManager())
+ {
+ manager.InitializeWithDocument(change.OldBuffer);
+
+ // Act
+ var result = manager.CheckForStructureChangesAndWait(change);
+
+ // Assert
+ Assert.Equal(PartialParseResult.Rejected | additionalFlags, result);
+ Assert.Equal(2, manager.ParseCount);
+ }
+ }
+
+ private static void RunPartialParseTest(TextChange change, Block newTreeRoot, PartialParseResult additionalFlags = (PartialParseResult)0)
+ {
+ // Arrange
+ using (var manager = CreateParserManager())
+ {
+ manager.InitializeWithDocument(change.OldBuffer);
+
+ // Act
+ var result = manager.CheckForStructureChangesAndWait(change);
+
+ // Assert
+ Assert.Equal(PartialParseResult.Accepted | additionalFlags, result);
+ Assert.Equal(1, manager.ParseCount);
+ ParserTestBase.EvaluateParseTree(manager.Parser.CurrentSyntaxTree.Root, newTreeRoot);
+ }
+ }
+
+ private static TestParserManager CreateParserManager()
+ {
+ var parser = new RazorEditorParser(CreateTemplateEngine(), TestLinePragmaFileName);
+ return new TestParserManager(parser);
+ }
+
+ private static RazorTemplateEngine CreateTemplateEngine(
+ string path = TestLinePragmaFileName,
+ IEnumerable tagHelpers = null)
+ {
+ var engine = RazorEngine.CreateDesignTime(b =>
+ {
+ if (tagHelpers != null)
+ {
+ b.AddTagHelpers(tagHelpers);
+ }
+ });
+
+ // GetImports on RazorTemplateEngine will at least check that the item exists, so we need to pretend
+ // that it does.
+ var items = new List();
+ items.Add(new TestRazorProjectItem(path));
+
+ var project = new TestRazorProject(items);
+
+ var templateEngine = new RazorTemplateEngine(engine, project);
+ templateEngine.Options.DefaultImports = RazorSourceDocument.Create("@addTagHelper *, Test", "_TestImports.cshtml");
+ return templateEngine;
+ }
+
+ private static void RunTypeKeywordTest(string keyword)
+ {
+ var before = "@" + keyword.Substring(0, keyword.Length - 1);
+ var after = "@" + keyword;
+ var changed = new StringTextBuffer(after);
+ var old = new StringTextBuffer(before);
+ RunFullReparseTest(new TextChange(keyword.Length, 0, old, 1, changed), additionalFlags: PartialParseResult.SpanContextChanged);
+ }
+
+ private class TestParserManager : IDisposable
+ {
+ public int ParseCount;
+
+ private readonly ManualResetEventSlim _parserComplete;
+
+ public TestParserManager(RazorEditorParser parser)
+ {
+ _parserComplete = new ManualResetEventSlim();
+ ParseCount = 0;
+ Parser = parser;
+ parser.DocumentParseComplete += (sender, args) =>
+ {
+ Interlocked.Increment(ref ParseCount);
+ _parserComplete.Set();
+ };
+ }
+
+ public RazorEditorParser Parser { get; }
+
+ public void InitializeWithDocument(ITextBuffer startDocument)
+ {
+ CheckForStructureChangesAndWait(new TextChange(0, 0, new StringTextBuffer(string.Empty), startDocument.Length, startDocument));
+ }
+
+ public PartialParseResult CheckForStructureChangesAndWait(TextChange change)
+ {
+ var result = Parser.CheckForStructureChanges(change);
+ if (result.HasFlag(PartialParseResult.Rejected))
+ {
+ WaitForParse();
+ }
+ return result;
+ }
+
+ public void WaitForParse()
+ {
+ MiscUtils.DoWithTimeoutIfNotDebugging(_parserComplete.Wait); // Wait for the parse to finish
+ _parserComplete.Reset();
+ }
+
+ public void Dispose()
+ {
+ Parser.Dispose();
+ }
+ }
+ }
+}
diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/TestFiles/DesignTime/Simple.cshtml b/test/Microsoft.AspNetCore.Razor.Evolution.Test/TestFiles/DesignTime/Simple.cshtml
new file mode 100644
index 0000000000..b50db22f77
--- /dev/null
+++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/TestFiles/DesignTime/Simple.cshtml
@@ -0,0 +1,16 @@
+@{
+ string hello = "Hello, World";
+}
+
+
+
+ Simple Page
+
+
+ Simple Page
+ @hello
+
+ @foreach(char c in hello) {@c}
+
+
+
\ No newline at end of file
diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/TestFiles/DesignTime/Simple.txt b/test/Microsoft.AspNetCore.Razor.Evolution.Test/TestFiles/DesignTime/Simple.txt
new file mode 100644
index 0000000000..f563a2f78a
--- /dev/null
+++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/TestFiles/DesignTime/Simple.txt
@@ -0,0 +1,49 @@
+namespace Razor
+{
+ #line hidden
+ using System;
+ using System.Threading.Tasks;
+ public class Template
+ {
+ #pragma warning disable 219
+ private void __RazorDirectiveTokenHelpers__() {
+ ((System.Action)(() => {
+System.Object __typeHelper = "*, Test";
+ }
+ ))();
+ }
+ #pragma warning restore 219
+ private static System.Object __o = null;
+ #pragma warning disable 1998
+ public async override global::System.Threading.Tasks.Task ExecuteAsync()
+ {
+#line 1 "C:\This\Path\Is\Just\For\Line\Pragmas.cshtml"
+
+ string hello = "Hello, World";
+
+#line default
+#line hidden
+#line 11 "C:\This\Path\Is\Just\For\Line\Pragmas.cshtml"
+ __o = hello;
+
+#line default
+#line hidden
+#line 13 "C:\This\Path\Is\Just\For\Line\Pragmas.cshtml"
+ foreach(char c in hello) {
+
+#line default
+#line hidden
+#line 13 "C:\This\Path\Is\Just\For\Line\Pragmas.cshtml"
+ __o = c;
+
+#line default
+#line hidden
+#line 13 "C:\This\Path\Is\Just\For\Line\Pragmas.cshtml"
+ }
+
+#line default
+#line hidden
+ }
+ #pragma warning restore 1998
+ }
+}
diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/TestRazorProject.cs b/test/Microsoft.AspNetCore.Razor.Evolution.Test/TestRazorProject.cs
index c06c2ac667..146c5b184f 100644
--- a/test/Microsoft.AspNetCore.Razor.Evolution.Test/TestRazorProject.cs
+++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/TestRazorProject.cs
@@ -11,6 +11,11 @@ namespace Microsoft.AspNetCore.Razor.Evolution
{
private readonly Dictionary _lookup;
+ public TestRazorProject()
+ : this(new RazorProjectItem[0])
+ {
+ }
+
public TestRazorProject(IList items)
{
_lookup = items.ToDictionary(item => item.Path);