diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTextViewRazorDocumentTrackerService.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTextViewRazorDocumentTrackerService.cs index 250081cb20..1d175017d1 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTextViewRazorDocumentTrackerService.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTextViewRazorDocumentTrackerService.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel.Composition; using Microsoft.CodeAnalysis; @@ -165,5 +166,12 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor return project.IsCapabilityMatch("DotNetCoreWeb"); } + + public static IEnumerable GetTextViews(ITextBuffer textBuffer) + { + // TODO: Extract text views from buffer + + return new[] { (ITextView)null }; + } } } diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/BackgroundParser.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/BackgroundParser.cs similarity index 84% rename from src/Microsoft.VisualStudio.LanguageServices.Razor/BackgroundParser.cs rename to src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/BackgroundParser.cs index b71f4d8814..b3b290af0a 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/BackgroundParser.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/BackgroundParser.cs @@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Legacy; using Microsoft.VisualStudio.Text; -namespace Microsoft.VisualStudio.LanguageServices.Razor +namespace Microsoft.VisualStudio.LanguageServices.Razor.Editor { internal class BackgroundParser : IDisposable { @@ -29,7 +29,7 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor /// /// Fired on the main thread. /// - public event EventHandler ResultsReady; + public event EventHandler ResultsReady; public bool IsIdle { @@ -62,46 +62,14 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor return _main.Lock(); } - protected virtual void OnResultsReady(DocumentParseCompleteEventArgs args) + protected virtual void OnResultsReady(DocumentStructureChangedEventArgs args) { - var handler = ResultsReady; - if (handler != null) + using (SynchronizeMainThreadState()) { - handler(this, args); + ResultsReady?.Invoke(this, args); } } - private static bool TreesAreDifferent(RazorSyntaxTree leftTree, RazorSyntaxTree rightTree, IEnumerable edits, CancellationToken cancelToken) - { - return TreesAreDifferent(leftTree.Root, rightTree.Root, edits.Select(edit => edit.Change), cancelToken); - } - - 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 (var 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 @@ -155,7 +123,7 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor SetThreadId(Thread.CurrentThread.ManagedThreadId); } - public event EventHandler ResultsReady; + public event EventHandler ResultsReady; public CancellationToken CancelToken { @@ -187,7 +155,8 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor public void QueueChange(Edit edit) { - EnsureOnThread(); + // Any thread can queue a change. + lock (_stateLock) { // CurrentParcel token source is not null ==> There's a parse underway @@ -217,7 +186,7 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor } } - public void ReturnParcel(DocumentParseCompleteEventArgs args) + public void ReturnParcel(DocumentStructureChangedEventArgs args) { lock (_stateLock) { @@ -307,7 +276,7 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor { try { - DocumentParseCompleteEventArgs args = null; + DocumentStructureChangedEventArgs args = null; using (var linkedCancel = CancellationTokenSource.CreateLinkedTokenSource(_shutdownToken, parcel.CancelToken)) { if (!linkedCancel.IsCancellationRequested) @@ -333,14 +302,12 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor // Clear discarded changes list _previouslyDiscarded = null; - var treeStructureChanged = _currentSyntaxTree == null || TreesAreDifferent(_currentSyntaxTree, results.GetSyntaxTree(), allEdits, parcel.CancelToken); _currentSyntaxTree = results.GetSyntaxTree(); // Build Arguments - args = new DocumentParseCompleteEventArgs( + args = new DocumentStructureChangedEventArgs( finalEdit.Change, finalEdit.Snapshot, - treeStructureChanged, results); } else @@ -416,4 +383,4 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor public ITextSnapshot Snapshot { get; set; } } } -} \ No newline at end of file +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/DocumentStructureChangedEventArgs.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/DocumentStructureChangedEventArgs.cs new file mode 100644 index 0000000000..c5ff0a249f --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/DocumentStructureChangedEventArgs.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.VisualStudio.Text; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Editor +{ + internal sealed class DocumentStructureChangedEventArgs : EventArgs + { + public DocumentStructureChangedEventArgs( + SourceChange change, + ITextSnapshot snapshot, + RazorCodeDocument codeDocument) + { + SourceChange = change; + Snapshot = snapshot; + CodeDocument = codeDocument; + } + + /// + /// The which triggered the re-parse. + /// + public SourceChange SourceChange { get; } + + /// + /// The text snapshot used in the re-parse. + /// + public ITextSnapshot Snapshot { get; } + + /// + /// The result of the parsing and code generation. + /// + public RazorCodeDocument CodeDocument { get; } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/ForegroundThreadAffinitizedObject.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/ForegroundThreadAffinitizedObject.cs new file mode 100644 index 0000000000..720fb6841c --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/ForegroundThreadAffinitizedObject.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Editor +{ + internal class ForegroundThreadAffinitizedObject + { + private readonly Thread _foregroundThread; + + public ForegroundThreadAffinitizedObject() + { + _foregroundThread = Thread.CurrentThread; + } + + public void AssertIsForeground() + { + if (Thread.CurrentThread != _foregroundThread) + { + throw new InvalidOperationException("Expected to be on the foreground thread and was not."); + } + } + + public void AssertIsBackground() + { + if (Thread.CurrentThread == _foregroundThread) + { + throw new InvalidOperationException("Expected to be on a background thread and was not."); + } + } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/RazorSyntaxTreePartialParser.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/RazorSyntaxTreePartialParser.cs new file mode 100644 index 0000000000..ade9568907 --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/RazorSyntaxTreePartialParser.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Legacy; +using Span = Microsoft.AspNetCore.Razor.Language.Legacy.Span; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Editor +{ + internal class RazorSyntaxTreePartialParser + { + private readonly RazorSyntaxTree _syntaxTree; + private Span _lastChangeOwner; + private bool _lastResultProvisional; + + public RazorSyntaxTreePartialParser(RazorSyntaxTree syntaxTree) + { + _syntaxTree = syntaxTree; + } + + public PartialParseResultInternal Parse(SourceChange change) + { + var result = GetPartialParseResult(change); + + // Remember if this was provisionally accepted for next partial parse. + _lastResultProvisional = (result & PartialParseResultInternal.Provisional) == PartialParseResultInternal.Provisional; + + return result; + } + + private PartialParseResultInternal GetPartialParseResult(SourceChange change) + { + var result = PartialParseResultInternal.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 & PartialParseResultInternal.Rejected) != PartialParseResultInternal.Rejected) + { + _lastChangeOwner.ReplaceWith(editResult.EditedSpan); + } + + return result; + } + + // Locate the span responsible for this change + _lastChangeOwner = _syntaxTree.Root.LocateOwner(change); + + if (_lastResultProvisional) + { + // Last change owner couldn't accept this, so we must do a full reparse + result = PartialParseResultInternal.Rejected; + } + else if (_lastChangeOwner != null) + { + var editResult = _lastChangeOwner.EditHandler.ApplyChange(_lastChangeOwner, change); + result = editResult.Result; + if ((editResult.Result & PartialParseResultInternal.Rejected) != PartialParseResultInternal.Rejected) + { + _lastChangeOwner.ReplaceWith(editResult.EditedSpan); + } + } + + return result; + } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/VisualStudioRazorParser.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/VisualStudioRazorParser.cs new file mode 100644 index 0000000000..94f7935395 --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/VisualStudioRazorParser.cs @@ -0,0 +1,181 @@ +// 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.Timers; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text; +using ITextBuffer = Microsoft.VisualStudio.Text.ITextBuffer; +using Timer = System.Timers.Timer; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Editor +{ + internal class VisualStudioRazorParser : IDisposable + { + // Internal for testing. + internal readonly ITextBuffer _textBuffer; + internal readonly Timer _idleTimer; + + private const int IdleDelay = 3000; + private readonly ICompletionBroker _completionBroker; + private readonly BackgroundParser _parser; + private readonly ForegroundThreadAffinitizedObject _foregroundThreadAffinitizedObject; + private RazorSyntaxTreePartialParser _partialParser; + + public VisualStudioRazorParser(ITextBuffer buffer, RazorTemplateEngine templateEngine, string filePath, ICompletionBroker completionBroker) + { + if (buffer == null) + { + throw new ArgumentNullException(nameof(buffer)); + } + + if (templateEngine == null) + { + throw new ArgumentNullException(nameof(templateEngine)); + } + + if (string.IsNullOrEmpty(filePath)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(filePath)); + } + + if (completionBroker == null) + { + throw new ArgumentNullException(nameof(completionBroker)); + } + + TemplateEngine = templateEngine; + FilePath = filePath; + _textBuffer = buffer; + _completionBroker = completionBroker; + _textBuffer.Changed += TextBuffer_OnChanged; + _parser = new BackgroundParser(templateEngine, filePath); + _idleTimer = new Timer(IdleDelay); + _idleTimer.Elapsed += Onidle; + _parser.ResultsReady += OnResultsReady; + _foregroundThreadAffinitizedObject = new ForegroundThreadAffinitizedObject(); + + _parser.Start(); + } + + public event EventHandler DocumentStructureChanged; + + public RazorTemplateEngine TemplateEngine { get; } + + public string FilePath { get; } + + public RazorCodeDocument CodeDocument { get; private set; } + + public ITextSnapshot Snapshot { get; private set; } + + public void Reparse() + { + // Can be called from any thread + var snapshot = _textBuffer.CurrentSnapshot; + _parser.QueueChange(null, snapshot); + } + + public void Dispose() + { + _foregroundThreadAffinitizedObject.AssertIsForeground(); + + _textBuffer.Changed -= TextBuffer_OnChanged; + _parser.Dispose(); + _idleTimer.Dispose(); + } + + private void TextBuffer_OnChanged(object sender, TextContentChangedEventArgs contentChange) + { + _foregroundThreadAffinitizedObject.AssertIsForeground(); + + if (contentChange.Changes.Count > 0) + { + // Idle timers are used to track provisional changes. Provisional changes only last for a single text change. After that normal + // partial parsing rules apply (stop the timer). + _idleTimer.Stop(); + + + var firstChange = contentChange.Changes[0]; + var lastChange = contentChange.Changes[contentChange.Changes.Count - 1]; + + var oldLen = lastChange.OldEnd - firstChange.OldPosition; + var newLen = lastChange.NewEnd - firstChange.NewPosition; + + var wasChanged = true; + if (oldLen == newLen) + { + var oldText = contentChange.Before.GetText(firstChange.OldPosition, oldLen); + var newText = contentChange.After.GetText(firstChange.NewPosition, newLen); + wasChanged = !string.Equals(oldText, newText, StringComparison.Ordinal); + } + + if (wasChanged) + { + var newText = contentChange.After.GetText(firstChange.NewPosition, newLen); + var change = new SourceChange(firstChange.OldPosition, oldLen, newText); + var snapshot = contentChange.After; + var result = PartialParseResultInternal.Rejected; + + using (_parser.SynchronizeMainThreadState()) + { + // Check if we can partial-parse + if (_partialParser != null && _parser.IsIdle) + { + result = _partialParser.Parse(change); + } + } + + // If partial parsing failed or there were outstanding parser tasks, start a full reparse + if ((result & PartialParseResultInternal.Rejected) == PartialParseResultInternal.Rejected) + { + _parser.QueueChange(change, snapshot); + } + + if ((result & PartialParseResultInternal.Provisional) == PartialParseResultInternal.Provisional) + { + _idleTimer.Start(); + } + } + } + } + + private void Onidle(object sender, ElapsedEventArgs e) + { + _foregroundThreadAffinitizedObject.AssertIsBackground(); + + var textViews = DefaultTextViewRazorDocumentTrackerService.GetTextViews(_textBuffer); + + foreach (var textView in textViews) + { + if (_completionBroker.IsCompletionActive(textView)) + { + return; + } + } + + _idleTimer.Stop(); + Reparse(); + } + + private void OnResultsReady(object sender, DocumentStructureChangedEventArgs args) + { + _foregroundThreadAffinitizedObject.AssertIsBackground(); + + if (DocumentStructureChanged != null) + { + if (args.Snapshot != _textBuffer.CurrentSnapshot) + { + // A different text change is being parsed. + return; + } + + CodeDocument = args.CodeDocument; + Snapshot = args.Snapshot; + _partialParser = new RazorSyntaxTreePartialParser(CodeDocument.GetSyntaxTree()); + DocumentStructureChanged(this, args); + } + } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/DocumentParseCompleteEventArgs.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/Legacy/DocumentParseCompleteEventArgs.cs similarity index 100% rename from src/Microsoft.VisualStudio.LanguageServices.Razor/DocumentParseCompleteEventArgs.cs rename to src/Microsoft.VisualStudio.LanguageServices.Razor/Legacy/DocumentParseCompleteEventArgs.cs diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/Legacy/RazorEditorParser.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/Legacy/RazorEditorParser.cs new file mode 100644 index 0000000000..58757df9ef --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/Legacy/RazorEditorParser.cs @@ -0,0 +1,598 @@ +// 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; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.VisualStudio.Text; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + public class RazorEditorParser : IDisposable + { + private AspNetCore.Razor.Language.Legacy.Span _lastChangeOwner; + private AspNetCore.Razor.Language.Legacy.Span _lastAutoCompleteSpan; + private BackgroundParser _parser; + + public RazorEditorParser(RazorTemplateEngine templateEngine, string filePath) + { + if (templateEngine == null) + { + throw new ArgumentNullException(nameof(templateEngine)); + } + + if (string.IsNullOrEmpty(filePath)) + { + throw new ArgumentException( + AspNetCore.Razor.Language.Resources.ArgumentCannotBeNullOrEmpty, + nameof(filePath)); + } + + TemplateEngine = templateEngine; + FilePath = filePath; + _parser = new BackgroundParser(templateEngine, filePath); + _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; } + + public string FilePath { get; } + + // Internal for testing. + internal RazorSyntaxTree CurrentSyntaxTree { get; private set; } + + // Internal for testing. + internal bool LastResultProvisional { get; private set; } + + public virtual string GetAutoCompleteString() + { + if (_lastAutoCompleteSpan?.EditHandler is AutoCompleteEditHandler editHandler) + { + return editHandler.AutoCompleteString; + } + + return null; + } + + public virtual PartialParseResult CheckForStructureChanges(SourceChange change, ITextSnapshot snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + var result = PartialParseResultInternal.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 & PartialParseResultInternal.Rejected) == PartialParseResultInternal.Rejected) + { + _parser.QueueChange(change, snapshot); + } + + // Otherwise, remember if this was provisionally accepted for next partial parse + LastResultProvisional = (result & PartialParseResultInternal.Provisional) == PartialParseResultInternal.Provisional; + VerifyFlagsAreValid(result); + + return (PartialParseResult)result; + } + + /// + /// Disposes of this parser. Should be called when the editor window is closed and the document is unloaded. + /// + public void Dispose() + { + _parser.Dispose(); + GC.SuppressFinalize(this); + } + + private PartialParseResultInternal TryPartialParse(SourceChange change) + { + var result = PartialParseResultInternal.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 & PartialParseResultInternal.Rejected) != PartialParseResultInternal.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 = PartialParseResultInternal.Rejected; + } + else if (_lastChangeOwner != null) + { + var editResult = _lastChangeOwner.EditHandler.ApplyChange(_lastChangeOwner, change); + result = editResult.Result; + if ((editResult.Result & PartialParseResultInternal.Rejected) != PartialParseResultInternal.Rejected) + { + _lastChangeOwner.ReplaceWith(editResult.EditedSpan); + } + if ((result & PartialParseResultInternal.AutoCompleteBlock) == PartialParseResultInternal.AutoCompleteBlock) + { + _lastAutoCompleteSpan = _lastChangeOwner; + } + else + { + _lastAutoCompleteSpan = null; + } + } + + return result; + } + + private void OnDocumentParseComplete(DocumentParseCompleteEventArgs args) + { + using (_parser.SynchronizeMainThreadState()) + { + CurrentSyntaxTree = args.CodeDocument.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(PartialParseResultInternal result) + { + Debug.Assert(((result & PartialParseResultInternal.Accepted) == PartialParseResultInternal.Accepted) || + ((result & PartialParseResultInternal.Rejected) == PartialParseResultInternal.Rejected), + "Partial Parse result does not have either of Accepted or Rejected flags set"); + Debug.Assert(((result & PartialParseResultInternal.Rejected) == PartialParseResultInternal.Rejected) || + ((result & PartialParseResultInternal.SpanContextChanged) != PartialParseResultInternal.SpanContextChanged), + "Partial Parse result was Accepted AND had SpanContextChanged flag set"); + Debug.Assert(((result & PartialParseResultInternal.Rejected) == PartialParseResultInternal.Rejected) || + ((result & PartialParseResultInternal.AutoCompleteBlock) != PartialParseResultInternal.AutoCompleteBlock), + "Partial Parse result was Accepted AND had AutoCompleteBlock flag set"); + Debug.Assert(((result & PartialParseResultInternal.Accepted) == PartialParseResultInternal.Accepted) || + ((result & PartialParseResultInternal.Provisional) != PartialParseResultInternal.Provisional), + "Partial Parse result was Rejected AND had Provisional flag set"); + } + + internal class BackgroundParser : IDisposable + { + private MainThreadState _main; + private BackgroundThread _bg; + + public BackgroundParser(RazorTemplateEngine templateEngine, string filePath) + { + _main = new MainThreadState(filePath); + _bg = new BackgroundThread(_main, templateEngine, filePath); + + _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(SourceChange change, ITextSnapshot snapshot) + { + var edit = new Edit(change, snapshot); + _main.QueueChange(edit); + } + + 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); + } + } + + private static bool TreesAreDifferent(RazorSyntaxTree leftTree, RazorSyntaxTree rightTree, IEnumerable edits, CancellationToken cancelToken) + { + return TreesAreDifferent(leftTree.Root, rightTree.Root, edits.Select(edit => edit.Change), cancelToken); + } + + 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 (var 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(Edit edit) + { + EnsureOnThread(); + lock (_stateLock) + { + // CurrentParcel token source is not null ==> There's a parse underway + if (_currentParcelCancelSource != null) + { + _currentParcelCancelSource.Cancel(); + } + + _changes.Add(edit); + _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 _filePath; + 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; + _filePath = fileName; + + _backgroundThread = new Thread(WorkerLoop); + SetThreadId(_backgroundThread.ManagedThreadId); + } + + // **** ANY THREAD **** + public void Start() + { + _backgroundThread.Start(); + } + + // **** BACKGROUND THREAD **** + private void WorkerLoop() + { + var fileNameOnly = Path.GetFileName(_filePath); + + try + { + EnsureOnThread(); + + while (!_shutdownToken.IsCancellationRequested) + { + // Grab the parcel of work to do + var parcel = _main.GetParcel(); + if (parcel.Edits.Any()) + { + try + { + DocumentParseCompleteEventArgs args = null; + using (var linkedCancel = CancellationTokenSource.CreateLinkedTokenSource(_shutdownToken, parcel.CancelToken)) + { + if (!linkedCancel.IsCancellationRequested) + { + // Collect ALL changes + List allEdits; + + if (_previouslyDiscarded != null) + { + allEdits = Enumerable.Concat(_previouslyDiscarded, parcel.Edits).ToList(); + } + else + { + allEdits = parcel.Edits.ToList(); + } + + var finalEdit = allEdits.Last(); + + var results = ParseChange(finalEdit.Snapshot, linkedCancel.Token); + + if (results != null && !linkedCancel.IsCancellationRequested) + { + // Clear discarded changes list + _previouslyDiscarded = null; + + var treeStructureChanged = _currentSyntaxTree == null || TreesAreDifferent(_currentSyntaxTree, results.GetSyntaxTree(), allEdits, parcel.CancelToken); + _currentSyntaxTree = results.GetSyntaxTree(); + + // Build Arguments + args = new DocumentParseCompleteEventArgs( + finalEdit.Change, + finalEdit.Snapshot, + treeStructureChanged, + results); + } + else + { + // Parse completed but we were cancelled in the mean time. Add these to the discarded changes set + _previouslyDiscarded = allEdits; + } + } + } + if (args != null) + { + _main.ReturnParcel(args); + } + } + catch (OperationCanceledException) + { + } + } + else + { + Thread.Yield(); + } + } + } + catch (OperationCanceledException) + { + // Do nothing. Just shut down. + } + finally + { + // Clean up main thread resources + _main.Dispose(); + } + } + + private RazorCodeDocument ParseChange(ITextSnapshot snapshot, CancellationToken token) + { + EnsureOnThread(); + + var sourceDocument = new TextSnapshotSourceDocument(snapshot, _filePath); + var imports = _templateEngine.GetImports(_filePath); + + var codeDocument = RazorCodeDocument.Create(sourceDocument, imports); + + _templateEngine.GenerateCode(codeDocument); + return codeDocument; + } + } + + private class WorkParcel + { + public WorkParcel(IList changes, CancellationToken cancelToken) + { + Edits = changes; + CancelToken = cancelToken; + } + + public CancellationToken CancelToken { get; } + + public IList Edits { get; } + } + + private class Edit + { + public Edit(SourceChange change, ITextSnapshot snapshot) + { + Change = change; + Snapshot = snapshot; + } + + public SourceChange Change { get; } + + public ITextSnapshot Snapshot { get; set; } + } + } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/Microsoft.VisualStudio.LanguageServices.Razor.csproj b/src/Microsoft.VisualStudio.LanguageServices.Razor/Microsoft.VisualStudio.LanguageServices.Razor.csproj index c351759d2d..65f6f98421 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/Microsoft.VisualStudio.LanguageServices.Razor.csproj +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/Microsoft.VisualStudio.LanguageServices.Razor.csproj @@ -37,6 +37,7 @@ + diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/Properties/Resources.Designer.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/Properties/Resources.Designer.cs index 41d599a8d9..2237686c54 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/Properties/Resources.Designer.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/Properties/Resources.Designer.cs @@ -10,6 +10,20 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor private static readonly ResourceManager _resourceManager = new ResourceManager("Microsoft.VisualStudio.LanguageServices.Razor.Resources", typeof(Resources).GetTypeInfo().Assembly); + /// + /// Value cannot be null or an empty string. + /// + internal static string ArgumentCannotBeNullOrEmpty + { + get => GetString("ArgumentCannotBeNullOrEmpty"); + } + + /// + /// Value cannot be null or an empty string. + /// + internal static string FormatArgumentCannotBeNullOrEmpty() + => GetString("ArgumentCannotBeNullOrEmpty"); + /// /// An unexpected exception occurred when invoking '{0}.{1}' on the Razor language service. /// diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/RazorEditorParser.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/RazorEditorParser.cs deleted file mode 100644 index 4b4a4efe06..0000000000 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/RazorEditorParser.cs +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Diagnostics; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.Language.Legacy; -using Microsoft.VisualStudio.Text; - -namespace Microsoft.VisualStudio.LanguageServices.Razor -{ - public class RazorEditorParser : IDisposable - { - private AspNetCore.Razor.Language.Legacy.Span _lastChangeOwner; - private AspNetCore.Razor.Language.Legacy.Span _lastAutoCompleteSpan; - private BackgroundParser _parser; - - public RazorEditorParser(RazorTemplateEngine templateEngine, string filePath) - { - if (templateEngine == null) - { - throw new ArgumentNullException(nameof(templateEngine)); - } - - if (string.IsNullOrEmpty(filePath)) - { - throw new ArgumentException( - AspNetCore.Razor.Language.Resources.ArgumentCannotBeNullOrEmpty, - nameof(filePath)); - } - - TemplateEngine = templateEngine; - FilePath = filePath; - _parser = new BackgroundParser(templateEngine, filePath); - _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; } - - public string FilePath { get; } - - // Internal for testing. - internal RazorSyntaxTree CurrentSyntaxTree { get; private set; } - - // Internal for testing. - internal bool LastResultProvisional { get; private set; } - - public virtual string GetAutoCompleteString() - { - if (_lastAutoCompleteSpan?.EditHandler is AutoCompleteEditHandler editHandler) - { - return editHandler.AutoCompleteString; - } - - return null; - } - - public virtual PartialParseResult CheckForStructureChanges(SourceChange change, ITextSnapshot snapshot) - { - if (snapshot == null) - { - throw new ArgumentNullException(nameof(snapshot)); - } - - var result = PartialParseResultInternal.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 & PartialParseResultInternal.Rejected) == PartialParseResultInternal.Rejected) - { - _parser.QueueChange(change, snapshot); - } - - // Otherwise, remember if this was provisionally accepted for next partial parse - LastResultProvisional = (result & PartialParseResultInternal.Provisional) == PartialParseResultInternal.Provisional; - VerifyFlagsAreValid(result); - - return (PartialParseResult)result; - } - - /// - /// Disposes of this parser. Should be called when the editor window is closed and the document is unloaded. - /// - public void Dispose() - { - _parser.Dispose(); - GC.SuppressFinalize(this); - } - - private PartialParseResultInternal TryPartialParse(SourceChange change) - { - var result = PartialParseResultInternal.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 & PartialParseResultInternal.Rejected) != PartialParseResultInternal.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 = PartialParseResultInternal.Rejected; - } - else if (_lastChangeOwner != null) - { - var editResult = _lastChangeOwner.EditHandler.ApplyChange(_lastChangeOwner, change); - result = editResult.Result; - if ((editResult.Result & PartialParseResultInternal.Rejected) != PartialParseResultInternal.Rejected) - { - _lastChangeOwner.ReplaceWith(editResult.EditedSpan); - } - if ((result & PartialParseResultInternal.AutoCompleteBlock) == PartialParseResultInternal.AutoCompleteBlock) - { - _lastAutoCompleteSpan = _lastChangeOwner; - } - else - { - _lastAutoCompleteSpan = null; - } - } - - return result; - } - - private void OnDocumentParseComplete(DocumentParseCompleteEventArgs args) - { - using (_parser.SynchronizeMainThreadState()) - { - CurrentSyntaxTree = args.CodeDocument.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(PartialParseResultInternal result) - { - Debug.Assert(((result & PartialParseResultInternal.Accepted) == PartialParseResultInternal.Accepted) || - ((result & PartialParseResultInternal.Rejected) == PartialParseResultInternal.Rejected), - "Partial Parse result does not have either of Accepted or Rejected flags set"); - Debug.Assert(((result & PartialParseResultInternal.Rejected) == PartialParseResultInternal.Rejected) || - ((result & PartialParseResultInternal.SpanContextChanged) != PartialParseResultInternal.SpanContextChanged), - "Partial Parse result was Accepted AND had SpanContextChanged flag set"); - Debug.Assert(((result & PartialParseResultInternal.Rejected) == PartialParseResultInternal.Rejected) || - ((result & PartialParseResultInternal.AutoCompleteBlock) != PartialParseResultInternal.AutoCompleteBlock), - "Partial Parse result was Accepted AND had AutoCompleteBlock flag set"); - Debug.Assert(((result & PartialParseResultInternal.Accepted) == PartialParseResultInternal.Accepted) || - ((result & PartialParseResultInternal.Provisional) != PartialParseResultInternal.Provisional), - "Partial Parse result was Rejected AND had Provisional flag set"); - } - } -} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/Resources.resx b/src/Microsoft.VisualStudio.LanguageServices.Razor/Resources.resx index 6783beb144..87f559339f 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/Resources.resx +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/Resources.resx @@ -117,6 +117,9 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Value cannot be null or an empty string. + An unexpected exception occurred when invoking '{0}.{1}' on the Razor language service. diff --git a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Editor/RazorSyntaxTreePartialParserTest.cs b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Editor/RazorSyntaxTreePartialParserTest.cs new file mode 100644 index 0000000000..aa11e1dbf2 --- /dev/null +++ b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Editor/RazorSyntaxTreePartialParserTest.cs @@ -0,0 +1,765 @@ +// 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 Microsoft.AspNetCore.Mvc.Razor.Extensions; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.VisualStudio.Text; +using Xunit; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Editor +{ + public class RazorSyntaxTreePartialParserTest + { + 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, + attributeStructure: AttributeStructure.Minimized) + })) + }, + { + CreateInsertionChange("

", 12, "ibute"), + new MarkupBlock( + new MarkupTagHelperBlock( + "p", + attributes: new List + { + new TagHelperAttributeNode( + "some-attribute", + value: null, + attributeStructure: AttributeStructure.Minimized) + })) + }, + { + CreateInsertionChange("

", 2, " before"), + new MarkupBlock( + new MarkupTagHelperBlock( + "p", + attributes: new List + { + new TagHelperAttributeNode( + "before", + value: null, + attributeStructure: AttributeStructure.Minimized), + new TagHelperAttributeNode( + "some-attr", + value: null, + attributeStructure: AttributeStructure.Minimized) + })) + }, + }; + } + } + + [Theory] + [MemberData(nameof(TagHelperPartialParseRejectData))] + public void TagHelperTagBodiesRejectPartialChanges(object objectEdit, object expectedDocument) + { + // Arrange + var edit = (TestEdit)objectEdit; + var builder = TagHelperDescriptorBuilder.Create("PTagHelper", "TestAssembly"); + builder.SetTypeName("PTagHelper"); + builder.TagMatchingRule(rule => rule.TagName = "p"); + var descriptors = new[] + { + builder.Build() + }; + var templateEngine = CreateTemplateEngine(tagHelpers: descriptors); + var document = TestRazorCodeDocument.Create( + TestRazorSourceDocument.Create(edit.OldSnapshot.GetText()), + new[] { templateEngine.Options.DefaultImports }); + templateEngine.Engine.Process(document); + var syntaxTree = document.GetSyntaxTree(); + var parser = new RazorSyntaxTreePartialParser(syntaxTree); + + // Act + var result = parser.Parse(edit.Change); + + // Assert + Assert.Equal(PartialParseResultInternal.Rejected, result); + } + + 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(AcceptedCharactersInternal.NonWhiteSpace)))), + AttributeStructure.SingleQuotes) + })), + PartialParseResultInternal.Accepted | PartialParseResultInternal.Provisional + }, + { + CreateInsertionChange("

", 21, "."), + new MarkupBlock( + new MarkupTagHelperBlock( + "p", + attributes: new List + { + new TagHelperAttributeNode( + "obj-attr", + factory.CodeMarkup("DateTime."), + AttributeStructure.SingleQuotes) + })), + PartialParseResultInternal.Accepted + }, + { + CreateInsertionChange("

", 25, "."), + new MarkupBlock( + new MarkupTagHelperBlock( + "p", + attributes: new List + { + new TagHelperAttributeNode( + "obj-attr", + factory.CodeMarkup("1 + DateTime."), + AttributeStructure.SingleQuotes) + })), + PartialParseResultInternal.Accepted + }, + { + CreateInsertionChange("

", 34, "."), + new MarkupBlock( + new MarkupTagHelperBlock( + "p", + attributes: new List + { + new TagHelperAttributeNode( + "before-attr", + value: null, + attributeStructure: AttributeStructure.Minimized), + new TagHelperAttributeNode( + "str-attr", + new MarkupBlock( + new MarkupBlock( + new ExpressionBlock( + factory.CodeTransition(), + factory + .Code("DateTime.") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)))), + AttributeStructure.SingleQuotes), + new TagHelperAttributeNode( + "after-attr", + value: null, + attributeStructure: AttributeStructure.Minimized), + })), + PartialParseResultInternal.Accepted | PartialParseResultInternal.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(AcceptedCharactersInternal.NonWhiteSpace))), + factory.Markup(" after")), + AttributeStructure.SingleQuotes) + })), + PartialParseResultInternal.Accepted | PartialParseResultInternal.Provisional + }, + }; + } + } + + [Theory] + [MemberData(nameof(TagHelperAttributeAcceptData))] + public void TagHelperAttributesAreLocatedAndAcceptChangesCorrectly( + object editObject, + object expectedDocument, + object partialParseResultObject) + { + // Arrange + var edit = (TestEdit)editObject; + var partialParseResult = (PartialParseResultInternal)partialParseResultObject; + var builder = TagHelperDescriptorBuilder.Create("PTagHelper", "Test"); + builder.SetTypeName("PTagHelper"); + builder.TagMatchingRule(rule => rule.TagName = "p"); + builder.BindAttribute(attribute => + { + attribute.Name = "obj-attr"; + attribute.TypeName = typeof(object).FullName; + attribute.SetPropertyName("ObjectAttribute"); + }); + builder.BindAttribute(attribute => + { + attribute.Name = "str-attr"; + attribute.TypeName = typeof(string).FullName; + attribute.SetPropertyName("StringAttribute"); + }); + var descriptors = new[] { builder.Build() }; + var templateEngine = CreateTemplateEngine(tagHelpers: descriptors); + var document = TestRazorCodeDocument.Create( + TestRazorSourceDocument.Create(edit.OldSnapshot.GetText()), + new[] { templateEngine.Options.DefaultImports }); + templateEngine.Engine.Process(document); + var syntaxTree = document.GetSyntaxTree(); + var parser = new RazorSyntaxTreePartialParser(syntaxTree); + + // Act + var result = parser.Parse(edit.Change); + + // Assert + Assert.Equal(partialParseResult, result); + } + + [Fact] + public void ImplicitExpressionAcceptsInnerInsertionsInStatementBlock() + { + // Arrange + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("@{" + Environment.NewLine + + " @DateTime..Now" + Environment.NewLine + + "}"); + var old = new StringTextSnapshot("@{" + Environment.NewLine + + " @DateTime.Now" + Environment.NewLine + + "}"); + + // Act and Assert + RunPartialParseTest(new TestEdit(17, 0, old, 1, changed, "."), + new MarkupBlock( + factory.EmptyHtml(), + new StatementBlock( + factory.CodeTransition(), + factory.MetaCode("{").Accepts(AcceptedCharactersInternal.None), + factory.Code(Environment.NewLine + " ") + .AsStatement() + .AutoCompleteWith(autoCompleteString: null), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("DateTime..Now") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Code(Environment.NewLine).AsStatement(), + factory.MetaCode("}").Accepts(AcceptedCharactersInternal.None)), + factory.EmptyHtml())); + } + + [Fact] + public void ImplicitExpressionAcceptsInnerInsertions() + { + // Arrange + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("foo @DateTime..Now baz"); + var old = new StringTextSnapshot("foo @DateTime.Now baz"); + + // Act and Assert + RunPartialParseTest(new TestEdit(13, 0, old, 1, changed, "."), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("DateTime..Now").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz")), additionalFlags: PartialParseResultInternal.Provisional); + } + + [Fact] + public void ImplicitExpressionAcceptsWholeIdentifierReplacement() + { + // Arrange + var factory = new SpanFactory(); + var old = new StringTextSnapshot("foo @date baz"); + var changed = new StringTextSnapshot("foo @DateTime baz"); + + // Act and Assert + RunPartialParseTest(new TestEdit(5, 4, old, 8, changed, "DateTime"), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("DateTime").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz"))); + } + + [Fact] + public void ImplicitExpressionRejectsWholeIdentifierReplacementToKeyword() + { + // Arrange + var old = new StringTextSnapshot("foo @date baz"); + var changed = new StringTextSnapshot("foo @if baz"); + var edit = new TestEdit(5, 4, old, 2, changed, "if"); + + // Act & Assert + RunPartialParseRejectionTest(edit); + } + + [Fact] + public void ImplicitExpressionRejectsWholeIdentifierReplacementToDirective() + { + // Arrange + var old = new StringTextSnapshot("foo @date baz"); + var changed = new StringTextSnapshot("foo @inherits baz"); + var edit = new TestEdit(5, 4, old, 8, changed, "inherits"); + + // Act & Assert + RunPartialParseRejectionTest(edit, PartialParseResultInternal.SpanContextChanged); + } + + [Fact] + public void ImplicitExpressionAcceptsPrefixIdentifierReplacements_SingleSymbol() + { + // Arrange + var factory = new SpanFactory(); + var old = new StringTextSnapshot("foo @dTime baz"); + var changed = new StringTextSnapshot("foo @DateTime baz"); + + // Act and Assert + RunPartialParseTest(new TestEdit(5, 1, old, 4, changed, "Date"), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("DateTime").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz"))); + } + + [Fact] + public void ImplicitExpressionAcceptsPrefixIdentifierReplacements_MultipleSymbols() + { + // Arrange + var factory = new SpanFactory(); + var old = new StringTextSnapshot("foo @dTime.Now baz"); + var changed = new StringTextSnapshot("foo @DateTime.Now baz"); + + // Act and Assert + RunPartialParseTest(new TestEdit(5, 1, old, 4, changed, "Date"), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("DateTime.Now").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz"))); + } + + [Fact] + public void ImplicitExpressionAcceptsSuffixIdentifierReplacements_SingleSymbol() + { + // Arrange + var factory = new SpanFactory(); + var old = new StringTextSnapshot("foo @Datet baz"); + var changed = new StringTextSnapshot("foo @DateTime baz"); + + // Act and Assert + RunPartialParseTest(new TestEdit(9, 1, old, 4, changed, "Time"), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("DateTime").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz"))); + } + + [Fact] + public void ImplicitExpressionAcceptsSuffixIdentifierReplacements_MultipleSymbols() + { + // Arrange + var factory = new SpanFactory(); + var old = new StringTextSnapshot("foo @DateTime.n baz"); + var changed = new StringTextSnapshot("foo @DateTime.Now baz"); + + // Act and Assert + RunPartialParseTest(new TestEdit(14, 1, old, 3, changed, "Now"), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("DateTime.Now").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz"))); + } + + [Fact] + public void ImplicitExpressionAcceptsSurroundedIdentifierReplacements() + { + // Arrange + var factory = new SpanFactory(); + var old = new StringTextSnapshot("foo @DateTime.n.ToString() baz"); + var changed = new StringTextSnapshot("foo @DateTime.Now.ToString() baz"); + + // Act and Assert + RunPartialParseTest(new TestEdit(14, 1, old, 3, changed, "Now"), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("DateTime.Now.ToString()").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz"))); + } + + [Fact] + public void ImplicitExpressionProvisionallyAcceptsDeleteOfIdentifierPartsIfDotRemains() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("foo @User. baz"); + var old = new StringTextSnapshot("foo @User.Name baz"); + RunPartialParseTest(new TestEdit(10, 4, old, 0, changed, string.Empty), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("User.").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz")), + additionalFlags: PartialParseResultInternal.Provisional); + } + + [Fact] + public void ImplicitExpressionAcceptsDeleteOfIdentifierPartsIfSomeOfIdentifierRemains() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("foo @Us baz"); + var old = new StringTextSnapshot("foo @User baz"); + RunPartialParseTest(new TestEdit(7, 2, old, 0, changed, string.Empty), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("Us").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz"))); + } + + [Fact] + public void ImplicitExpressionProvisionallyAcceptsMultipleInsertionIfItCausesIdentifierExpansionAndTrailingDot() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("foo @User. baz"); + var old = new StringTextSnapshot("foo @U baz"); + RunPartialParseTest(new TestEdit(6, 0, old, 4, changed, "ser."), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("User.").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz")), + additionalFlags: PartialParseResultInternal.Provisional); + } + + [Fact] + public void ImplicitExpressionAcceptsMultipleInsertionIfItOnlyCausesIdentifierExpansion() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("foo @barbiz baz"); + var old = new StringTextSnapshot("foo @bar baz"); + RunPartialParseTest(new TestEdit(8, 0, old, 3, changed, "biz"), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("barbiz").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz"))); + } + + [Fact] + public void ImplicitExpressionAcceptsIdentifierExpansionAtEndOfNonWhitespaceCharacters() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("@{" + Environment.NewLine + + " @food" + Environment.NewLine + + "}"); + var old = new StringTextSnapshot("@{" + Environment.NewLine + + " @foo" + Environment.NewLine + + "}"); + RunPartialParseTest(new TestEdit(10 + Environment.NewLine.Length, 0, old, 1, changed, "d"), + new MarkupBlock( + factory.EmptyHtml(), + new StatementBlock( + factory.CodeTransition(), + factory.MetaCode("{").Accepts(AcceptedCharactersInternal.None), + factory.Code(Environment.NewLine + " ") + .AsStatement() + .AutoCompleteWith(autoCompleteString: null), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("food") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Code(Environment.NewLine).AsStatement(), + factory.MetaCode("}").Accepts(AcceptedCharactersInternal.None)), + factory.EmptyHtml())); + } + + [Fact] + public void ImplicitExpressionAcceptsIdentifierAfterDotAtEndOfNonWhitespaceCharacters() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("@{" + Environment.NewLine + + " @foo.d" + Environment.NewLine + + "}"); + var old = new StringTextSnapshot("@{" + Environment.NewLine + + " @foo." + Environment.NewLine + + "}"); + RunPartialParseTest(new TestEdit(11 + Environment.NewLine.Length, 0, old, 1, changed, "d"), + new MarkupBlock( + factory.EmptyHtml(), + new StatementBlock( + factory.CodeTransition(), + factory.MetaCode("{").Accepts(AcceptedCharactersInternal.None), + factory.Code(Environment.NewLine + " ") + .AsStatement() + .AutoCompleteWith(autoCompleteString: null), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("foo.d") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Code(Environment.NewLine).AsStatement(), + factory.MetaCode("}").Accepts(AcceptedCharactersInternal.None)), + factory.EmptyHtml())); + } + + [Fact] + public void ImplicitExpressionAcceptsDotAtEndOfNonWhitespaceCharacters() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("@{" + Environment.NewLine + + " @foo." + Environment.NewLine + + "}"); + var old = new StringTextSnapshot("@{" + Environment.NewLine + + " @foo" + Environment.NewLine + + "}"); + RunPartialParseTest(new TestEdit(10 + Environment.NewLine.Length, 0, old, 1, changed, "."), + new MarkupBlock( + factory.EmptyHtml(), + new StatementBlock( + factory.CodeTransition(), + factory.MetaCode("{").Accepts(AcceptedCharactersInternal.None), + factory.Code(Environment.NewLine + " ") + .AsStatement() + .AutoCompleteWith(autoCompleteString: null), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code(@"foo.") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Code(Environment.NewLine).AsStatement(), + factory.MetaCode("}").Accepts(AcceptedCharactersInternal.None)), + factory.EmptyHtml())); + } + + [Fact] + public void ImplicitExpressionProvisionallyAcceptsDotAfterIdentifierInMarkup() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("foo @foo. bar"); + var old = new StringTextSnapshot("foo @foo bar"); + RunPartialParseTest(new TestEdit(8, 0, old, 1, changed, "."), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("foo.") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" bar")), + additionalFlags: PartialParseResultInternal.Provisional); + } + + [Fact] + public void ImplicitExpressionAcceptsAdditionalIdentifierCharactersIfEndOfSpanIsIdentifier() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("foo @foob bar"); + var old = new StringTextSnapshot("foo @foo bar"); + RunPartialParseTest(new TestEdit(8, 0, old, 1, changed, "b"), + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("foob") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" bar"))); + } + + [Fact] + public void ImplicitExpressionAcceptsAdditionalIdentifierStartCharactersIfEndOfSpanIsDot() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("@{@foo.b}"); + var old = new StringTextSnapshot("@{@foo.}"); + RunPartialParseTest(new TestEdit(7, 0, old, 1, changed, "b"), + new MarkupBlock( + factory.EmptyHtml(), + new StatementBlock( + factory.CodeTransition(), + factory.MetaCode("{").Accepts(AcceptedCharactersInternal.None), + factory.EmptyCSharp() + .AsStatement() + .AutoCompleteWith(autoCompleteString: null), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("foo.b") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.EmptyCSharp().AsStatement(), + factory.MetaCode("}").Accepts(AcceptedCharactersInternal.None)), + factory.EmptyHtml())); + } + + [Fact] + public void ImplicitExpressionAcceptsDotIfTrailingDotsAreAllowed() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("@{@foo.}"); + var old = new StringTextSnapshot("@{@foo}"); + RunPartialParseTest(new TestEdit(6, 0, old, 1, changed, "."), + new MarkupBlock( + factory.EmptyHtml(), + new StatementBlock( + factory.CodeTransition(), + factory.MetaCode("{").Accepts(AcceptedCharactersInternal.None), + factory.EmptyCSharp() + .AsStatement() + .AutoCompleteWith(autoCompleteString: null), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("foo.") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.EmptyCSharp().AsStatement(), + factory.MetaCode("}").Accepts(AcceptedCharactersInternal.None)), + factory.EmptyHtml())); + } + + private void RunPartialParseRejectionTest(TestEdit edit, PartialParseResultInternal additionalFlags = 0) + { + var templateEngine = CreateTemplateEngine(); + var document = TestRazorCodeDocument.Create(edit.OldSnapshot.GetText()); + templateEngine.Engine.Process(document); + var syntaxTree = document.GetSyntaxTree(); + var parser = new RazorSyntaxTreePartialParser(syntaxTree); + + var result = parser.Parse(edit.Change); + Assert.Equal(PartialParseResultInternal.Rejected | additionalFlags, result); + } + + private static void RunPartialParseTest(TestEdit edit, Block expectedTree, PartialParseResultInternal additionalFlags = 0) + { + var templateEngine = CreateTemplateEngine(); + var document = TestRazorCodeDocument.Create(edit.OldSnapshot.GetText()); + templateEngine.Engine.Process(document); + var syntaxTree = document.GetSyntaxTree(); + var parser = new RazorSyntaxTreePartialParser(syntaxTree); + + var result = parser.Parse(edit.Change); + Assert.Equal(PartialParseResultInternal.Accepted | additionalFlags, result); + ParserTestBase.EvaluateParseTree(expectedTree, syntaxTree.Root); + } + + private static TestEdit CreateInsertionChange(string initialText, int insertionLocation, string insertionText) + { + var changedText = initialText.Insert(insertionLocation, insertionText); + var sourceChange = new SourceChange(insertionLocation, 0, insertionText); + var oldSnapshot = new StringTextSnapshot(initialText); + var changedSnapshot = new StringTextSnapshot(changedText); + return new TestEdit + { + Change = sourceChange, + OldSnapshot = oldSnapshot, + NewSnapshot = changedSnapshot, + }; + } + + private static RazorTemplateEngine CreateTemplateEngine( + string path = "C:\\This\\Path\\Is\\Just\\For\\Line\\Pragmas.cshtml", + IEnumerable tagHelpers = null) + { + var engine = RazorEngine.CreateDesignTime(builder => + { + RazorExtensions.Register(builder); + + if (tagHelpers != null) + { + builder.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 class TestEdit + { + public TestEdit() + { + } + + public TestEdit(int position, int oldLength, ITextSnapshot oldSnapshot, int newLength, ITextSnapshot newSnapshot, string newText) + { + Change = new SourceChange(position, oldLength, newText); + OldSnapshot = oldSnapshot; + NewSnapshot = newSnapshot; + } + + public SourceChange Change { get; set; } + + public ITextSnapshot OldSnapshot { get; set; } + + public ITextSnapshot NewSnapshot { get; set; } + } + } +} diff --git a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Editor/VisualStudioRazorParserTest.cs b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Editor/VisualStudioRazorParserTest.cs new file mode 100644 index 0000000000..28ddab5335 --- /dev/null +++ b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Editor/VisualStudioRazorParserTest.cs @@ -0,0 +1,892 @@ +// 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.Collections.ObjectModel; +using System.Diagnostics; +using System.Threading; +using Microsoft.AspNetCore.Mvc.Razor.Extensions; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.LanguageServices.Razor.Editor; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Utilities; +using Xunit; + +namespace Microsoft.VisualStudio.LanguageServices.Razor.Editor +{ + public class VisualStudioRazorParserTest + { + private const string TestLinePragmaFileName = "C:\\This\\Path\\Is\\Just\\For\\Line\\Pragmas.cshtml"; + + [Fact] + public void ConstructorRequiresNonNullPhysicalPath() + { + Assert.Throws("filePath", () => new VisualStudioRazorParser(new TestTextBuffer(null), CreateTemplateEngine(), null, new TestCompletionBroker())); + } + + [Fact] + public void ConstructorRequiresNonEmptyPhysicalPath() + { + Assert.Throws("filePath", () => new VisualStudioRazorParser(new TestTextBuffer(null), CreateTemplateEngine(), string.Empty, new TestCompletionBroker())); + } + + [Fact] + public void BufferChangeStartsFullReparseIfChangeOverlapsMultipleSpans() + { + // Arrange + var original = new StringTextSnapshot("Foo @bar Baz"); + var testBuffer = new TestTextBuffer(original); + using (var parser = new VisualStudioRazorParser(testBuffer, CreateTemplateEngine(), TestLinePragmaFileName, new TestCompletionBroker())) + { + parser._idleTimer.Interval = 100; + var changed = new StringTextSnapshot("Foo @bap Daz"); + var edit = new TestEdit(7, 3, original, 3, changed, "p D"); + var parseComplete = new ManualResetEventSlim(); + var parseCount = 0; + parser.DocumentStructureChanged += (s, a) => + { + Interlocked.Increment(ref parseCount); + parseComplete.Set(); + }; + + // Act - 1 + testBuffer.ApplyEdit(edit); + DoWithTimeoutIfNotDebugging(parseComplete.Wait); // Wait for the parse to finish + + // Assert - 1 + Assert.Equal(1, parseCount); + parseComplete.Reset(); + + // Act - 2 + testBuffer.ApplyEdit(edit); + + // Assert - 2 + DoWithTimeoutIfNotDebugging(parseComplete.Wait); + Assert.Equal(2, parseCount); + } + } + + [Fact] + public void AwaitPeriodInsertionAcceptedProvisionally() + { + // Arrange + var original = new StringTextSnapshot("foo @await Html baz"); + using (var manager = CreateParserManager(original)) + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("foo @await Html. baz"); + var edit = new TestEdit(15, 0, original, 1, changed, "."); + manager.InitializeWithDocument(edit.OldSnapshot); + + // Act + manager.ApplyEditAndWaitForReparse(edit); + + // Assert + Assert.Equal(2, manager.ParseCount); + ParserTestBase.EvaluateParseTree(manager.CurrentSyntaxTree.Root, new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("await Html").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.WhiteSpace | AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(". baz"))); + } + } + + [Fact] + public void ImplicitExpressionAcceptsDotlessCommitInsertionsInStatementBlockAfterIdentifiers() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("@{" + Environment.NewLine + + " @DateTime." + Environment.NewLine + + "}"); + var original = new StringTextSnapshot("@{" + Environment.NewLine + + " @DateTime" + Environment.NewLine + + "}"); + + var edit = new TestEdit(15 + Environment.NewLine.Length, 0, original, 1, changed, "."); + using (var manager = CreateParserManager(original)) + { + void ApplyAndVerifyPartialChange(TestEdit testEdit, string expectedCode) + { + manager.ApplyEdit(testEdit); + Assert.Equal(1, manager.ParseCount); + ParserTestBase.EvaluateParseTree(manager.CurrentSyntaxTree.Root, new MarkupBlock( + factory.EmptyHtml(), + new StatementBlock( + factory.CodeTransition(), + factory.MetaCode("{").Accepts(AcceptedCharactersInternal.None), + factory.Code(Environment.NewLine + " ") + .AsStatement() + .AutoCompleteWith(autoCompleteString: null), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code(expectedCode) + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Code(Environment.NewLine).AsStatement(), + factory.MetaCode("}").Accepts(AcceptedCharactersInternal.None)), + factory.EmptyHtml())); + }; + + manager.InitializeWithDocument(edit.OldSnapshot); + + // This is the process of a dotless commit when doing "." insertions to commit intellisense changes. + ApplyAndVerifyPartialChange(edit, "DateTime."); + + original = changed; + changed = new StringTextSnapshot("@{" + Environment.NewLine + + " @DateTime.." + Environment.NewLine + + "}"); + edit = new TestEdit(16 + Environment.NewLine.Length, 0, original, 1, changed, "."); + + ApplyAndVerifyPartialChange(edit, "DateTime.."); + + original = changed; + changed = new StringTextSnapshot("@{" + Environment.NewLine + + " @DateTime.Now." + Environment.NewLine + + "}"); + edit = new TestEdit(16 + Environment.NewLine.Length, 0, original, 3, changed, "Now"); + + ApplyAndVerifyPartialChange(edit, "DateTime.Now."); + } + } + + [Fact] + public void ImplicitExpressionAcceptsDotlessCommitInsertionsInStatementBlock() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("@{" + Environment.NewLine + + " @DateT." + Environment.NewLine + + "}"); + var original = new StringTextSnapshot("@{" + Environment.NewLine + + " @DateT" + Environment.NewLine + + "}"); + + var edit = new TestEdit(12 + Environment.NewLine.Length, 0, original, 1, changed, "."); + using (var manager = CreateParserManager(original)) + { + void ApplyAndVerifyPartialChange(TestEdit testEdit, string expectedCode) + { + manager.ApplyEdit(testEdit); + Assert.Equal(1, manager.ParseCount); + ParserTestBase.EvaluateParseTree(manager.CurrentSyntaxTree.Root, new MarkupBlock( + factory.EmptyHtml(), + new StatementBlock( + factory.CodeTransition(), + factory.MetaCode("{").Accepts(AcceptedCharactersInternal.None), + factory.Code(Environment.NewLine + " ") + .AsStatement() + .AutoCompleteWith(autoCompleteString: null), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code(expectedCode) + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: true) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Code(Environment.NewLine).AsStatement(), + factory.MetaCode("}").Accepts(AcceptedCharactersInternal.None)), + factory.EmptyHtml())); + }; + + manager.InitializeWithDocument(edit.OldSnapshot); + + // This is the process of a dotless commit when doing "." insertions to commit intellisense changes. + ApplyAndVerifyPartialChange(edit, "DateT."); + + original = changed; + changed = new StringTextSnapshot("@{" + Environment.NewLine + + " @DateTime." + Environment.NewLine + + "}"); + edit = new TestEdit(12 + Environment.NewLine.Length, 0, original, 3, changed, "ime"); + + ApplyAndVerifyPartialChange(edit, "DateTime."); + } + } + + [Fact] + public void ImplicitExpressionProvisionallyAcceptsDotlessCommitInsertions() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("foo @DateT. baz"); + var original = new StringTextSnapshot("foo @DateT baz"); + var edit = new TestEdit(10, 0, original, 1, changed, "."); + using (var manager = CreateParserManager(original, idleDelay: 250)) + { + void ApplyAndVerifyPartialChange(TestEdit testEdit, string expectedCode) + { + manager.ApplyEdit(testEdit); + Assert.Equal(1, manager.ParseCount); + + ParserTestBase.EvaluateParseTree(manager.CurrentSyntaxTree.Root, new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code(expectedCode).AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz"))); + }; + + manager.InitializeWithDocument(edit.OldSnapshot); + + // This is the process of a dotless commit when doing "." insertions to commit intellisense changes. + ApplyAndVerifyPartialChange(edit, "DateT."); + + original = changed; + changed = new StringTextSnapshot("foo @DateTime. baz"); + edit = new TestEdit(10, 0, original, 3, changed, "ime"); + + ApplyAndVerifyPartialChange(edit, "DateTime."); + + // Verify the reparse finally comes + manager.WaitForReparse(); + + Assert.Equal(2, manager.ParseCount); + ParserTestBase.EvaluateParseTree(manager.CurrentSyntaxTree.Root, new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("DateTime").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(". baz"))); + } + } + + [Fact] + public void ImplicitExpressionProvisionallyAcceptsDotlessCommitInsertionsAfterIdentifiers() + { + var factory = new SpanFactory(); + var changed = new StringTextSnapshot("foo @DateTime. baz"); + var original = new StringTextSnapshot("foo @DateTime baz"); + var edit = new TestEdit(13, 0, original, 1, changed, "."); + using (var manager = CreateParserManager(original, idleDelay: 250)) + { + void ApplyAndVerifyPartialChange(TestEdit testEdit, string expectedCode) + { + manager.ApplyEdit(testEdit); + Assert.Equal(1, manager.ParseCount); + + ParserTestBase.EvaluateParseTree(manager.CurrentSyntaxTree.Root, new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code(expectedCode).AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz"))); + }; + + manager.InitializeWithDocument(edit.OldSnapshot); + + // This is the process of a dotless commit when doing "." insertions to commit intellisense changes. + ApplyAndVerifyPartialChange(edit, "DateTime."); + + original = changed; + changed = new StringTextSnapshot("foo @DateTime.. baz"); + edit = new TestEdit(14, 0, original, 1, changed, "."); + + ApplyAndVerifyPartialChange(edit, "DateTime.."); + + original = changed; + changed = new StringTextSnapshot("foo @DateTime.Now. baz"); + edit = new TestEdit(14, 0, original, 3, changed, "Now"); + + ApplyAndVerifyPartialChange(edit, "DateTime.Now."); + + // Verify the reparse eventually happens + manager.WaitForReparse(); + + Assert.Equal(2, manager.ParseCount); + ParserTestBase.EvaluateParseTree(manager.CurrentSyntaxTree.Root, new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("DateTime.Now").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(". baz"))); + } + } + + [Fact] + public void ImplicitExpressionProvisionallyAcceptsCaseInsensitiveDotlessCommitInsertions_NewRoslynIntegration() + { + var factory = new SpanFactory(); + var original = new StringTextSnapshot("foo @date baz"); + var changed = new StringTextSnapshot("foo @date. baz"); + var edit = new TestEdit(9, 0, original, 1, changed, "."); + using (var manager = CreateParserManager(original, idleDelay: 250)) + { + void ApplyAndVerifyPartialChange(Action applyEdit, string expectedCode) + { + applyEdit(); + Assert.Equal(1, manager.ParseCount); + + ParserTestBase.EvaluateParseTree(manager.CurrentSyntaxTree.Root, new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code(expectedCode).AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" baz"))); + }; + + manager.InitializeWithDocument(edit.OldSnapshot); + + // This is the process of a dotless commit when doing "." insertions to commit intellisense changes. + + // @date => @date. + ApplyAndVerifyPartialChange(() => manager.ApplyEdit(edit), "date."); + + original = changed; + changed = new StringTextSnapshot("foo @date baz"); + edit = new TestEdit(9, 1, original, 0, changed, ""); + + // @date. => @date + ApplyAndVerifyPartialChange(() => manager.ApplyEdit(edit), "date"); + + original = changed; + changed = new StringTextSnapshot("foo @DateTime baz"); + edit = new TestEdit(5, 4, original, 8, changed, "DateTime"); + + // @date => @DateTime + ApplyAndVerifyPartialChange(() => manager.ApplyEdit(edit), "DateTime"); + + original = changed; + changed = new StringTextSnapshot("foo @DateTime. baz"); + edit = new TestEdit(13, 0, original, 1, changed, "."); + + // @DateTime => @DateTime. + ApplyAndVerifyPartialChange(() => manager.ApplyEdit(edit), "DateTime."); + + // Verify the reparse eventually happens + manager.WaitForReparse(); + + Assert.Equal(2, manager.ParseCount); + ParserTestBase.EvaluateParseTree(manager.CurrentSyntaxTree.Root, new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("DateTime").AsImplicitExpression(CSharpCodeParser.DefaultKeywords).Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(". baz"))); + } + } + + [Fact] + public void ImplicitExpressionRejectsChangeWhichWouldHaveBeenAcceptedIfLastChangeWasProvisionallyAcceptedOnDifferentSpan() + { + // Arrange + var factory = new SpanFactory(); + var dotTyped = new TestEdit(8, 0, new StringTextSnapshot("foo @foo @bar"), 1, new StringTextSnapshot("foo @foo. @bar"), "."); + var charTyped = new TestEdit(14, 0, new StringTextSnapshot("foo @foo. @bar"), 1, new StringTextSnapshot("foo @foo. @barb"), "b"); + using (var manager = CreateParserManager(dotTyped.OldSnapshot)) + { + manager.InitializeWithDocument(dotTyped.OldSnapshot); + + // Apply the dot change + manager.ApplyEditAndWaitForReparse(dotTyped); + + // Act (apply the identifier start char change) + manager.ApplyEditAndWaitForParse(charTyped); + + // Assert + Assert.Equal(2, manager.ParseCount); + ParserTestBase.EvaluateParseTree(manager.CurrentSyntaxTree.Root, + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("foo") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(". "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("barb") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.EmptyHtml())); + } + } + + [Fact] + public void ImplicitExpressionAcceptsIdentifierTypedAfterDotIfLastChangeWasProvisionalAcceptanceOfDot() + { + // Arrange + var factory = new SpanFactory(); + var dotTyped = new TestEdit(8, 0, new StringTextSnapshot("foo @foo bar"), 1, new StringTextSnapshot("foo @foo. bar"), "."); + var charTyped = new TestEdit(9, 0, new StringTextSnapshot("foo @foo. bar"), 1, new StringTextSnapshot("foo @foo.b bar"), "b"); + using (var manager = CreateParserManager(dotTyped.OldSnapshot, idleDelay: 250)) + { + manager.InitializeWithDocument(dotTyped.OldSnapshot); + + // Apply the dot change + manager.ApplyEdit(dotTyped); + + // Act (apply the identifier start char change) + manager.ApplyEdit(charTyped); + + // Assert + Assert.Equal(1, manager.ParseCount); + ParserTestBase.EvaluateParseTree(manager.CurrentSyntaxTree.Root, + new MarkupBlock( + factory.Markup("foo "), + new ExpressionBlock( + factory.CodeTransition(), + factory.Code("foo.b") + .AsImplicitExpression(CSharpCodeParser.DefaultKeywords) + .Accepts(AcceptedCharactersInternal.NonWhiteSpace)), + factory.Markup(" bar"))); + } + } + + [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 TestParserManager CreateParserManager(ITextSnapshot originalSnapshot, int idleDelay = 50) + { + var parser = new VisualStudioRazorParser(new TestTextBuffer(originalSnapshot), CreateTemplateEngine(), TestLinePragmaFileName, new TestCompletionBroker()); + + // Normal idle delay is 3000 milliseconds, for testing we want it to be far shorter. + parser._idleTimer.Interval = idleDelay; + return new TestParserManager(parser); + } + + private static RazorTemplateEngine CreateTemplateEngine( + string path = TestLinePragmaFileName, + IEnumerable tagHelpers = null) + { + var engine = RazorEngine.CreateDesignTime(builder => + { + RazorExtensions.Register(builder); + + if (tagHelpers != null) + { + builder.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) + { + // Arrange + var before = "@" + keyword.Substring(0, keyword.Length - 1); + var after = "@" + keyword; + var changed = new StringTextSnapshot(after); + var old = new StringTextSnapshot(before); + var change = new SourceChange(keyword.Length, 0, keyword[keyword.Length - 1].ToString()); + var edit = new TestEdit + { + Change = change, + NewSnapshot = changed, + OldSnapshot = old + }; + using (var manager = CreateParserManager(old)) + { + manager.InitializeWithDocument(edit.OldSnapshot); + + // Act + manager.ApplyEditAndWaitForParse(edit); + + // Assert + Assert.Equal(2, manager.ParseCount); + } + } + + private static void DoWithTimeoutIfNotDebugging(Func withTimeout) + { +#if DEBUG + if (Debugger.IsAttached) + { + withTimeout(Timeout.Infinite); + } + else + { +#endif + Assert.True(withTimeout((int)TimeSpan.FromSeconds(1).TotalMilliseconds), "Timeout expired!"); +#if DEBUG + } +#endif + } + + private class TestParserManager : IDisposable + { + public int ParseCount; + + private readonly ManualResetEventSlim _parserComplete; + private readonly ManualResetEventSlim _reparseComplete; + private readonly TestTextBuffer _testBuffer; + private readonly VisualStudioRazorParser _parser; + + public TestParserManager(VisualStudioRazorParser parser) + { + _parserComplete = new ManualResetEventSlim(); + _reparseComplete = new ManualResetEventSlim(); + _testBuffer = (TestTextBuffer)parser._textBuffer; + ParseCount = 0; + _parser = parser; + parser.DocumentStructureChanged += (sender, args) => + { + Interlocked.Increment(ref ParseCount); + _parserComplete.Set(); + + if (args.SourceChange == null) + { + // Reparse occurred + _reparseComplete.Set(); + } + + CurrentSyntaxTree = args.CodeDocument.GetSyntaxTree(); + }; + } + + public RazorSyntaxTree CurrentSyntaxTree { get; private set; } + + public void InitializeWithDocument(ITextSnapshot snapshot) + { + var old = new StringTextSnapshot(string.Empty); + var initialChange = new SourceChange(0, 0, snapshot.GetText()); + var edit = new TestEdit + { + Change = initialChange, + OldSnapshot = old, + NewSnapshot = snapshot + }; + ApplyEditAndWaitForParse(edit); + } + + public void ApplyEdit(TestEdit edit) + { + _testBuffer.ApplyEdit(edit); + } + + public void ApplyEditAndWaitForParse(TestEdit edit) + { + ApplyEdit(edit); + WaitForParse(); + } + + public void ApplyEditAndWaitForReparse(TestEdit edit) + { + ApplyEdit(edit); + WaitForReparse(); + } + + public void WaitForParse() + { + DoWithTimeoutIfNotDebugging(_parserComplete.Wait); // Wait for the parse to finish + _parserComplete.Reset(); + } + + public void WaitForReparse() + { + DoWithTimeoutIfNotDebugging(_reparseComplete.Wait); + _reparseComplete.Reset(); + } + + public void Dispose() + { + _parser.Dispose(); + } + } + + private class TextChange : ITextChange + { + public TextChange(TestEdit edit) : this(edit.Change) + { + } + + public TextChange(SourceChange change) + { + var changeSpan = change.Span; + + OldPosition = changeSpan.AbsoluteIndex; + NewPosition = OldPosition; + OldEnd = changeSpan.AbsoluteIndex + changeSpan.Length; + NewEnd = changeSpan.AbsoluteIndex + change.NewText.Length; + } + + public Text.Span OldSpan => throw new NotImplementedException(); + + public Text.Span NewSpan => throw new NotImplementedException(); + + public int OldPosition { get; } + + public int NewPosition { get; } + + public int Delta => throw new NotImplementedException(); + + public int OldEnd { get; } + + public int NewEnd { get; } + + public string OldText => throw new NotImplementedException(); + + public string NewText => throw new NotImplementedException(); + + public int OldLength => throw new NotImplementedException(); + + public int NewLength => throw new NotImplementedException(); + + public int LineCountDelta => throw new NotImplementedException(); + } + + private class TestCompletionBroker : ICompletionBroker + { + public ICompletionSession CreateCompletionSession(ITextView textView, ITrackingPoint triggerPoint, bool trackCaret) + { + throw new NotImplementedException(); + } + + public void DismissAllSessions(ITextView textView) + { + throw new NotImplementedException(); + } + + public ReadOnlyCollection GetSessions(ITextView textView) + { + throw new NotImplementedException(); + } + + public bool IsCompletionActive(ITextView textView) + { + return false; + } + + public ICompletionSession TriggerCompletion(ITextView textView) + { + throw new NotImplementedException(); + } + + public ICompletionSession TriggerCompletion(ITextView textView, ITrackingPoint triggerPoint, bool trackCaret) + { + throw new NotImplementedException(); + } + } + + private class TestTextBuffer : Text.ITextBuffer + { + private ITextSnapshot _currentSnapshot; + + public TestTextBuffer(ITextSnapshot initialSnapshot) + { + _currentSnapshot = initialSnapshot; + ReadOnlyRegionsChanged += (sender, args) => { }; + ChangedLowPriority += (sender, args) => { }; + ChangedHighPriority += (sender, args) => { }; + Changing += (sender, args) => { }; + PostChanged += (sender, args) => { }; + ContentTypeChanged += (sender, args) => { }; + } + + public void ApplyEdit(TestEdit edit) + { + var args = new TextContentChangedEventArgs(edit.OldSnapshot, edit.NewSnapshot, new EditOptions(), null); + args.Changes.Add(new TextChange(edit)); + Changed?.Invoke(this, args); + + ReadOnlyRegionsChanged?.Invoke(null, null); + ChangedLowPriority?.Invoke(null, null); + ChangedHighPriority?.Invoke(null, null); + Changing?.Invoke(null, null); + PostChanged?.Invoke(null, null); + ContentTypeChanged?.Invoke(null, null); + + _currentSnapshot = edit.NewSnapshot; + } + + public IContentType ContentType => throw new NotImplementedException(); + + public ITextSnapshot CurrentSnapshot => _currentSnapshot; + + public bool EditInProgress => throw new NotImplementedException(); + + public PropertyCollection Properties => throw new NotImplementedException(); + + public event EventHandler ReadOnlyRegionsChanged; + public event EventHandler Changed; + public event EventHandler ChangedLowPriority; + public event EventHandler ChangedHighPriority; + public event EventHandler Changing; + public event EventHandler PostChanged; + public event EventHandler ContentTypeChanged; + + public void ChangeContentType(IContentType newContentType, object editTag) + { + throw new NotImplementedException(); + } + + public bool CheckEditAccess() + { + throw new NotImplementedException(); + } + + public ITextEdit CreateEdit(EditOptions options, int? reiteratedVersionNumber, object editTag) + { + throw new NotImplementedException(); + } + + public ITextEdit CreateEdit() + { + throw new NotImplementedException(); + } + + public IReadOnlyRegionEdit CreateReadOnlyRegionEdit() + { + throw new NotImplementedException(); + } + + public ITextSnapshot Delete(Text.Span deleteSpan) + { + throw new NotImplementedException(); + } + + public NormalizedSpanCollection GetReadOnlyExtents(Text.Span span) + { + throw new NotImplementedException(); + } + + public ITextSnapshot Insert(int position, string text) + { + throw new NotImplementedException(); + } + + public bool IsReadOnly(int position) + { + throw new NotImplementedException(); + } + + public bool IsReadOnly(int position, bool isEdit) + { + throw new NotImplementedException(); + } + + public bool IsReadOnly(Text.Span span) + { + throw new NotImplementedException(); + } + + public bool IsReadOnly(Text.Span span, bool isEdit) + { + throw new NotImplementedException(); + } + + public ITextSnapshot Replace(Text.Span replaceSpan, string replaceWith) + { + throw new NotImplementedException(); + } + + public void TakeThreadOwnership() + { + throw new NotImplementedException(); + } + } + + private class TestEdit + { + public TestEdit() + { + } + + public TestEdit(int position, int oldLength, ITextSnapshot oldSnapshot, int newLength, ITextSnapshot newSnapshot, string newText) + { + Change = new SourceChange(position, oldLength, newText); + OldSnapshot = oldSnapshot; + NewSnapshot = newSnapshot; + } + + public SourceChange Change { get; set; } + + public ITextSnapshot OldSnapshot { get; set; } + + public ITextSnapshot NewSnapshot { get; set; } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/RazorEditorParserTest.cs b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Legacy/RazorEditorParserTest.cs similarity index 99% rename from test/Microsoft.VisualStudio.LanguageServices.Razor.Test/RazorEditorParserTest.cs rename to test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Legacy/RazorEditorParserTest.cs index 5fd369d42a..d78d8b6807 100644 --- a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/RazorEditorParserTest.cs +++ b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Legacy/RazorEditorParserTest.cs @@ -323,7 +323,7 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor modified.LinkNodes(); // Act - var treesAreDifferent = BackgroundParser.TreesAreDifferent( + var treesAreDifferent = RazorEditorParser.BackgroundParser.TreesAreDifferent( original, modified, new[] @@ -355,7 +355,7 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor factory.Code("f") .AsImplicitExpression(CSharpCodeParser.DefaultKeywords, acceptTrailingDot: false)), factory.Markup("

")); - Assert.True(BackgroundParser.TreesAreDifferent( + Assert.True(RazorEditorParser.BackgroundParser.TreesAreDifferent( original, modified, new[] @@ -386,7 +386,7 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor factory.Markup("

")); original.LinkNodes(); modified.LinkNodes(); - Assert.False(BackgroundParser.TreesAreDifferent( + Assert.False(RazorEditorParser.BackgroundParser.TreesAreDifferent( original, modified, new[] diff --git a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/StringTextSnapshot.cs b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/StringTextSnapshot.cs index c6e183029b..0cbcc181d4 100644 --- a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/StringTextSnapshot.cs +++ b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/StringTextSnapshot.cs @@ -11,96 +11,91 @@ namespace Microsoft.AspNetCore.Razor.Language.Legacy { public class StringTextSnapshot : ITextSnapshot { - private readonly string _content; - public StringTextSnapshot(string content) { - _content = content; + Content = content; } - public char this[int position] => _content[position]; + public string Content { get; } + + public char this[int position] => Content[position]; + + public ITextVersion Version { get; } = new TextVersion(); + + public int Length => Content.Length; public VisualStudio.Text.ITextBuffer TextBuffer => throw new NotImplementedException(); public IContentType ContentType => throw new NotImplementedException(); - public ITextVersion Version => throw new NotImplementedException(); - - public int Length => _content.Length; - public int LineCount => throw new NotImplementedException(); public IEnumerable Lines => throw new NotImplementedException(); - public void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count) + public void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count) => Content.CopyTo(sourceIndex, destination, destinationIndex, count); + + public string GetText(int startIndex, int length) => Content.Substring(startIndex, length); + + public string GetText() => Content; + + public char[] ToCharArray(int startIndex, int length) => Content.ToCharArray(); + + public ITrackingPoint CreateTrackingPoint(int position, PointTrackingMode trackingMode) => throw new NotImplementedException(); + + public ITrackingPoint CreateTrackingPoint(int position, PointTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => throw new NotImplementedException(); + + public ITrackingSpan CreateTrackingSpan(VisualStudio.Text.Span span, SpanTrackingMode trackingMode) => throw new NotImplementedException(); + + public ITrackingSpan CreateTrackingSpan(VisualStudio.Text.Span span, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => throw new NotImplementedException(); + + public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode) => throw new NotImplementedException(); + + public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => throw new NotImplementedException(); + + public ITextSnapshotLine GetLineFromLineNumber(int lineNumber) => throw new NotImplementedException(); + + public ITextSnapshotLine GetLineFromPosition(int position) => throw new NotImplementedException(); + + public int GetLineNumberFromPosition(int position) => throw new NotImplementedException(); + + public string GetText(VisualStudio.Text.Span span) => throw new NotImplementedException(); + + public void Write(TextWriter writer, VisualStudio.Text.Span span) => throw new NotImplementedException(); + + public void Write(TextWriter writer) => throw new NotImplementedException(); + + private class TextVersion : ITextVersion { - _content.CopyTo(sourceIndex, destination, destinationIndex, count); - } + public INormalizedTextChangeCollection Changes { get; } = new TextChangeCollection(); - public ITrackingPoint CreateTrackingPoint(int position, PointTrackingMode trackingMode) - { - throw new NotImplementedException(); - } + public ITextVersion Next => throw new NotImplementedException(); - public ITrackingPoint CreateTrackingPoint(int position, PointTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) - { - throw new NotImplementedException(); - } + public int Length => throw new NotImplementedException(); - public ITrackingSpan CreateTrackingSpan(VisualStudio.Text.Span span, SpanTrackingMode trackingMode) - { - throw new NotImplementedException(); - } + public VisualStudio.Text.ITextBuffer TextBuffer => throw new NotImplementedException(); - public ITrackingSpan CreateTrackingSpan(VisualStudio.Text.Span span, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) - { - throw new NotImplementedException(); - } + public int VersionNumber => throw new NotImplementedException(); - public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode) - { - throw new NotImplementedException(); - } + public int ReiteratedVersionNumber => throw new NotImplementedException(); - public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) - { - throw new NotImplementedException(); - } + public ITrackingSpan CreateCustomTrackingSpan(VisualStudio.Text.Span span, TrackingFidelityMode trackingFidelity, object customState, CustomTrackToVersion behavior) => throw new NotImplementedException(); - public ITextSnapshotLine GetLineFromLineNumber(int lineNumber) - { - throw new NotImplementedException(); - } + public ITrackingPoint CreateTrackingPoint(int position, PointTrackingMode trackingMode) => throw new NotImplementedException(); - public ITextSnapshotLine GetLineFromPosition(int position) - { - throw new NotImplementedException(); - } + public ITrackingPoint CreateTrackingPoint(int position, PointTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => throw new NotImplementedException(); - public int GetLineNumberFromPosition(int position) - { - throw new NotImplementedException(); - } + public ITrackingSpan CreateTrackingSpan(VisualStudio.Text.Span span, SpanTrackingMode trackingMode) => throw new NotImplementedException(); - public string GetText(VisualStudio.Text.Span span) - { - throw new NotImplementedException(); - } + public ITrackingSpan CreateTrackingSpan(VisualStudio.Text.Span span, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => throw new NotImplementedException(); - public string GetText(int startIndex, int length) => _content.Substring(startIndex, length); + public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode) => throw new NotImplementedException(); - public string GetText() => _content; + public ITrackingSpan CreateTrackingSpan(int start, int length, SpanTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => throw new NotImplementedException(); - public char[] ToCharArray(int startIndex, int length) => _content.ToCharArray(); - - public void Write(TextWriter writer, VisualStudio.Text.Span span) - { - throw new NotImplementedException(); - } - - public void Write(TextWriter writer) - { - throw new NotImplementedException(); + private class TextChangeCollection : List, INormalizedTextChangeCollection + { + public bool IncludesLineChanges => false; + } } } }