diff --git a/src/Microsoft.AspNetCore.Razor.Language/Legacy/ParserHelpers.cs b/src/Microsoft.AspNetCore.Razor.Language/Legacy/ParserHelpers.cs index 0ad2263a4a..71835178b2 100644 --- a/src/Microsoft.AspNetCore.Razor.Language/Legacy/ParserHelpers.cs +++ b/src/Microsoft.AspNetCore.Razor.Language/Legacy/ParserHelpers.cs @@ -10,13 +10,18 @@ namespace Microsoft.AspNetCore.Razor.Language.Legacy { internal static class ParserHelpers { + public static char[] NewLineCharacters = new[] + { + '\r', // Carriage return + '\n', // Linefeed + '\u0085', // Next Line + '\u2028', // Line separator + '\u2029' // Paragraph separator + }; + public static bool IsNewLine(char value) { - return value == '\r' // Carriage return - || value == '\n' // Linefeed - || value == '\u0085' // Next Line - || value == '\u2028' // Line separator - || value == '\u2029'; // Paragraph separator + return NewLineCharacters.Contains(value); } public static bool IsNewLine(string value) diff --git a/src/Microsoft.AspNetCore.Razor.Language/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Razor.Language/Properties/AssemblyInfo.cs index c3e9d48681..2e1dafb7a8 100644 --- a/src/Microsoft.AspNetCore.Razor.Language/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.Razor.Language/Properties/AssemblyInfo.cs @@ -12,6 +12,7 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Microsoft.CodeAnalysis.Razor.Workspaces, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.CodeAnalysis.Remote.Razor, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.VisualStudio.Editor.Razor, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.VisualStudio.Editor.Razor.Test.Common, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.VisualStudio.LanguageServices.Razor, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.VisualStudio.Editor.Razor.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.VisualStudio.LanguageServices.Razor.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Microsoft.VisualStudio.Editor.Razor/BraceSmartIndenter.cs b/src/Microsoft.VisualStudio.Editor.Razor/BraceSmartIndenter.cs new file mode 100644 index 0000000000..635f3d18e6 --- /dev/null +++ b/src/Microsoft.VisualStudio.Editor.Razor/BraceSmartIndenter.cs @@ -0,0 +1,268 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Text; +using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Operations; +using ITextBuffer = Microsoft.VisualStudio.Text.ITextBuffer; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + /// + /// This class is responsible for handling situations where Roslyn and the HTML editor cannot auto-indent Razor code. + /// + /// + /// Attempting to insert a newline (pipe indicates the cursor): + /// @{ |} + /// Should result in the text buffer looking like the following: + /// @{ + /// | + /// } + /// This is also true for directive block scenarios. + /// + internal class BraceSmartIndenter : IDisposable + { + private readonly ForegroundDispatcher _dispatcher; + private readonly ITextBuffer _textBuffer; + private readonly VisualStudioDocumentTrackerFactory _documentTrackerFactory; + private readonly IEditorOperationsFactoryService _editorOperationsFactory; + private readonly StringBuilder _indentBuilder = new StringBuilder(); + private BraceIndentationContext _context; + + public BraceSmartIndenter( + ForegroundDispatcher dispatcher, + ITextBuffer textBuffer, + VisualStudioDocumentTrackerFactory documentTrackerFactory, + IEditorOperationsFactoryService editorOperationsFactory) + { + if (dispatcher == null) + { + throw new ArgumentNullException(nameof(dispatcher)); + } + + if (textBuffer == null) + { + throw new ArgumentNullException(nameof(textBuffer)); + } + + if (documentTrackerFactory == null) + { + throw new ArgumentNullException(nameof(documentTrackerFactory)); + } + + if (editorOperationsFactory == null) + { + throw new ArgumentNullException(nameof(editorOperationsFactory)); + } + + _dispatcher = dispatcher; + _textBuffer = textBuffer; + _documentTrackerFactory = documentTrackerFactory; + _editorOperationsFactory = editorOperationsFactory; + _textBuffer.Changed += TextBuffer_OnChanged; + _textBuffer.PostChanged += TextBuffer_OnPostChanged; + } + + public void Dispose() + { + _dispatcher.AssertForegroundThread(); + + _textBuffer.Changed -= TextBuffer_OnChanged; + _textBuffer.PostChanged -= TextBuffer_OnPostChanged; + } + + // Internal for testing + internal void TriggerSmartIndent(ITextView textView) + { + // This forces the smart indent. For example attempting to enter a newline between the functions directive: + // @functions {} will not auto-indent in between the braces unless we forcefully move to end of line. + var editorOperations = _editorOperationsFactory.GetEditorOperations(textView); + editorOperations.MoveToEndOfLine(false); + } + + // Internal for testing + internal void TextBuffer_OnChanged(object sender, TextContentChangedEventArgs args) + { + _dispatcher.AssertForegroundThread(); + + if (!args.TextChangeOccurred(out var changeInformation)) + { + return; + } + + var documentTracker = _documentTrackerFactory.GetTracker(_textBuffer); + + // Extra hardening, this should never be null. + if (documentTracker == null) + { + return; + } + + var newText = changeInformation.newText; + if (TryCreateIndentationContext(changeInformation.firstChange.NewPosition, newText.Length, newText, documentTracker, out var context)) + { + _context = context; + } + } + + private void TextBuffer_OnPostChanged(object sender, EventArgs e) + { + _dispatcher.AssertForegroundThread(); + + var context = _context; + _context = null; + + if (context != null) + { + // Save the current caret position + var textView = context.FocusedTextView; + var caret = textView.Caret.Position.BufferPosition; + var textViewBuffer = textView.TextBuffer; + var indent = CalculateIndent(textViewBuffer, context.ChangePosition); + + // Current state, pipe is cursor: + // @{ + // |} + + // Insert the completion text, i.e. "\r\n " + InsertIndent(caret.Position, indent, textViewBuffer); + + // @{ + // + // |} + + // Place the caret inbetween the braces (before our indent). + RestoreCaretTo(caret.Position, textView); + + // @{ + // | + // } + + // For Razor metacode cases the editor's smart indent wont kick in automatically. + TriggerSmartIndent(textView); + + // @{ + // | + // } + } + } + + private string CalculateIndent(ITextBuffer buffer, int from) + { + // Get the line text of the block start + var currentSnapshotPoint = new SnapshotPoint(buffer.CurrentSnapshot, from); + var line = buffer.CurrentSnapshot.GetLineFromPosition(currentSnapshotPoint); + var lineText = line.GetText(); + + // Gather up the indent from the start block + _indentBuilder.Append(line.GetLineBreakText()); + foreach (var ch in lineText) + { + if (!char.IsWhiteSpace(ch)) + { + break; + } + _indentBuilder.Append(ch); + } + + var indent = _indentBuilder.ToString(); + _indentBuilder.Clear(); + + return indent; + } + + // Internal for testing + internal static void InsertIndent(int insertLocation, string indent, ITextBuffer textBuffer) + { + var edit = textBuffer.CreateEdit(); + edit.Insert(insertLocation, indent); + edit.Apply(); + } + + // Internal for testing + internal static void RestoreCaretTo(int caretPosition, ITextView textView) + { + var currentSnapshotPoint = new SnapshotPoint(textView.TextBuffer.CurrentSnapshot, caretPosition); + textView.Caret.MoveTo(currentSnapshotPoint); + } + + // Internal for testing + internal static bool TryCreateIndentationContext(int changePosition, int changeLength, string finalText, VisualStudioDocumentTracker documentTracker, out BraceIndentationContext context) + { + var focusedTextView = documentTracker.GetFocusedTextView(); + if (focusedTextView != null && ParserHelpers.IsNewLine(finalText)) + { + var currentSnapshot = documentTracker.TextBuffer.CurrentSnapshot; + var preChangeLineSnapshot = currentSnapshot.GetLineFromPosition(changePosition); + + // Handle the case where the \n comes through separately from the \r and the position + // on the line is beyond what the GetText call above gives back. + var linePosition = Math.Min(preChangeLineSnapshot.Length, changePosition - preChangeLineSnapshot.Start) - 1; + + if (AfterOpeningBrace(linePosition, preChangeLineSnapshot)) + { + var afterChangePosition = changePosition + changeLength; + var afterChangeLineSnapshot = currentSnapshot.GetLineFromPosition(afterChangePosition); + var afterChangeLinePosition = afterChangePosition - afterChangeLineSnapshot.Start; + + if (BeforeClosingBrace(afterChangeLinePosition, afterChangeLineSnapshot)) + { + context = new BraceIndentationContext(focusedTextView, changePosition); + return true; + } + } + } + + context = null; + return false; + } + + internal static bool BeforeClosingBrace(int linePosition, ITextSnapshotLine lineSnapshot) + { + var lineText = lineSnapshot.GetText(); + for (; linePosition < lineSnapshot.Length; linePosition++) + { + if (!char.IsWhiteSpace(lineText[linePosition])) + { + break; + } + } + + var beforeClosingBrace = linePosition < lineSnapshot.Length && lineText[linePosition] == '}'; + return beforeClosingBrace; + } + + internal static bool AfterOpeningBrace(int linePosition, ITextSnapshotLine lineSnapshot) + { + var lineText = lineSnapshot.GetText(); + for (; linePosition >= 0; linePosition--) + { + if (!char.IsWhiteSpace(lineText[linePosition])) + { + break; + } + } + + var afterClosingBrace = linePosition >= 0 && lineText[linePosition] == '{'; + return afterClosingBrace; + } + + internal class BraceIndentationContext + { + public BraceIndentationContext(ITextView focusedTextView, int changePosition) + { + FocusedTextView = focusedTextView; + ChangePosition = changePosition; + } + + public ITextView FocusedTextView { get; } + + public int ChangePosition { get; } + } + } +} diff --git a/src/Microsoft.VisualStudio.Editor.Razor/Properties/Resources.Designer.cs b/src/Microsoft.VisualStudio.Editor.Razor/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..922d049da8 --- /dev/null +++ b/src/Microsoft.VisualStudio.Editor.Razor/Properties/Resources.Designer.cs @@ -0,0 +1,44 @@ +// +namespace Microsoft.VisualStudio.Editor.Razor +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class Resources + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.VisualStudio.Editor.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"); + + private static string GetString(string name, params string[] formatterNames) + { + var value = _resourceManager.GetString(name); + + System.Diagnostics.Debug.Assert(value != null); + + if (formatterNames != null) + { + for (var i = 0; i < formatterNames.Length; i++) + { + value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}"); + } + } + + return value; + } + } +} diff --git a/src/Microsoft.VisualStudio.Editor.Razor/Resources.resx b/src/Microsoft.VisualStudio.Editor.Razor/Resources.resx new file mode 100644 index 0000000000..55a253ebbe --- /dev/null +++ b/src/Microsoft.VisualStudio.Editor.Razor/Resources.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Value cannot be null or an empty string. + + \ No newline at end of file diff --git a/src/Microsoft.VisualStudio.Editor.Razor/TextContentChangedEventArgsExtensions.cs b/src/Microsoft.VisualStudio.Editor.Razor/TextContentChangedEventArgsExtensions.cs new file mode 100644 index 0000000000..2e274ee885 --- /dev/null +++ b/src/Microsoft.VisualStudio.Editor.Razor/TextContentChangedEventArgsExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.VisualStudio.Text +{ + internal static class TextContentChangedEventArgsExtensions + { + public static bool TextChangeOccurred(this TextContentChangedEventArgs args, out (ITextChange firstChange, ITextChange lastChange, string newText, string oldText) changeInformation) + { + if (args.Changes.Count > 0) + { + var firstChange = args.Changes[0]; + var lastChange = args.Changes[args.Changes.Count - 1]; + var oldLength = lastChange.OldEnd - firstChange.OldPosition; + var newLength = lastChange.NewEnd - firstChange.NewPosition; + var newText = args.After.GetText(firstChange.NewPosition, newLength); + var oldText = args.Before.GetText(firstChange.OldPosition, oldLength); + + var wasChanged = true; + if (oldLength == newLength) + { + wasChanged = !string.Equals(oldText, newText, StringComparison.Ordinal); + } + + if (wasChanged) + { + changeInformation = (firstChange, lastChange, newText, oldText); + return true; + } + } + + changeInformation = default((ITextChange, ITextChange, string, string)); + return false; + } + } +} diff --git a/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioDocumentTracker.cs b/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioDocumentTracker.cs index 48abb122da..2c4c6858d6 100644 --- a/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioDocumentTracker.cs +++ b/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioDocumentTracker.cs @@ -25,5 +25,7 @@ namespace Microsoft.VisualStudio.Editor.Razor public abstract ITextBuffer TextBuffer { get; } public abstract IReadOnlyList TextViews { get; } + + public abstract ITextView GetFocusedTextView(); } } diff --git a/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioDocumentTrackerFactory.cs b/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioDocumentTrackerFactory.cs index 60f3b8e7b4..7795f83ddc 100644 --- a/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioDocumentTrackerFactory.cs +++ b/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioDocumentTrackerFactory.cs @@ -1,6 +1,7 @@ // 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.VisualStudio.Text; using Microsoft.VisualStudio.Text.Editor; namespace Microsoft.VisualStudio.Editor.Razor @@ -8,5 +9,7 @@ namespace Microsoft.VisualStudio.Editor.Razor public abstract class VisualStudioDocumentTrackerFactory { public abstract VisualStudioDocumentTracker GetTracker(ITextView textView); + + public abstract VisualStudioDocumentTracker GetTracker(ITextBuffer textBuffer); } } diff --git a/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioRazorParser.cs b/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioRazorParser.cs index b101361cf1..c0e75084f7 100644 --- a/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioRazorParser.cs +++ b/src/Microsoft.VisualStudio.Editor.Razor/VisualStudioRazorParser.cs @@ -2,15 +2,17 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Timers; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Legacy; using Microsoft.CodeAnalysis.Razor; using Microsoft.VisualStudio.Language.Intellisense; using Microsoft.VisualStudio.Text; -using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Operations; using ITextBuffer = Microsoft.VisualStudio.Text.ITextBuffer; -using Timer = System.Timers.Timer; +using Timer = System.Threading.Timer; namespace Microsoft.VisualStudio.Editor.Razor { @@ -18,13 +20,17 @@ namespace Microsoft.VisualStudio.Editor.Razor { // Internal for testing. internal readonly ITextBuffer _textBuffer; - internal readonly Timer _idleTimer; + internal TimeSpan IdleDelay = TimeSpan.FromSeconds(3); + internal Timer _idleTimer; - private const int IdleDelay = 3000; + private readonly object IdleLock = new object(); private readonly ICompletionBroker _completionBroker; + private readonly VisualStudioDocumentTrackerFactory _documentTrackerFactory; private readonly BackgroundParser _parser; private readonly ForegroundDispatcher _dispatcher; + private readonly ErrorReporter _errorReporter; private RazorSyntaxTreePartialParser _partialParser; + private BraceSmartIndenter _braceSmartIndenter; // For testing only internal VisualStudioRazorParser(RazorCodeDocument codeDocument) @@ -32,7 +38,15 @@ namespace Microsoft.VisualStudio.Editor.Razor CodeDocument = codeDocument; } - public VisualStudioRazorParser(ForegroundDispatcher dispatcher, ITextBuffer buffer, RazorTemplateEngine templateEngine, string filePath, ICompletionBroker completionBroker) + public VisualStudioRazorParser( + ForegroundDispatcher dispatcher, + ITextBuffer buffer, + RazorTemplateEngine templateEngine, + string filePath, + ErrorReporter errorReporter, + ICompletionBroker completionBroker, + VisualStudioDocumentTrackerFactory documentTrackerFactory, + IEditorOperationsFactoryService editorOperationsFactory) { if (dispatcher == null) { @@ -54,20 +68,36 @@ namespace Microsoft.VisualStudio.Editor.Razor throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(filePath)); } + if (errorReporter == null) + { + throw new ArgumentNullException(nameof(errorReporter)); + } + if (completionBroker == null) { throw new ArgumentNullException(nameof(completionBroker)); } + if (documentTrackerFactory == null) + { + throw new ArgumentNullException(nameof(documentTrackerFactory)); + } + + if (editorOperationsFactory == null) + { + throw new ArgumentNullException(nameof(editorOperationsFactory)); + } + _dispatcher = dispatcher; TemplateEngine = templateEngine; FilePath = filePath; + _errorReporter = errorReporter; _textBuffer = buffer; _completionBroker = completionBroker; + _documentTrackerFactory = documentTrackerFactory; _textBuffer.Changed += TextBuffer_OnChanged; + _braceSmartIndenter = new BraceSmartIndenter(_dispatcher, _textBuffer, _documentTrackerFactory, editorOperationsFactory); _parser = new BackgroundParser(templateEngine, filePath); - _idleTimer = new Timer(IdleDelay); - _idleTimer.Elapsed += Onidle; _parser.ResultsReady += OnResultsReady; _parser.Start(); @@ -95,83 +125,126 @@ namespace Microsoft.VisualStudio.Editor.Razor _dispatcher.AssertForegroundThread(); _textBuffer.Changed -= TextBuffer_OnChanged; + _braceSmartIndenter.Dispose(); _parser.Dispose(); - _idleTimer.Dispose(); + + StopIdleTimer(); } - private void TextBuffer_OnChanged(object sender, TextContentChangedEventArgs contentChange) + // Internal for testing + internal void StartIdleTimer() { _dispatcher.AssertForegroundThread(); - if (contentChange.Changes.Count > 0) + lock (IdleLock) { - // 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) + if (_idleTimer == null) { - 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(); - } + // Timer will fire after a fixed delay, but only once. + _idleTimer = new Timer(Timer_Tick, null, IdleDelay, Timeout.InfiniteTimeSpan); } } } - private void Onidle(object sender, ElapsedEventArgs e) + // Internal for testing + internal void StopIdleTimer() { - _dispatcher.AssertBackgroundThread(); + // Can be called from any thread. - var textViews = Array.Empty(); + lock (IdleLock) + { + if (_idleTimer != null) + { + _idleTimer.Dispose(); + _idleTimer = null; + } + } + } - foreach (var textView in textViews) + private void TextBuffer_OnChanged(object sender, TextContentChangedEventArgs args) + { + _dispatcher.AssertForegroundThread(); + + if (args.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). + StopIdleTimer(); + } + + if (!args.TextChangeOccurred(out var changeInformation)) + { + return; + } + + var change = new SourceChange(changeInformation.firstChange.OldPosition, changeInformation.oldText.Length, changeInformation.newText); + var snapshot = args.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) + { + StartIdleTimer(); + } + } + + private void OnIdle(object state) + { + _dispatcher.AssertForegroundThread(); + + var documentTracker = _documentTrackerFactory.GetTracker(_textBuffer); + + if (documentTracker == null) + { + Debug.Fail("Document tracker should never be null when checking idle state."); + return; + } + + foreach (var textView in documentTracker.TextViews) { if (_completionBroker.IsCompletionActive(textView)) { + // Completion list is still active, need to re-start timer. + StartIdleTimer(); return; } } - _idleTimer.Stop(); Reparse(); } + private async void Timer_Tick(object state) + { + try + { + _dispatcher.AssertBackgroundThread(); + + StopIdleTimer(); + + // We need to get back to the UI thread to properly check if a completion is active. + await Task.Factory.StartNew(OnIdle, null, CancellationToken.None, TaskCreationOptions.None, _dispatcher.ForegroundScheduler); + } + catch (Exception ex) + { + // This is something totally unexpected, let's just send it over to the workspace. + await Task.Factory.StartNew(() => _errorReporter.ReportError(ex), CancellationToken.None, TaskCreationOptions.None, _dispatcher.ForegroundScheduler); + } + } + private void OnResultsReady(object sender, DocumentStructureChangedEventArgs args) { _dispatcher.AssertBackgroundThread(); diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/DefaultVisualStudioDocumentTracker.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/DefaultVisualStudioDocumentTracker.cs index b852a11e48..a83d7e769c 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/DefaultVisualStudioDocumentTracker.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/DefaultVisualStudioDocumentTracker.cs @@ -79,6 +79,19 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor.Editor public override Workspace Workspace => _workspace; + public override ITextView GetFocusedTextView() + { + for (var i = 0; i < TextViews.Count; i++) + { + if (TextViews[i].HasAggregateFocus) + { + return TextViews[i]; + } + } + + return null; + } + public void Subscribe() { // Fundamentally we have a Razor half of the world as as soon as the document is open - and then later diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/DefaultVisualStudioDocumentTrackerFactory.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/DefaultVisualStudioDocumentTrackerFactory.cs index 8119556d04..2ed1a1ad23 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/DefaultVisualStudioDocumentTrackerFactory.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/Editor/DefaultVisualStudioDocumentTrackerFactory.cs @@ -114,6 +114,31 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor.Editor return tracker; } + public override VisualStudioDocumentTracker GetTracker(ITextBuffer textBuffer) + { + if (textBuffer == null) + { + throw new ArgumentNullException(nameof(textBuffer)); + } + + _foregroundDispatcher.AssertForegroundThread(); + + if (!textBuffer.IsRazorBuffer()) + { + // Not a Razor buffer. + return null; + } + + // A little bit of hardening here, to make sure our assumptions are correct. + DefaultVisualStudioDocumentTracker tracker; + if (!textBuffer.Properties.TryGetProperty(typeof(VisualStudioDocumentTracker), out tracker)) + { + Debug.Fail("The document tracker should be initialized"); + } + + return tracker; + } + public void SubjectBuffersConnected(IWpfTextView textView, ConnectionReason reason, Collection subjectBuffers) { if (textView == null) diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test.Common/StringTextSnapshot.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test.Common/StringTextSnapshot.cs index 5072f89ccd..16295be713 100644 --- a/test/Microsoft.VisualStudio.Editor.Razor.Test.Common/StringTextSnapshot.cs +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test.Common/StringTextSnapshot.cs @@ -4,15 +4,41 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using Microsoft.AspNetCore.Razor.Language.Legacy; using Microsoft.VisualStudio.Utilities; namespace Microsoft.VisualStudio.Text { public class StringTextSnapshot : ITextSnapshot { + private readonly List _lines; + public StringTextSnapshot(string content) { Content = content; + _lines = new List(); + + var start = 0; + var delimiterIndex = 0; + while (delimiterIndex != -1) + { + var delimiterLength = 2; + delimiterIndex = Content.IndexOf("\r\n", start); + + if (delimiterIndex == -1) + { + delimiterLength = 1; + delimiterIndex = Content.IndexOfAny(ParserHelpers.NewLineCharacters, start); + } + + var nextLineStartIndex = delimiterIndex != -1 ? delimiterIndex + delimiterLength : Content.Length; + + var lineText = Content.Substring(start, nextLineStartIndex - start); + _lines.Add(new SnapshotLine(lineText, start, this)); + + start = nextLineStartIndex; + } } public string Content { get; } @@ -23,7 +49,7 @@ namespace Microsoft.VisualStudio.Text public int Length => Content.Length; - public VisualStudio.Text.ITextBuffer TextBuffer => throw new NotImplementedException(); + public ITextBuffer TextBuffer => throw new NotImplementedException(); public IContentType ContentType => throw new NotImplementedException(); @@ -39,6 +65,18 @@ namespace Microsoft.VisualStudio.Text public char[] ToCharArray(int startIndex, int length) => Content.ToCharArray(); + public ITextSnapshotLine GetLineFromPosition(int position) + { + var matchingLine = _lines.FirstOrDefault(line => line.Start + line.LengthIncludingLineBreak > position); + + if (position < 0 || matchingLine == null) + { + throw new ArgumentOutOfRangeException(); + } + + return matchingLine; + } + public ITrackingPoint CreateTrackingPoint(int position, PointTrackingMode trackingMode) => throw new NotImplementedException(); public ITrackingPoint CreateTrackingPoint(int position, PointTrackingMode trackingMode, TrackingFidelityMode trackingFidelity) => throw new NotImplementedException(); @@ -53,8 +91,6 @@ namespace Microsoft.VisualStudio.Text 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(); @@ -71,7 +107,7 @@ namespace Microsoft.VisualStudio.Text public int Length => throw new NotImplementedException(); - public VisualStudio.Text.ITextBuffer TextBuffer => throw new NotImplementedException(); + public ITextBuffer TextBuffer => throw new NotImplementedException(); public int VersionNumber => throw new NotImplementedException(); @@ -96,5 +132,55 @@ namespace Microsoft.VisualStudio.Text public bool IncludesLineChanges => false; } } + + private class SnapshotLine : ITextSnapshotLine + { + private readonly string _contentWithLineBreak; + private readonly string _content; + + public SnapshotLine(string contentWithLineBreak, int start, ITextSnapshot owner) + { + _contentWithLineBreak = contentWithLineBreak; + _content = contentWithLineBreak; + + if (_content.EndsWith("\r\n")) + { + _content = _content.Substring(0, _content.Length - 2); + } + else if(_content.Length > 0 && ParserHelpers.NewLineCharacters.Contains(_content[_content.Length - 1])) + { + _content = _content.Substring(0, _content.Length - 1); + } + + Start = new SnapshotPoint(owner, start); + Snapshot = owner; + } + + public ITextSnapshot Snapshot { get; } + + public SnapshotPoint Start { get; } + + public int Length => _content.Length; + + public int LengthIncludingLineBreak => _contentWithLineBreak.Length; + + public int LineBreakLength => _contentWithLineBreak.Length - _content.Length; + + public string GetText() => _content; + + public string GetLineBreakText() => _contentWithLineBreak.Substring(_content.Length); + + public string GetTextIncludingLineBreak() => _contentWithLineBreak; + + public int LineNumber => throw new NotImplementedException(); + + public SnapshotSpan Extent => throw new NotImplementedException(); + + public SnapshotSpan ExtentIncludingLineBreak => throw new NotImplementedException(); + + public SnapshotPoint End => throw new NotImplementedException(); + + public SnapshotPoint EndIncludingLineBreak => throw new NotImplementedException(); + } } } diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/BraceSmartIndenterIntegrationTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/BraceSmartIndenterIntegrationTest.cs new file mode 100644 index 0000000000..0287895386 --- /dev/null +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/BraceSmartIndenterIntegrationTest.cs @@ -0,0 +1,88 @@ +// 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.VisualStudio.Test; +using Microsoft.VisualStudio.Text; +using Xunit; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + public class BraceSmartIndenterIntegrationTest : BraceSmartIndenterTestBase + { + [ForegroundFact] + public void TextBuffer_OnPostChanged_IndentsInbetweenBraces_BaseIndentation() + { + // Arrange + var change = Environment.NewLine; + var initialSnapshot = new StringTextSnapshot("@{ }"); + var afterChangeSnapshot = new StringTextSnapshot("@{ " + change + "}"); + var edit = new TestEdit(3, 0, initialSnapshot, change.Length, afterChangeSnapshot, change); + var expectedIndentResult = "@{ " + change + change + "}"; + + var caret = CreateCaretFrom(3 + change.Length, afterChangeSnapshot); + TestTextBuffer textBuffer = null; + var focusedTextView = CreateFocusedTextView(() => textBuffer, caret); + var documentTracker = CreateDocumentTracker(() => textBuffer, focusedTextView); + textBuffer = CreateTextBuffer(initialSnapshot, documentTracker); + var editorOperationsFactory = CreateOperationsFactoryService(); + var braceSmartIndenter = new BraceSmartIndenter(Dispatcher, textBuffer, CreateDocumentTrackerFactory(() => textBuffer, documentTracker), editorOperationsFactory); + + // Act + textBuffer.ApplyEdit(edit); + + // Assert + Assert.Equal(expectedIndentResult, ((StringTextSnapshot)textBuffer.CurrentSnapshot).Content); + } + + [ForegroundFact] + public void TextBuffer_OnPostChanged_IndentsInbetweenBraces_OneLevelOfIndentation() + { + // Arrange + var change = "\r"; + var initialSnapshot = new StringTextSnapshot(" @{ }"); + var afterChangeSnapshot = new StringTextSnapshot(" @{ " + change + "}"); + var edit = new TestEdit(7, 0, initialSnapshot, change.Length, afterChangeSnapshot, change); + var expectedIndentResult = " @{ " + change + change + " }"; + + var caret = CreateCaretFrom(7 + change.Length, afterChangeSnapshot); + TestTextBuffer textBuffer = null; + var focusedTextView = CreateFocusedTextView(() => textBuffer, caret); + var documentTracker = CreateDocumentTracker(() => textBuffer, focusedTextView); + textBuffer = CreateTextBuffer(initialSnapshot, documentTracker); + var editorOperationsFactory = CreateOperationsFactoryService(); + var braceSmartIndenter = new BraceSmartIndenter(Dispatcher, textBuffer, CreateDocumentTrackerFactory(() => textBuffer, documentTracker), editorOperationsFactory); + + // Act + textBuffer.ApplyEdit(edit); + + // Assert + Assert.Equal(expectedIndentResult, ((StringTextSnapshot)textBuffer.CurrentSnapshot).Content); + } + + [ForegroundFact] + public void TextBuffer_OnPostChanged_IndentsInbetweenDirectiveBlockBraces() + { + // Arrange + var change = Environment.NewLine; + var initialSnapshot = new StringTextSnapshot(" @functions {}"); + var afterChangeSnapshot = new StringTextSnapshot(" @functions {" + change + "}"); + var edit = new TestEdit(16, 0, initialSnapshot, change.Length, afterChangeSnapshot, change); + var expectedIndentResult = " @functions {" + change + change + " }"; + + var caret = CreateCaretFrom(16 + change.Length, afterChangeSnapshot); + TestTextBuffer textBuffer = null; + var focusedTextView = CreateFocusedTextView(() => textBuffer, caret); + var documentTracker = CreateDocumentTracker(() => textBuffer, focusedTextView); + textBuffer = CreateTextBuffer(initialSnapshot, documentTracker); + var editorOperationsFactory = CreateOperationsFactoryService(); + var braceSmartIndenter = new BraceSmartIndenter(Dispatcher, textBuffer, CreateDocumentTrackerFactory(() => textBuffer, documentTracker), editorOperationsFactory); + + // Act + textBuffer.ApplyEdit(edit); + + // Assert + Assert.Equal(expectedIndentResult, ((StringTextSnapshot)textBuffer.CurrentSnapshot).Content); + } + } +} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/BraceSmartIndenterTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/BraceSmartIndenterTest.cs new file mode 100644 index 0000000000..5fa523db88 --- /dev/null +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/BraceSmartIndenterTest.cs @@ -0,0 +1,299 @@ +// 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.VisualStudio.Test; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Operations; +using Moq; +using Xunit; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + public class BraceSmartIndenterTest : BraceSmartIndenterTestBase + { + [Fact] + public void InsertIndent_InsertsProvidedIndentIntoBuffer() + { + // Arrange + var initialSnapshot = new StringTextSnapshot("@{ \n}"); + var expectedIndentResult = "@{ anything\n}"; + ITextBuffer textBuffer = null; + var textView = CreateFocusedTextView(() => textBuffer); + var documentTracker = CreateDocumentTracker(() => textBuffer, textView); + textBuffer = CreateTextBuffer(initialSnapshot, documentTracker); + + // Act + BraceSmartIndenter.InsertIndent(3, "anything", textBuffer); + + // Assert + Assert.Equal(expectedIndentResult, ((StringTextSnapshot)textBuffer.CurrentSnapshot).Content); + } + + [Fact] + public void RestoreCaretTo_PlacesCursorAtProvidedPosition() + { + // Arrange + var initialSnapshot = new StringTextSnapshot("@{ \n\n}"); + var bufferPosition = new VirtualSnapshotPoint(initialSnapshot, 4); + var caret = new Mock(); + caret.Setup(c => c.MoveTo(It.IsAny())) + .Callback(point => + { + Assert.Equal(3, point.Position); + Assert.Same(initialSnapshot, point.Snapshot); + }); + ITextBuffer textBuffer = null; + var textView = CreateFocusedTextView(() => textBuffer, caret.Object); + var documentTracker = CreateDocumentTracker(() => textBuffer, textView); + textBuffer = CreateTextBuffer(initialSnapshot, documentTracker); + + // Act + BraceSmartIndenter.RestoreCaretTo(3, textView); + + // Assert + caret.VerifyAll(); + } + + [Fact] + public void TriggerSmartIndent_ForcesEditorToMoveToEndOfLine() + { + // Arrange + var textView = CreateFocusedTextView(); + var editorOperations = new Mock(); + editorOperations.Setup(operations => operations.MoveToEndOfLine(false)); + var editorOperationsFactory = new Mock(); + editorOperationsFactory.Setup(factory => factory.GetEditorOperations(textView)) + .Returns(editorOperations.Object); + var smartIndenter = new BraceSmartIndenter(Dispatcher, new Mock().Object, new Mock().Object, editorOperationsFactory.Object); + + // Act + smartIndenter.TriggerSmartIndent(textView); + + // Assert + editorOperations.VerifyAll(); + } + + [Fact] + public void AfterClosingBrace_ContentAfterBrace_ReturnsFalse() + { + // Arrange + var fileSnapshot = new StringTextSnapshot("@functions\n{a\n}"); + var changePosition = 13; + var line = fileSnapshot.GetLineFromPosition(changePosition); + + // Act & Assert + Assert.False(BraceSmartIndenter.BeforeClosingBrace(0, line)); + } + + [Theory] + [InlineData("@functions\n{\n}")] + [InlineData("@functions\n{ \n}")] + [InlineData("@functions\n { \n}")] + [InlineData("@functions\n\t\t{\t\t\n}")] + public void AfterClosingBrace_BraceBeforePosition_ReturnsTrue(string fileContent) + { + // Arrange + var fileSnapshot = new StringTextSnapshot(fileContent); + var changePosition = fileContent.Length - 3 /* \n} */; + var line = fileSnapshot.GetLineFromPosition(changePosition); + + // Act & Assert + Assert.True(BraceSmartIndenter.AfterOpeningBrace(line.Length - 1, line)); + } + + [Fact] + public void BeforeClosingBrace_ContentPriorToBrace_ReturnsFalse() + { + // Arrange + var fileSnapshot = new StringTextSnapshot("@functions\n{\na}"); + var changePosition = 12; + var line = fileSnapshot.GetLineFromPosition(changePosition + 1 /* \n */); + + // Act & Assert + Assert.False(BraceSmartIndenter.BeforeClosingBrace(0, line)); + } + + [Theory] + [InlineData("@functions\n{\n}")] + [InlineData("@functions\n{\n }")] + [InlineData("@functions\n{\n } ")] + [InlineData("@functions\n{\n\t\t } ")] + public void BeforeClosingBrace_BraceAfterPosition_ReturnsTrue(string fileContent) + { + // Arrange + var fileSnapshot = new StringTextSnapshot(fileContent); + var changePosition = 12; + var line = fileSnapshot.GetLineFromPosition(changePosition + 1 /* \n */); + + // Act & Assert + Assert.True(BraceSmartIndenter.BeforeClosingBrace(0, line)); + } + + [ForegroundFact] + public void TextBuffer_OnChanged_NoopsIfNoChanges() + { + // Arrange + var textBuffer = new Mock(); + var editorOperationsFactory = new Mock(); + var changeCollection = new TestTextChangeCollection(); + var textContentChangeArgs = new TestTextContentChangedEventArgs(changeCollection); + var documentTrackerFactory = new Mock(); + var braceSmartIndenter = new BraceSmartIndenter(Dispatcher, textBuffer.Object, documentTrackerFactory.Object, editorOperationsFactory.Object); + + // Act & Assert + braceSmartIndenter.TextBuffer_OnChanged(null, textContentChangeArgs); + } + + [ForegroundFact] + public void TextBuffer_OnChanged_NoopsIfChangesThatResultInNoChange() + { + // Arrange + var initialSnapshot = new StringTextSnapshot("Hello World"); + var textBuffer = new TestTextBuffer(initialSnapshot); + var edit = new TestEdit(0, 0, initialSnapshot, 0, initialSnapshot, string.Empty); + var editorOperationsFactory = new Mock(); + var documentTrackerFactory = new Mock(); + var braceSmartIndenter = new BraceSmartIndenter(Dispatcher, textBuffer, documentTrackerFactory.Object, editorOperationsFactory.Object); + + // Act & Assert + textBuffer.ApplyEdits(edit, edit); + } + + [Fact] + public void TryCreateIndentationContext_ReturnsFalseIfNoFocusedTextView() + { + // Arrange + var snapshot = new StringTextSnapshot(Environment.NewLine + "Hello World"); + ITextBuffer textBuffer = null; + var documentTracker = CreateDocumentTracker(() => textBuffer, focusedTextView: null); + textBuffer = CreateTextBuffer(snapshot, documentTracker); + + // Act + var result = BraceSmartIndenter.TryCreateIndentationContext(0, Environment.NewLine.Length, Environment.NewLine, documentTracker, out var context); + + // Assert + Assert.Null(context); + Assert.False(result); + } + + [Fact] + public void TryCreateIndentationContext_ReturnsFalseIfTextChangeIsNotNewline() + { + // Arrange + var snapshot = new StringTextSnapshot("This Hello World"); + ITextBuffer textBuffer = null; + var focusedTextView = CreateFocusedTextView(() => textBuffer); + var documentTracker = CreateDocumentTracker(() => textBuffer, focusedTextView); + textBuffer = CreateTextBuffer(snapshot, documentTracker); + + // Act + var result = BraceSmartIndenter.TryCreateIndentationContext(0, 5, "This ", documentTracker, out var context); + + // Assert + Assert.Null(context); + Assert.False(result); + } + + [Fact] + public void TryCreateIndentationContext_ReturnsFalseIfNewLineIsNotPrecededByOpenBrace_FileStart() + { + // Arrange + var initialSnapshot = new StringTextSnapshot(Environment.NewLine + "Hello World"); + ITextBuffer textBuffer = null; + var focusedTextView = CreateFocusedTextView(() => textBuffer); + var documentTracker = CreateDocumentTracker(() => textBuffer, focusedTextView); + textBuffer = CreateTextBuffer(initialSnapshot, documentTracker); + + // Act + var result = BraceSmartIndenter.TryCreateIndentationContext(0, Environment.NewLine.Length, Environment.NewLine, documentTracker, out var context); + + // Assert + Assert.Null(context); + Assert.False(result); + } + + [Fact] + public void TryCreateIndentationContext_ReturnsFalseIfNewLineIsNotPrecededByOpenBrace_MidFile() + { + // Arrange + var initialSnapshot = new StringTextSnapshot("Hello\u0085World"); + ITextBuffer textBuffer = null; + var focusedTextView = CreateFocusedTextView(() => textBuffer); + var documentTracker = CreateDocumentTracker(() => textBuffer, focusedTextView); + textBuffer = CreateTextBuffer(initialSnapshot, documentTracker); + + // Act + var result = BraceSmartIndenter.TryCreateIndentationContext(5, 1, "\u0085", documentTracker, out var context); + + // Assert + Assert.Null(context); + Assert.False(result); + } + + [Fact] + public void TryCreateIndentationContext_ReturnsFalseIfNewLineIsNotFollowedByCloseBrace() + { + // Arrange + var initialSnapshot = new StringTextSnapshot("@{ " + Environment.NewLine + "World"); + ITextBuffer textBuffer = null; + var focusedTextView = CreateFocusedTextView(() => textBuffer); + var documentTracker = CreateDocumentTracker(() => textBuffer, focusedTextView); + textBuffer = CreateTextBuffer(initialSnapshot, documentTracker); + + // Act + var result = BraceSmartIndenter.TryCreateIndentationContext(3, Environment.NewLine.Length, Environment.NewLine, documentTracker, out var context); + + // Assert + Assert.Null(context); + Assert.False(result); + } + + [Fact] + public void TryCreateIndentationContext_ReturnsTrueIfNewLineIsSurroundedByBraces() + { + // Arrange + var initialSnapshot = new StringTextSnapshot("@{ \n}"); + ITextBuffer textBuffer = null; + var focusedTextView = CreateFocusedTextView(() => textBuffer); + var documentTracker = CreateDocumentTracker(() => textBuffer, focusedTextView); + textBuffer = CreateTextBuffer(initialSnapshot, documentTracker); + + // Act + var result = BraceSmartIndenter.TryCreateIndentationContext(3, 1, "\n", documentTracker, out var context); + + // Assert + Assert.NotNull(context); + Assert.Same(focusedTextView, context.FocusedTextView); + Assert.Equal(3, context.ChangePosition); + Assert.True(result); + } + + protected class TestTextContentChangedEventArgs : TextContentChangedEventArgs + { + public TestTextContentChangedEventArgs(INormalizedTextChangeCollection changeCollection) + : base(CreateBeforeSnapshot(changeCollection), new Mock().Object, EditOptions.DefaultMinimalChange, null) + { + } + + protected static ITextSnapshot CreateBeforeSnapshot(INormalizedTextChangeCollection collection) + { + var version = new Mock(); + version.Setup(v => v.Changes) + .Returns(collection); + var snapshot = new Mock(); + snapshot.Setup(obj => obj.Version) + .Returns(version.Object); + + return snapshot.Object; + } + } + + protected class TestTextChangeCollection : List, INormalizedTextChangeCollection + { + public bool IncludesLineChanges => throw new NotImplementedException(); + } + } +} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/BraceSmartIndenterTestBase.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/BraceSmartIndenterTestBase.cs new file mode 100644 index 0000000000..747f8d8713 --- /dev/null +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/BraceSmartIndenterTestBase.cs @@ -0,0 +1,87 @@ +// 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.VisualStudio.Test; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Operations; +using Moq; +using Xunit; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + public class BraceSmartIndenterTestBase : ForegroundDispatcherTestBase + { + protected static VisualStudioDocumentTracker CreateDocumentTracker(Func bufferAccessor, ITextView focusedTextView) + { + var tracker = new Mock(); + tracker.Setup(t => t.TextBuffer) + .Returns(bufferAccessor); + tracker.Setup(t => t.GetFocusedTextView()) + .Returns(focusedTextView); + + return tracker.Object; + } + + protected static VisualStudioDocumentTrackerFactory CreateDocumentTrackerFactory(Func bufferAccessor, VisualStudioDocumentTracker documentTracker) + { + var trackerFactory = new Mock(); + trackerFactory.Setup(factory => factory.GetTracker(It.IsAny())) + .Returns(documentTracker); + + return trackerFactory.Object; + } + + protected static ITextView CreateFocusedTextView(Func textBufferAccessor = null, ITextCaret caret = null) + { + var focusedTextView = new Mock(); + focusedTextView.Setup(textView => textView.HasAggregateFocus) + .Returns(true); + + if (textBufferAccessor != null) + { + focusedTextView.Setup(textView => textView.TextBuffer) + .Returns(textBufferAccessor); + } + + if (caret != null) + { + focusedTextView.Setup(textView => textView.Caret) + .Returns(caret); + } + + return focusedTextView.Object; + } + + protected static ITextCaret CreateCaretFrom(int position, ITextSnapshot snapshot) + { + var bufferPosition = new VirtualSnapshotPoint(snapshot, position); + var caret = new Mock(); + caret.Setup(c => c.Position) + .Returns(new CaretPosition(bufferPosition, new Mock().Object, PositionAffinity.Predecessor)); + caret.Setup(c => c.MoveTo(It.IsAny())); + + return caret.Object; + } + + protected static IEditorOperationsFactoryService CreateOperationsFactoryService() + { + var editorOperations = new Mock(); + editorOperations.Setup(operations => operations.MoveToEndOfLine(false)); + var editorOperationsFactory = new Mock(); + editorOperationsFactory.Setup(factory => factory.GetEditorOperations(It.IsAny())) + .Returns(editorOperations.Object); + + return editorOperationsFactory.Object; + } + + protected static TestTextBuffer CreateTextBuffer(ITextSnapshot initialSnapshot, VisualStudioDocumentTracker documentTracker) + { + var textBuffer = new TestTextBuffer(initialSnapshot); + textBuffer.Properties.AddProperty(typeof(VisualStudioDocumentTracker), documentTracker); + + return textBuffer; + } + } +} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/Infrastructure/TestEdit.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/Infrastructure/TestEdit.cs new file mode 100644 index 0000000000..33dd99b7eb --- /dev/null +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/Infrastructure/TestEdit.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.VisualStudio.Text; + +namespace Microsoft.VisualStudio.Test +{ + public class TestEdit + { + public TestEdit(SourceChange change, ITextSnapshot oldSnapshot, ITextSnapshot newSnapshot) + { + Change = change; + OldSnapshot = oldSnapshot; + NewSnapshot = newSnapshot; + } + + 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; } + + public ITextSnapshot OldSnapshot { get; } + + public ITextSnapshot NewSnapshot { get; } + } +} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/Infrastructure/TestTextBuffer.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/Infrastructure/TestTextBuffer.cs new file mode 100644 index 0000000000..5f7a658b8c --- /dev/null +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/Infrastructure/TestTextBuffer.cs @@ -0,0 +1,149 @@ +// 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.VisualStudio.Text; +using Microsoft.VisualStudio.Utilities; + +namespace Microsoft.VisualStudio.Test +{ + public class TestTextBuffer : 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) => { }; + Properties = new PropertyCollection(); + } + + public void ApplyEdit(TestEdit edit) + { + ApplyEdits(edit); + } + + public void ApplyEdits(params TestEdit[] edits) + { + var args = new TextContentChangedEventArgs(edits[0].OldSnapshot, edits[edits.Length - 1].NewSnapshot, new EditOptions(), null); + foreach (var edit in edits) + { + args.Changes.Add(new TestTextChange(edit.Change)); + } + + _currentSnapshot = edits[edits.Length - 1].NewSnapshot; + + Changed?.Invoke(this, args); + PostChanged?.Invoke(null, null); + + ReadOnlyRegionsChanged?.Invoke(null, null); + ChangedLowPriority?.Invoke(null, null); + ChangedHighPriority?.Invoke(null, null); + Changing?.Invoke(null, null); + ContentTypeChanged?.Invoke(null, null); + } + + public ITextSnapshot CurrentSnapshot => _currentSnapshot; + + public PropertyCollection Properties { get; } + + 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 bool EditInProgress => throw new NotImplementedException(); + + public IContentType ContentType => throw new NotImplementedException(); + + public ITextEdit CreateEdit() => new BufferEdit(this); + + 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 IReadOnlyRegionEdit CreateReadOnlyRegionEdit() => throw new NotImplementedException(); + + public ITextSnapshot Delete(Span deleteSpan) => throw new NotImplementedException(); + + public NormalizedSpanCollection GetReadOnlyExtents(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(Span span) => throw new NotImplementedException(); + + public bool IsReadOnly(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 BufferEdit : ITextEdit + { + private readonly TestTextBuffer _textBuffer; + private readonly List _edits; + + public BufferEdit(TestTextBuffer textBuffer) + { + _textBuffer = textBuffer; + _edits = new List(); + } + + public bool HasEffectiveChanges => throw new NotImplementedException(); + + public bool HasFailedChanges => throw new NotImplementedException(); + + public ITextSnapshot Snapshot => throw new NotImplementedException(); + + public bool Canceled => throw new NotImplementedException(); + + public ITextSnapshot Apply() + { + _textBuffer.ApplyEdits(_edits.ToArray()); + _edits.Clear(); + + return _textBuffer.CurrentSnapshot; + } + + public bool Insert(int position, string text) + { + var initialSnapshot = (StringTextSnapshot)_textBuffer.CurrentSnapshot; + var newText = initialSnapshot.Content.Insert(position, text); + var changedSnapshot = new StringTextSnapshot(newText); + var edit = new TestEdit(position, 0, initialSnapshot, text.Length, changedSnapshot, text); + _edits.Add(edit); + + return true; + } + + public void Cancel() => throw new NotImplementedException(); + + public bool Delete(Span deleteSpan) => throw new NotImplementedException(); + + public bool Delete(int startPosition, int charsToDelete) => throw new NotImplementedException(); + + public void Dispose() => throw new NotImplementedException(); + + public bool Insert(int position, char[] characterBuffer, int startIndex, int length) => throw new NotImplementedException(); + + public bool Replace(Span replaceSpan, string replaceWith) => throw new NotImplementedException(); + + public bool Replace(int startPosition, int charsToReplace, string replaceWith) => throw new NotImplementedException(); + } + } +} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/Infrastructure/TestTextChange.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/Infrastructure/TestTextChange.cs new file mode 100644 index 0000000000..7b1167c2c5 --- /dev/null +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/Infrastructure/TestTextChange.cs @@ -0,0 +1,50 @@ +// 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.Test +{ + public class TestTextChange : ITextChange + { + public TestTextChange(TestEdit edit) : this(edit.Change) + { + } + + public TestTextChange(SourceChange change) + { + var changeSpan = change.Span; + + OldPosition = changeSpan.AbsoluteIndex; + NewPosition = OldPosition; + OldEnd = changeSpan.AbsoluteIndex + changeSpan.Length; + NewEnd = changeSpan.AbsoluteIndex + change.NewText.Length; + } + + public int OldPosition { get; } + + public int NewPosition { get; } + + public int OldEnd { get; } + + public int NewEnd { get; } + + public Span OldSpan => throw new NotImplementedException(); + + public Span NewSpan => throw new NotImplementedException(); + + public int Delta => throw new NotImplementedException(); + + 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(); + } +} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorSyntaxTreePartialParserTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorSyntaxTreePartialParserTest.cs index a99603fe9b..544f74a91d 100644 --- a/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorSyntaxTreePartialParserTest.cs +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorSyntaxTreePartialParserTest.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.Razor.Extensions; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.VisualStudio.Test; using Microsoft.VisualStudio.Text; using Xunit; @@ -574,12 +575,7 @@ namespace Microsoft.VisualStudio.Editor.Razor 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, - }; + return new TestEdit(sourceChange, oldSnapshot, changedSnapshot); } private static RazorTemplateEngine CreateTemplateEngine( @@ -607,25 +603,5 @@ namespace Microsoft.VisualStudio.Editor.Razor 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.Editor.Razor.Test/TextContentChangedEventArgsExtensionsTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/TextContentChangedEventArgsExtensionsTest.cs new file mode 100644 index 0000000000..4882a49a13 --- /dev/null +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/TextContentChangedEventArgsExtensionsTest.cs @@ -0,0 +1,96 @@ +// 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.VisualStudio.Test; +using Xunit; + +namespace Microsoft.VisualStudio.Text +{ + public class TextContentChangedEventArgsExtensionsTest + { + [Fact] + public void TextChangeOccurred_NoChanges_ReturnsFalse() + { + // Arrange + var before = new StringTextSnapshot(string.Empty); + var after = new StringTextSnapshot(string.Empty); + var testArgs = new TestTextContentChangedEventArgs(before, after); + + // Act + var result = testArgs.TextChangeOccurred(out var changeInformation); + + // Assert + Assert.False(result); + } + + [Fact] + public void TextChangeOccurred_CancelingChanges_ReturnsFalse() + { + // Arrange + var before = new StringTextSnapshot("by"); + before.Version.Changes.Add(new TestTextChange(new SourceChange(0, 2, "hi"))); + before.Version.Changes.Add(new TestTextChange(new SourceChange(0, 2, "by"))); + var after = new StringTextSnapshot("by"); + var testArgs = new TestTextContentChangedEventArgs(before, after); + + // Act + var result = testArgs.TextChangeOccurred(out var changeInformation); + + // Assert + Assert.False(result); + } + + [Fact] + public void TextChangeOccurred_SingleChange_ReturnsTrue() + { + // Arrange + var before = new StringTextSnapshot("by"); + var firstChange = new TestTextChange(new SourceChange(0, 2, "hi")); + before.Version.Changes.Add(firstChange); + var after = new StringTextSnapshot("hi"); + var testArgs = new TestTextContentChangedEventArgs(before, after); + + // Act + var result = testArgs.TextChangeOccurred(out var changeInformation); + + // Assert + Assert.True(result); + Assert.Same(firstChange, changeInformation.firstChange); + Assert.Equal(firstChange, changeInformation.lastChange); + Assert.Equal("hi", changeInformation.newText); + Assert.Equal("by", changeInformation.oldText); + } + + [Fact] + public void TextChangeOccurred_MultipleChanges_ReturnsTrue() + { + // Arrange + var before = new StringTextSnapshot("by by"); + var firstChange = new TestTextChange(new SourceChange(0, 2, "hi")); + before.Version.Changes.Add(firstChange); + var lastChange = new TestTextChange(new SourceChange(3, 2, "hi")); + before.Version.Changes.Add(lastChange); + var after = new StringTextSnapshot("hi hi"); + var testArgs = new TestTextContentChangedEventArgs(before, after); + + // Act + var result = testArgs.TextChangeOccurred(out var changeInformation); + + // Assert + Assert.True(result); + Assert.Same(firstChange, changeInformation.firstChange); + Assert.Equal(lastChange, changeInformation.lastChange); + Assert.Equal("hi hi", changeInformation.newText); + Assert.Equal("by by", changeInformation.oldText); + } + + private class TestTextContentChangedEventArgs : TextContentChangedEventArgs + { + public TestTextContentChangedEventArgs(ITextSnapshot before, ITextSnapshot after) + : base(before, after, EditOptions.DefaultMinimalChange, null) + { + } + } + } +} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/VisualStudioRazorParserTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/VisualStudioRazorParserTest.cs index c56595f421..10b3a29000 100644 --- a/test/Microsoft.VisualStudio.Editor.Razor.Test/VisualStudioRazorParserTest.cs +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/VisualStudioRazorParserTest.cs @@ -9,10 +9,13 @@ using System.Threading; using Microsoft.AspNetCore.Mvc.Razor.Extensions; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.CodeAnalysis.Razor; using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Test; using Microsoft.VisualStudio.Text; using Microsoft.VisualStudio.Text.Editor; -using Microsoft.VisualStudio.Utilities; +using Microsoft.VisualStudio.Text.Operations; +using Moq; using Xunit; namespace Microsoft.VisualStudio.Editor.Razor @@ -24,13 +27,31 @@ namespace Microsoft.VisualStudio.Editor.Razor [Fact] public void ConstructorRequiresNonNullPhysicalPath() { - Assert.Throws("filePath", () => new VisualStudioRazorParser(Dispatcher, new TestTextBuffer(null), CreateTemplateEngine(), null, new TestCompletionBroker())); + Assert.Throws("filePath", + () => new VisualStudioRazorParser( + Dispatcher, + new TestTextBuffer(null), + CreateTemplateEngine(), + null, + new DefaultErrorReporter(), + new TestCompletionBroker(), + new Mock().Object, + new Mock().Object)); } [Fact] public void ConstructorRequiresNonEmptyPhysicalPath() { - Assert.Throws("filePath", () => new VisualStudioRazorParser(Dispatcher, new TestTextBuffer(null), CreateTemplateEngine(), string.Empty, new TestCompletionBroker())); + Assert.Throws("filePath", + () => new VisualStudioRazorParser( + Dispatcher, + new TestTextBuffer(null), + CreateTemplateEngine(), + string.Empty, + new DefaultErrorReporter(), + new TestCompletionBroker(), + new Mock().Object, + new Mock().Object)); } // [Fact] Silent skip to avoid warnings. Skipping until we can control the parser more directly. @@ -39,9 +60,17 @@ namespace Microsoft.VisualStudio.Editor.Razor // Arrange var original = new StringTextSnapshot("Foo @bar Baz"); var testBuffer = new TestTextBuffer(original); - using (var parser = new VisualStudioRazorParser(Dispatcher, testBuffer, CreateTemplateEngine(), TestLinePragmaFileName, new TestCompletionBroker())) + using (var parser = new VisualStudioRazorParser( + Dispatcher, + testBuffer, + CreateTemplateEngine(), + TestLinePragmaFileName, + new DefaultErrorReporter(), + new TestCompletionBroker(), + new Mock().Object, + new Mock().Object)) { - parser._idleTimer.Interval = 100; + parser.IdleDelay = TimeSpan.FromMilliseconds(100); var changed = new StringTextSnapshot("Foo @bap Daz"); var edit = new TestEdit(7, 3, original, 3, changed, "p D"); var parseComplete = new ManualResetEventSlim(); @@ -520,7 +549,15 @@ namespace Microsoft.VisualStudio.Editor.Razor private TestParserManager CreateParserManager(ITextSnapshot originalSnapshot, int idleDelay = 50) { - var parser = new VisualStudioRazorParser(Dispatcher, new TestTextBuffer(originalSnapshot), CreateTemplateEngine(), TestLinePragmaFileName, new TestCompletionBroker()); + var parser = new VisualStudioRazorParser( + Dispatcher, + new TestTextBuffer(originalSnapshot), + CreateTemplateEngine(), + TestLinePragmaFileName, + new DefaultErrorReporter(), + new TestCompletionBroker(), + new Mock().Object, + new Mock().Object); return new TestParserManager(parser); } @@ -559,12 +596,7 @@ namespace Microsoft.VisualStudio.Editor.Razor 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 - }; + var edit = new TestEdit(change, old, changed); using (var manager = CreateParserManager(old)) { manager.InitializeWithDocument(edit.OldSnapshot); @@ -610,7 +642,7 @@ namespace Microsoft.VisualStudio.Editor.Razor ParseCount = 0; // Change idle delay to be huge in order to enable us to take control of when idle methods fire. - parser._idleTimer.Interval = TimeSpan.FromMinutes(2).TotalMilliseconds; + parser.IdleDelay = TimeSpan.FromMinutes(2); _parser = parser; parser.DocumentStructureChanged += (sender, args) => { @@ -633,12 +665,7 @@ namespace Microsoft.VisualStudio.Editor.Razor { var old = new StringTextSnapshot(string.Empty); var initialChange = new SourceChange(0, 0, snapshot.GetText()); - var edit = new TestEdit - { - Change = initialChange, - OldSnapshot = old, - NewSnapshot = snapshot - }; + var edit = new TestEdit(initialChange, old, snapshot); ApplyEditAndWaitForParse(edit); } @@ -667,15 +694,15 @@ namespace Microsoft.VisualStudio.Editor.Razor public void WaitForReparse() { - Assert.True(_parser._idleTimer.Enabled, "Expected the parser to be waiting for an idle invocation but it was not."); + Assert.True(_parser._idleTimer != null, "Expected the parser to be waiting for an idle invocation but it was not."); - _parser._idleTimer.Stop(); - _parser._idleTimer.Interval = 50; - _parser._idleTimer.Start(); + _parser.StopIdleTimer(); + _parser.IdleDelay = TimeSpan.FromMilliseconds(50); + _parser.StartIdleTimer(); DoWithTimeoutIfNotDebugging(_reparseComplete.Wait); _reparseComplete.Reset(); - Assert.False(_parser._idleTimer.Enabled); - _parser._idleTimer.Interval = TimeSpan.FromMinutes(2).TotalMilliseconds; + Assert.Null(_parser._idleTimer); + _parser.IdleDelay = TimeSpan.FromMinutes(2); } public void Dispose() @@ -684,47 +711,6 @@ namespace Microsoft.VisualStudio.Editor.Razor } } - 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) @@ -757,143 +743,5 @@ namespace Microsoft.VisualStudio.Editor.Razor 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/Editor/DefaultVisualStudioDocumentTrackerFactoryTest.cs b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Editor/DefaultVisualStudioDocumentTrackerFactoryTest.cs index 6544ba0f85..5d9759c410 100644 --- a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Editor/DefaultVisualStudioDocumentTrackerFactoryTest.cs +++ b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Editor/DefaultVisualStudioDocumentTrackerFactoryTest.cs @@ -217,7 +217,39 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor.Editor } [ForegroundFact] - public void GetTracker_ForRazorTextBufferWithTracker_ReturnsTheFirstTracker() + public void GetTracker_ITextBuffer_ForRazorTextBufferWithTracker_ReturnsTracker() + { + // Arrange + var factory = new DefaultVisualStudioDocumentTrackerFactory(Dispatcher, ProjectManager, ProjectService, Workspace); + var textBuffer = Mock.Of(b => b.ContentType == RazorContentType && b.Properties == new PropertyCollection()); + + // Preload the buffer's properties with a tracker, so it's like we've already tracked this one. + var tracker = new DefaultVisualStudioDocumentTracker(ProjectManager, ProjectService, Workspace, textBuffer); + textBuffer.Properties.AddProperty(typeof(VisualStudioDocumentTracker), tracker); + + // Act + var result = factory.GetTracker(textBuffer); + + // Assert + Assert.Same(tracker, result); + } + + [ForegroundFact] + public void GetTracker_ITextBuffer_NonRazorBuffer_ReturnsNull() + { + // Arrange + var factory = new DefaultVisualStudioDocumentTrackerFactory(Dispatcher, ProjectManager, ProjectService, Workspace); + var textBuffer = Mock.Of(b => b.ContentType == NonRazorContentType && b.Properties == new PropertyCollection()); + + // Act + var result = factory.GetTracker(textBuffer); + + // Assert + Assert.Null(result); + } + + [ForegroundFact] + public void GetTracker_ITextView_ForRazorTextBufferWithTracker_ReturnsTheFirstTracker() { // Arrange var factory = new DefaultVisualStudioDocumentTrackerFactory(Dispatcher, ProjectManager, ProjectService, Workspace); @@ -244,7 +276,7 @@ namespace Microsoft.VisualStudio.LanguageServices.Razor.Editor } [ForegroundFact] - public void GetTracker_WithoutRazorBuffer_ReturnsNull() + public void GetTracker_ITextView_WithoutRazorBuffer_ReturnsNull() { // Arrange var factory = new DefaultVisualStudioDocumentTrackerFactory(Dispatcher, ProjectManager, ProjectService, Workspace);