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);