From 4476a72ecfec6d6c6917a74a24bd70182e34a6d9 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Thu, 7 Dec 2017 16:41:11 -0800 Subject: [PATCH] Refactor `ImportDocumentManager` to not be windows specific. - Added a `FileChangeTracker`, `FileChangeTrackerFactory` and corresponding implementations. These types now enable us to implement Windows and Mac file change tracking instead of replacing the entire import manager. - Changed the import manager to be a Roslyn based service. - Moved import manager implementation to the editor.razor assembly now since it no longer depends on windows. - Updated import manager unit tests. - Added import manager integration test - Added file change tracking tests. #1804 --- .../DefaultImportDocumentManager.cs | 162 +++++++++++ .../DefaultImportDocumentManagerFactory.cs | 35 +++ ...sualStudioDocumentTrackerFactoryFactory.cs | 13 +- .../FileChangeEventArgs.cs | 20 ++ ...{ImportChangeKind.cs => FileChangeKind.cs} | 2 +- .../FileChangeTracker.cs | 18 ++ .../FileChangeTrackerFactory.cs | 12 + .../ImportChangedEventArgs.cs | 4 +- .../ImportDocumentManager.cs | 3 +- .../DefaultFileChangeTracker.cs | 141 ++++++++++ .../DefaultFileChangeTrackerFactory.cs | 53 ++++ .../DefaultFileChangeTrackerFactoryFactory.cs | 44 +++ .../DefaultImportDocumentManager.cs | 255 ------------------ ...ultImportDocumentManagerIntegrationTest.cs | 70 +++++ .../DefaultImportDocumentManagerTest.cs | 149 ++++++++++ .../DefaultVisualStudioDocumentTrackerTest.cs | 4 +- .../DefaultFileChangeTrackerTest.cs | 128 +++++++++ .../DefaultImportDocumentManagerTest.cs | 193 ------------- 18 files changed, 842 insertions(+), 464 deletions(-) create mode 100644 src/Microsoft.VisualStudio.Editor.Razor/DefaultImportDocumentManager.cs create mode 100644 src/Microsoft.VisualStudio.Editor.Razor/DefaultImportDocumentManagerFactory.cs create mode 100644 src/Microsoft.VisualStudio.Editor.Razor/FileChangeEventArgs.cs rename src/Microsoft.VisualStudio.Editor.Razor/{ImportChangeKind.cs => FileChangeKind.cs} (88%) create mode 100644 src/Microsoft.VisualStudio.Editor.Razor/FileChangeTracker.cs create mode 100644 src/Microsoft.VisualStudio.Editor.Razor/FileChangeTrackerFactory.cs create mode 100644 src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTracker.cs create mode 100644 src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTrackerFactory.cs create mode 100644 src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTrackerFactoryFactory.cs delete mode 100644 src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultImportDocumentManager.cs create mode 100644 test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultImportDocumentManagerIntegrationTest.cs create mode 100644 test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultImportDocumentManagerTest.cs create mode 100644 test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultFileChangeTrackerTest.cs delete mode 100644 test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultImportDocumentManagerTest.cs diff --git a/src/Microsoft.VisualStudio.Editor.Razor/DefaultImportDocumentManager.cs b/src/Microsoft.VisualStudio.Editor.Razor/DefaultImportDocumentManager.cs new file mode 100644 index 0000000000..36f600a59a --- /dev/null +++ b/src/Microsoft.VisualStudio.Editor.Razor/DefaultImportDocumentManager.cs @@ -0,0 +1,162 @@ +// 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 Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Razor; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + internal class DefaultImportDocumentManager : ImportDocumentManager + { + private readonly FileChangeTrackerFactory _fileChangeTrackerFactory; + private readonly ForegroundDispatcher _foregroundDispatcher; + private readonly ErrorReporter _errorReporter; + private readonly RazorTemplateEngineFactoryService _templateEngineFactoryService; + private readonly Dictionary _importTrackerCache; + + public override event EventHandler Changed; + + public DefaultImportDocumentManager( + ForegroundDispatcher foregroundDispatcher, + ErrorReporter errorReporter, + FileChangeTrackerFactory fileChangeTrackerFactory, + RazorTemplateEngineFactoryService templateEngineFactoryService) + { + if (foregroundDispatcher == null) + { + throw new ArgumentNullException(nameof(foregroundDispatcher)); + } + + if (errorReporter == null) + { + throw new ArgumentNullException(nameof(errorReporter)); + } + + if (fileChangeTrackerFactory == null) + { + throw new ArgumentNullException(nameof(fileChangeTrackerFactory)); + } + + if (templateEngineFactoryService == null) + { + throw new ArgumentNullException(nameof(templateEngineFactoryService)); + } + + _foregroundDispatcher = foregroundDispatcher; + _errorReporter = errorReporter; + _fileChangeTrackerFactory = fileChangeTrackerFactory; + _templateEngineFactoryService = templateEngineFactoryService; + _importTrackerCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public override void OnSubscribed(VisualStudioDocumentTracker tracker) + { + if (tracker == null) + { + throw new ArgumentNullException(nameof(tracker)); + } + + _foregroundDispatcher.AssertForegroundThread(); + + var imports = GetImportItems(tracker); + foreach (var import in imports) + { + var importFilePath = import.PhysicalPath; + Debug.Assert(importFilePath != null); + + if (!_importTrackerCache.TryGetValue(importFilePath, out var importTracker)) + { + // First time seeing this import. Start tracking it. + var fileChangeTracker = _fileChangeTrackerFactory.Create(importFilePath); + importTracker = new ImportTracker(fileChangeTracker); + _importTrackerCache[importFilePath] = importTracker; + + fileChangeTracker.Changed += FileChangeTracker_Changed; + fileChangeTracker.StartListening(); + } + + importTracker.AssociatedDocuments.Add(tracker.FilePath); + } + } + + public override void OnUnsubscribed(VisualStudioDocumentTracker tracker) + { + if (tracker == null) + { + throw new ArgumentNullException(nameof(tracker)); + } + + _foregroundDispatcher.AssertForegroundThread(); + + var imports = GetImportItems(tracker); + foreach (var import in imports) + { + var importFilePath = import.PhysicalPath; + Debug.Assert(importFilePath != null); + + if (_importTrackerCache.TryGetValue(importFilePath, out var importTracker)) + { + importTracker.AssociatedDocuments.Remove(tracker.FilePath); + + if (importTracker.AssociatedDocuments.Count == 0) + { + // There are no open documents that care about this import. We no longer need to track it. + importTracker.FileChangeTracker.StopListening(); + _importTrackerCache.Remove(importFilePath); + } + } + } + } + + private IEnumerable GetImportItems(VisualStudioDocumentTracker tracker) + { + var projectDirectory = Path.GetDirectoryName(tracker.ProjectPath); + var templateEngine = _templateEngineFactoryService.Create(projectDirectory, _ => { }); + var imports = templateEngine.GetImportItems(tracker.FilePath); + + return imports; + } + + private void OnChanged(ImportTracker importTracker, FileChangeKind changeKind) + { + _foregroundDispatcher.AssertForegroundThread(); + + if (Changed == null) + { + return; + } + + var args = new ImportChangedEventArgs(importTracker.FilePath, changeKind, importTracker.AssociatedDocuments); + Changed.Invoke(this, args); + } + + private void FileChangeTracker_Changed(object sender, FileChangeEventArgs args) + { + _foregroundDispatcher.AssertForegroundThread(); + + if (_importTrackerCache.TryGetValue(args.FilePath, out var importTracker)) + { + OnChanged(importTracker, args.Kind); + } + } + + private class ImportTracker + { + public ImportTracker(FileChangeTracker fileChangeTracker) + { + FileChangeTracker = fileChangeTracker; + AssociatedDocuments = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + public string FilePath => FileChangeTracker.FilePath; + + public FileChangeTracker FileChangeTracker { get; } + + public HashSet AssociatedDocuments { get; } + } + } +} diff --git a/src/Microsoft.VisualStudio.Editor.Razor/DefaultImportDocumentManagerFactory.cs b/src/Microsoft.VisualStudio.Editor.Razor/DefaultImportDocumentManagerFactory.cs new file mode 100644 index 0000000000..3cb0ead39a --- /dev/null +++ b/src/Microsoft.VisualStudio.Editor.Razor/DefaultImportDocumentManagerFactory.cs @@ -0,0 +1,35 @@ +// 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.Composition; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.Razor; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + [Shared] + [ExportLanguageServiceFactory(typeof(ImportDocumentManager), RazorLanguage.Name, ServiceLayer.Default)] + internal class DefaultImportDocumentManagerFactory : ILanguageServiceFactory + { + public ILanguageService CreateLanguageService(HostLanguageServices languageServices) + { + if (languageServices == null) + { + throw new ArgumentNullException(nameof(languageServices)); + } + + var dispatcher = languageServices.WorkspaceServices.GetRequiredService(); + var errorReporter = languageServices.WorkspaceServices.GetRequiredService(); + var fileChangeTrackerFactory = languageServices.GetRequiredService(); + var templateEngineFactoryService = languageServices.GetRequiredService(); + + return new DefaultImportDocumentManager( + dispatcher, + errorReporter, + fileChangeTrackerFactory, + templateEngineFactoryService); + } + } +} diff --git a/src/Microsoft.VisualStudio.Editor.Razor/DefaultVisualStudioDocumentTrackerFactoryFactory.cs b/src/Microsoft.VisualStudio.Editor.Razor/DefaultVisualStudioDocumentTrackerFactoryFactory.cs index 0a97cf211a..4fd4f1f583 100644 --- a/src/Microsoft.VisualStudio.Editor.Razor/DefaultVisualStudioDocumentTrackerFactoryFactory.cs +++ b/src/Microsoft.VisualStudio.Editor.Razor/DefaultVisualStudioDocumentTrackerFactoryFactory.cs @@ -18,13 +18,11 @@ namespace Microsoft.VisualStudio.Editor.Razor { private readonly TextBufferProjectService _projectService; private readonly ITextDocumentFactoryService _textDocumentFactory; - private readonly ImportDocumentManager _importDocumentManager; [ImportingConstructor] public DefaultVisualStudioDocumentTrackerFactoryFactory( TextBufferProjectService projectService, - ITextDocumentFactoryService textDocumentFactory, - ImportDocumentManager importDocumentManager) + ITextDocumentFactoryService textDocumentFactory) { if (projectService == null) { @@ -36,14 +34,8 @@ namespace Microsoft.VisualStudio.Editor.Razor throw new ArgumentNullException(nameof(textDocumentFactory)); } - if (importDocumentManager == null) - { - throw new ArgumentNullException(nameof(importDocumentManager)); - } - _projectService = projectService; _textDocumentFactory = textDocumentFactory; - _importDocumentManager = importDocumentManager; } public ILanguageService CreateLanguageService(HostLanguageServices languageServices) @@ -56,6 +48,7 @@ namespace Microsoft.VisualStudio.Editor.Razor var dispatcher = languageServices.WorkspaceServices.GetRequiredService(); var projectManager = languageServices.GetRequiredService(); var editorSettingsManager = languageServices.GetRequiredService(); + var importDocumentManager = languageServices.GetRequiredService(); return new DefaultVisualStudioDocumentTrackerFactory( dispatcher, @@ -63,7 +56,7 @@ namespace Microsoft.VisualStudio.Editor.Razor editorSettingsManager, _projectService, _textDocumentFactory, - _importDocumentManager, + importDocumentManager, languageServices.WorkspaceServices.Workspace); } } diff --git a/src/Microsoft.VisualStudio.Editor.Razor/FileChangeEventArgs.cs b/src/Microsoft.VisualStudio.Editor.Razor/FileChangeEventArgs.cs new file mode 100644 index 0000000000..508c27381a --- /dev/null +++ b/src/Microsoft.VisualStudio.Editor.Razor/FileChangeEventArgs.cs @@ -0,0 +1,20 @@ +// 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.Editor.Razor +{ + internal sealed class FileChangeEventArgs : EventArgs + { + public FileChangeEventArgs(string filePath, FileChangeKind kind) + { + FilePath = filePath; + Kind = kind; + } + + public string FilePath { get; } + + public FileChangeKind Kind { get; } + } +} diff --git a/src/Microsoft.VisualStudio.Editor.Razor/ImportChangeKind.cs b/src/Microsoft.VisualStudio.Editor.Razor/FileChangeKind.cs similarity index 88% rename from src/Microsoft.VisualStudio.Editor.Razor/ImportChangeKind.cs rename to src/Microsoft.VisualStudio.Editor.Razor/FileChangeKind.cs index ace9b769ca..f5be58db77 100644 --- a/src/Microsoft.VisualStudio.Editor.Razor/ImportChangeKind.cs +++ b/src/Microsoft.VisualStudio.Editor.Razor/FileChangeKind.cs @@ -3,7 +3,7 @@ namespace Microsoft.VisualStudio.Editor.Razor { - internal enum ImportChangeKind + internal enum FileChangeKind { Added, Removed, diff --git a/src/Microsoft.VisualStudio.Editor.Razor/FileChangeTracker.cs b/src/Microsoft.VisualStudio.Editor.Razor/FileChangeTracker.cs new file mode 100644 index 0000000000..3c51bebdf6 --- /dev/null +++ b/src/Microsoft.VisualStudio.Editor.Razor/FileChangeTracker.cs @@ -0,0 +1,18 @@ +// 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.Editor.Razor +{ + internal abstract class FileChangeTracker + { + public abstract event EventHandler Changed; + + public abstract string FilePath { get; } + + public abstract void StartListening(); + + public abstract void StopListening(); + } +} diff --git a/src/Microsoft.VisualStudio.Editor.Razor/FileChangeTrackerFactory.cs b/src/Microsoft.VisualStudio.Editor.Razor/FileChangeTrackerFactory.cs new file mode 100644 index 0000000000..f3b57f72a0 --- /dev/null +++ b/src/Microsoft.VisualStudio.Editor.Razor/FileChangeTrackerFactory.cs @@ -0,0 +1,12 @@ +// 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.CodeAnalysis.Host; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + internal abstract class FileChangeTrackerFactory : ILanguageService + { + public abstract FileChangeTracker Create(string filePath); + } +} diff --git a/src/Microsoft.VisualStudio.Editor.Razor/ImportChangedEventArgs.cs b/src/Microsoft.VisualStudio.Editor.Razor/ImportChangedEventArgs.cs index b8836ca69a..8444943bd6 100644 --- a/src/Microsoft.VisualStudio.Editor.Razor/ImportChangedEventArgs.cs +++ b/src/Microsoft.VisualStudio.Editor.Razor/ImportChangedEventArgs.cs @@ -8,7 +8,7 @@ namespace Microsoft.VisualStudio.Editor.Razor { internal class ImportChangedEventArgs : EventArgs { - public ImportChangedEventArgs(string filePath, ImportChangeKind kind, IEnumerable associatedDocuments) + public ImportChangedEventArgs(string filePath, FileChangeKind kind, IEnumerable associatedDocuments) { FilePath = filePath; Kind = kind; @@ -17,7 +17,7 @@ namespace Microsoft.VisualStudio.Editor.Razor public string FilePath { get; } - public ImportChangeKind Kind { get; } + public FileChangeKind Kind { get; } public IEnumerable AssociatedDocuments { get; } } diff --git a/src/Microsoft.VisualStudio.Editor.Razor/ImportDocumentManager.cs b/src/Microsoft.VisualStudio.Editor.Razor/ImportDocumentManager.cs index ecfa7d911d..0dbc93a21b 100644 --- a/src/Microsoft.VisualStudio.Editor.Razor/ImportDocumentManager.cs +++ b/src/Microsoft.VisualStudio.Editor.Razor/ImportDocumentManager.cs @@ -2,10 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.CodeAnalysis.Host; namespace Microsoft.VisualStudio.Editor.Razor { - internal abstract class ImportDocumentManager + internal abstract class ImportDocumentManager : ILanguageService { public abstract event EventHandler Changed; diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTracker.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTracker.cs new file mode 100644 index 0000000000..e2d164e806 --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTracker.cs @@ -0,0 +1,141 @@ +// 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.Runtime.InteropServices; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.VisualStudio.Editor.Razor; +using Microsoft.VisualStudio.Shell.Interop; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + internal class DefaultFileChangeTracker : FileChangeTracker, IVsFileChangeEvents + { + private const uint FileChangeFlags = (uint)(_VSFILECHANGEFLAGS.VSFILECHG_Time | _VSFILECHANGEFLAGS.VSFILECHG_Size | _VSFILECHANGEFLAGS.VSFILECHG_Del | _VSFILECHANGEFLAGS.VSFILECHG_Add); + + private readonly ForegroundDispatcher _foregroundDispatcher; + private readonly ErrorReporter _errorReporter; + private readonly IVsFileChangeEx _fileChangeService; + private uint _fileChangeCookie; + + public override event EventHandler Changed; + + public DefaultFileChangeTracker( + string filePath, + ForegroundDispatcher foregroundDispatcher, + ErrorReporter errorReporter, + IVsFileChangeEx fileChangeService) + { + if (string.IsNullOrEmpty(filePath)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(filePath)); + } + + if (foregroundDispatcher == null) + { + throw new ArgumentNullException(nameof(foregroundDispatcher)); + } + + if (errorReporter == null) + { + throw new ArgumentNullException(nameof(errorReporter)); + } + + if (fileChangeService == null) + { + throw new ArgumentNullException(nameof(fileChangeService)); + } + + FilePath = filePath; + _foregroundDispatcher = foregroundDispatcher; + _errorReporter = errorReporter; + _fileChangeService = fileChangeService; + _fileChangeCookie = VSConstants.VSCOOKIE_NIL; + } + + public override string FilePath { get; } + + public override void StartListening() + { + _foregroundDispatcher.AssertForegroundThread(); + + try + { + if (_fileChangeCookie == VSConstants.VSCOOKIE_NIL) + { + var hr = _fileChangeService.AdviseFileChange( + FilePath, + FileChangeFlags, + this, + out _fileChangeCookie); + + Marshal.ThrowExceptionForHR(hr); + } + } + catch (Exception exception) + { + _errorReporter.ReportError(exception); + } + } + + public override void StopListening() + { + _foregroundDispatcher.AssertForegroundThread(); + + try + { + if (_fileChangeCookie != VSConstants.VSCOOKIE_NIL) + { + var hr = _fileChangeService.UnadviseFileChange(_fileChangeCookie); + Marshal.ThrowExceptionForHR(hr); + _fileChangeCookie = VSConstants.VSCOOKIE_NIL; + } + } + catch (Exception exception) + { + _errorReporter.ReportError(exception); + } + } + + public int FilesChanged(uint fileCount, string[] filePaths, uint[] fileChangeFlags) + { + _foregroundDispatcher.AssertForegroundThread(); + + foreach (var fileChangeFlag in fileChangeFlags) + { + var fileChangeKind = FileChangeKind.Changed; + var changeFlag = (_VSFILECHANGEFLAGS)fileChangeFlag; + if ((changeFlag & _VSFILECHANGEFLAGS.VSFILECHG_Del) == _VSFILECHANGEFLAGS.VSFILECHG_Del) + { + fileChangeKind = FileChangeKind.Removed; + } + else if ((changeFlag & _VSFILECHANGEFLAGS.VSFILECHG_Add) == _VSFILECHANGEFLAGS.VSFILECHG_Add) + { + fileChangeKind = FileChangeKind.Added; + } + + // Purposefully not passing through the file paths here because we know this change has to do with this trackers FilePath. + // We use that FilePath instead so any path normalization the file service did does not impact callers. + OnChanged(fileChangeKind); + } + + return VSConstants.S_OK; + } + + public int DirectoryChanged(string pszDirectory) => VSConstants.S_OK; + + private void OnChanged(FileChangeKind changeKind) + { + _foregroundDispatcher.AssertForegroundThread(); + + if (Changed == null) + { + return; + } + + var args = new FileChangeEventArgs(FilePath, changeKind); + Changed.Invoke(this, args); + } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTrackerFactory.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTrackerFactory.cs new file mode 100644 index 0000000000..6c6515be4a --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTrackerFactory.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.VisualStudio.Editor.Razor; +using Microsoft.VisualStudio.Shell.Interop; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + internal partial class DefaultFileChangeTrackerFactory : FileChangeTrackerFactory + { + private readonly ForegroundDispatcher _foregroundDispatcher; + private readonly ErrorReporter _errorReporter; + private readonly IVsFileChangeEx _fileChangeService; + + public DefaultFileChangeTrackerFactory( + ForegroundDispatcher foregroundDispatcher, + ErrorReporter errorReporter, + IVsFileChangeEx fileChangeService) + { + if (foregroundDispatcher == null) + { + throw new ArgumentNullException(nameof(foregroundDispatcher)); + } + + if (errorReporter == null) + { + throw new ArgumentNullException(nameof(errorReporter)); + } + + if (fileChangeService == null) + { + throw new ArgumentNullException(nameof(fileChangeService)); + } + + _foregroundDispatcher = foregroundDispatcher; + _errorReporter = errorReporter; + _fileChangeService = fileChangeService; + } + + public override FileChangeTracker Create(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(filePath)); + } + + var fileChangeTracker = new DefaultFileChangeTracker(filePath, _foregroundDispatcher, _errorReporter, _fileChangeService); + return fileChangeTracker; + } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTrackerFactoryFactory.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTrackerFactoryFactory.cs new file mode 100644 index 0000000000..e4253e2ab7 --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultFileChangeTrackerFactoryFactory.cs @@ -0,0 +1,44 @@ +// 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.Composition; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.VisualStudio.Editor.Razor; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + [Shared] + [ExportLanguageServiceFactory(typeof(FileChangeTrackerFactory), RazorLanguage.Name, ServiceLayer.Default)] + internal class DefaultFileChangeTrackerFactoryFactory : ILanguageServiceFactory + { + private readonly IVsFileChangeEx _fileChangeService; + + [ImportingConstructor] + public DefaultFileChangeTrackerFactoryFactory(SVsServiceProvider serviceProvider) + { + if (serviceProvider == null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + + _fileChangeService = serviceProvider.GetService(typeof(SVsFileChangeEx)) as IVsFileChangeEx; + } + + public ILanguageService CreateLanguageService(HostLanguageServices languageServices) + { + if (languageServices == null) + { + throw new ArgumentNullException(nameof(languageServices)); + } + + var foregroundDispatcher = languageServices.WorkspaceServices.GetRequiredService(); + var errorReporter = languageServices.WorkspaceServices.GetRequiredService(); + return new DefaultFileChangeTrackerFactory(foregroundDispatcher, errorReporter, _fileChangeService); + } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultImportDocumentManager.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultImportDocumentManager.cs deleted file mode 100644 index 84e564c9dc..0000000000 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultImportDocumentManager.cs +++ /dev/null @@ -1,255 +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.Collections.Generic; -using System.ComponentModel.Composition; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.VisualStudio.Editor.Razor; -using Microsoft.VisualStudio.Shell; -using Microsoft.VisualStudio.Shell.Interop; - -namespace Microsoft.VisualStudio.LanguageServices.Razor -{ - [System.Composition.Shared] - [Export(typeof(ImportDocumentManager))] - internal class DefaultImportDocumentManager : ImportDocumentManager - { - private const uint FileChangeFlags = (uint)(_VSFILECHANGEFLAGS.VSFILECHG_Time | _VSFILECHANGEFLAGS.VSFILECHG_Size | _VSFILECHANGEFLAGS.VSFILECHG_Del | _VSFILECHANGEFLAGS.VSFILECHG_Add); - - private readonly IVsFileChangeEx _fileChangeService; - private readonly ForegroundDispatcher _foregroundDispatcher; - private readonly ErrorReporter _errorReporter; - private readonly RazorTemplateEngineFactoryService _templateEngineFactoryService; - private readonly Dictionary _importTrackerCache; - - public override event EventHandler Changed; - - [ImportingConstructor] - public DefaultImportDocumentManager( - [Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider, - VisualStudioWorkspaceAccessor workspaceAccessor) - { - if (serviceProvider == null) - { - throw new ArgumentNullException(nameof(serviceProvider)); - } - - if (workspaceAccessor == null) - { - throw new ArgumentNullException(nameof(workspaceAccessor)); - } - - _fileChangeService = serviceProvider.GetService(typeof(SVsFileChangeEx)) as IVsFileChangeEx; - - var workspace = workspaceAccessor.Workspace; - _foregroundDispatcher = workspace.Services.GetRequiredService(); - _errorReporter = workspace.Services.GetRequiredService(); - - var razorLanguageServices = workspace.Services.GetLanguageServices(RazorLanguage.Name); - _templateEngineFactoryService = razorLanguageServices.GetRequiredService(); - - _importTrackerCache = new Dictionary(StringComparer.OrdinalIgnoreCase); - } - - // This is only used for testing. - internal DefaultImportDocumentManager( - IVsFileChangeEx fileChangeService, - RazorTemplateEngineFactoryService templateEngineFactoryService, - ForegroundDispatcher foregroundDispatcher, - ErrorReporter errorReporter) - { - _fileChangeService = fileChangeService; - _templateEngineFactoryService = templateEngineFactoryService; - _foregroundDispatcher = foregroundDispatcher; - _errorReporter = errorReporter; - - _importTrackerCache = new Dictionary(StringComparer.OrdinalIgnoreCase); - } - - public override void OnSubscribed(VisualStudioDocumentTracker tracker) - { - if (tracker == null) - { - throw new ArgumentNullException(nameof(tracker)); - } - - _foregroundDispatcher.AssertForegroundThread(); - - var imports = GetImportItems(tracker); - foreach (var import in imports) - { - var importFilePath = import.PhysicalPath; - Debug.Assert(importFilePath != null); - - if (!_importTrackerCache.TryGetValue(importFilePath, out var importTracker)) - { - // First time seeing this import. Start tracking it. - importTracker = new ImportTracker(importFilePath); - _importTrackerCache[importFilePath] = importTracker; - - StartListeningForChanges(importTracker); - } - - importTracker.AssociatedDocuments.Add(tracker.FilePath); - } - } - - public override void OnUnsubscribed(VisualStudioDocumentTracker tracker) - { - if (tracker == null) - { - throw new ArgumentNullException(nameof(tracker)); - } - - _foregroundDispatcher.AssertForegroundThread(); - - var imports = GetImportItems(tracker); - foreach (var import in imports) - { - var importFilePath = import.PhysicalPath; - Debug.Assert(importFilePath != null); - - if (_importTrackerCache.TryGetValue(importFilePath, out var importTracker)) - { - importTracker.AssociatedDocuments.Remove(tracker.FilePath); - - if (importTracker.AssociatedDocuments.Count == 0) - { - // There are no open documents that care about this import. We no longer need to track it. - StopListeningForChanges(importTracker); - _importTrackerCache.Remove(importFilePath); - } - } - } - } - - private IEnumerable GetImportItems(VisualStudioDocumentTracker tracker) - { - var projectDirectory = Path.GetDirectoryName(tracker.ProjectPath); - var templateEngine = _templateEngineFactoryService.Create(projectDirectory, _ => { }); - var imports = templateEngine.GetImportItems(tracker.FilePath); - - return imports; - } - - private void FireImportChanged(string importPath, ImportChangeKind kind) - { - _foregroundDispatcher.AssertForegroundThread(); - - var handler = Changed; - if (handler != null && _importTrackerCache.TryGetValue(importPath, out var importTracker)) - { - var args = new ImportChangedEventArgs(importPath, kind, importTracker.AssociatedDocuments); - handler(this, args); - } - } - - // internal for testing. - internal void OnFilesChanged(uint fileCount, string[] filePaths, uint[] fileChangeFlags) - { - for (var i = 0; i < fileCount; i++) - { - var kind = ImportChangeKind.Changed; - var flag = (_VSFILECHANGEFLAGS)fileChangeFlags[i]; - - if ((flag & _VSFILECHANGEFLAGS.VSFILECHG_Del) == _VSFILECHANGEFLAGS.VSFILECHG_Del) - { - kind = ImportChangeKind.Removed; - } - else if ((flag & _VSFILECHANGEFLAGS.VSFILECHG_Add) == _VSFILECHANGEFLAGS.VSFILECHG_Add) - { - kind = ImportChangeKind.Added; - } - - FireImportChanged(filePaths[i], kind); - } - } - - private void StartListeningForChanges(ImportTracker importTracker) - { - try - { - if (importTracker.FileChangeCookie == VSConstants.VSCOOKIE_NIL) - { - var hr = _fileChangeService.AdviseFileChange( - importTracker.FilePath, - FileChangeFlags, - new ImportDocumentEventSink(this, _foregroundDispatcher), - out var cookie); - - Marshal.ThrowExceptionForHR(hr); - - importTracker.FileChangeCookie = cookie; - } - } - catch (Exception exception) - { - _errorReporter.ReportError(exception); - } - } - - private void StopListeningForChanges(ImportTracker importTracker) - { - try - { - if (importTracker.FileChangeCookie != VSConstants.VSCOOKIE_NIL) - { - var hr = _fileChangeService.UnadviseFileChange(importTracker.FileChangeCookie); - Marshal.ThrowExceptionForHR(hr); - importTracker.FileChangeCookie = VSConstants.VSCOOKIE_NIL; - } - } - catch (Exception exception) - { - _errorReporter.ReportError(exception); - } - } - - private class ImportTracker - { - public ImportTracker(string filePath) - { - FilePath = filePath; - AssociatedDocuments = new HashSet(StringComparer.OrdinalIgnoreCase); - FileChangeCookie = VSConstants.VSCOOKIE_NIL; - } - - public string FilePath { get; } - - public HashSet AssociatedDocuments { get; } - - public uint FileChangeCookie { get; set; } - } - - private class ImportDocumentEventSink : IVsFileChangeEvents - { - private readonly DefaultImportDocumentManager _importDocumentManager; - private readonly ForegroundDispatcher _foregroundDispatcher; - - public ImportDocumentEventSink(DefaultImportDocumentManager importDocumentManager, ForegroundDispatcher foregroundDispatcher) - { - _importDocumentManager = importDocumentManager; - _foregroundDispatcher = foregroundDispatcher; - } - - public int FilesChanged(uint cChanges, string[] rgpszFile, uint[] rggrfChange) - { - _foregroundDispatcher.AssertForegroundThread(); - - _importDocumentManager.OnFilesChanged(cChanges, rgpszFile, rggrfChange); - - return VSConstants.S_OK; - } - - public int DirectoryChanged(string pszDirectory) - { - return VSConstants.S_OK; - } - } - } -} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultImportDocumentManagerIntegrationTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultImportDocumentManagerIntegrationTest.cs new file mode 100644 index 0000000000..18d890faac --- /dev/null +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultImportDocumentManagerIntegrationTest.cs @@ -0,0 +1,70 @@ +// 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.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Moq; +using Xunit; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + public class DefaultImportDocumentManagerIntegrationTest : ForegroundDispatcherTestBase + { + [ForegroundFact] + public void Changed_TrackerChanged_ResultsInChangedHavingCorrectArgs() + { + // Arrange + var filePath = "C:\\path\\to\\project\\Views\\Home\\file.cshtml"; + var anotherFilePath = "C:\\path\\to\\project\\anotherFile.cshtml"; + var projectPath = "C:\\path\\to\\project\\project.csproj"; + var testImportsPath = "C:\\path\\to\\project\\_ViewImports.cshtml"; + var tracker = Mock.Of(t => t.FilePath == filePath && t.ProjectPath == projectPath); + var anotherTracker = Mock.Of(t => t.FilePath == anotherFilePath && t.ProjectPath == projectPath); + var templateEngineFactoryService = GetTemplateEngineFactoryService(); + var fileChangeTracker = new Mock(); + fileChangeTracker.Setup(f => f.FilePath).Returns(testImportsPath); + var fileChangeTrackerFactory = new Mock(); + fileChangeTrackerFactory + .Setup(f => f.Create(testImportsPath)) + .Returns(fileChangeTracker.Object); + fileChangeTrackerFactory + .Setup(f => f.Create("C:\\path\\to\\project\\Views\\_ViewImports.cshtml")) + .Returns(Mock.Of()); + fileChangeTrackerFactory + .Setup(f => f.Create("C:\\path\\to\\project\\Views\\Home\\_ViewImports.cshtml")) + .Returns(Mock.Of()); + + var called = false; + var manager = new DefaultImportDocumentManager(Dispatcher, new DefaultErrorReporter(), fileChangeTrackerFactory.Object, templateEngineFactoryService); + manager.OnSubscribed(tracker); + manager.OnSubscribed(anotherTracker); + manager.Changed += (sender, args) => + { + called = true; + Assert.Same(sender, manager); + Assert.Equal(testImportsPath, args.FilePath); + Assert.Equal(FileChangeKind.Changed, args.Kind); + Assert.Collection( + args.AssociatedDocuments, + f => Assert.Equal(filePath, f), + f => Assert.Equal(anotherFilePath, f)); + }; + + // Act + fileChangeTracker.Raise(t => t.Changed += null, new FileChangeEventArgs(testImportsPath, FileChangeKind.Changed)); + + // Assert + Assert.True(called); + } + + private RazorTemplateEngineFactoryService GetTemplateEngineFactoryService() + { + var projectManager = new Mock(); + projectManager.Setup(p => p.Projects).Returns(Array.Empty()); + + var service = new DefaultTemplateEngineFactoryService(projectManager.Object); + return service; + } + } +} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultImportDocumentManagerTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultImportDocumentManagerTest.cs new file mode 100644 index 0000000000..856461052f --- /dev/null +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultImportDocumentManagerTest.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 Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Moq; +using Xunit; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + public class DefaultImportDocumentManagerTest : ForegroundDispatcherTestBase + { + [ForegroundFact] + public void OnSubscribed_StartsFileChangeTrackers() + { + // Arrange + var filePath = "C:\\path\\to\\project\\Views\\Home\\file.cshtml"; + var projectPath = "C:\\path\\to\\project\\project.csproj"; + var tracker = Mock.Of(t => t.FilePath == filePath && t.ProjectPath == projectPath); + var templateEngineFactoryService = GetTemplateEngineFactoryService(); + var fileChangeTracker1 = new Mock(); + fileChangeTracker1.Setup(f => f.StartListening()).Verifiable(); + var fileChangeTrackerFactory = new Mock(); + fileChangeTrackerFactory + .Setup(f => f.Create("C:\\path\\to\\project\\Views\\Home\\_ViewImports.cshtml")) + .Returns(fileChangeTracker1.Object) + .Verifiable(); + var fileChangeTracker2 = new Mock(); + fileChangeTracker2.Setup(f => f.StartListening()).Verifiable(); + fileChangeTrackerFactory + .Setup(f => f.Create("C:\\path\\to\\project\\Views\\_ViewImports.cshtml")) + .Returns(fileChangeTracker2.Object) + .Verifiable(); + var fileChangeTracker3 = new Mock(); + fileChangeTracker3.Setup(f => f.StartListening()).Verifiable(); + fileChangeTrackerFactory + .Setup(f => f.Create("C:\\path\\to\\project\\_ViewImports.cshtml")) + .Returns(fileChangeTracker3.Object) + .Verifiable(); + + var manager = new DefaultImportDocumentManager(Dispatcher, new DefaultErrorReporter(), fileChangeTrackerFactory.Object, templateEngineFactoryService); + + // Act + manager.OnSubscribed(tracker); + + // Assert + fileChangeTrackerFactory.Verify(); + fileChangeTracker1.Verify(); + fileChangeTracker2.Verify(); + fileChangeTracker3.Verify(); + } + + [ForegroundFact] + public void OnSubscribed_AlreadySubscribed_DoesNothing() + { + // Arrange + var filePath = "C:\\path\\to\\project\\file.cshtml"; + var projectPath = "C:\\path\\to\\project\\project.csproj"; + var tracker = Mock.Of(t => t.FilePath == filePath && t.ProjectPath == projectPath); + var templateEngineFactoryService = GetTemplateEngineFactoryService(); + + var callCount = 0; + var fileChangeTrackerFactory = new Mock(); + fileChangeTrackerFactory + .Setup(f => f.Create(It.IsAny())) + .Returns(Mock.Of()) + .Callback(() => callCount++); + + var manager = new DefaultImportDocumentManager(Dispatcher, new DefaultErrorReporter(), fileChangeTrackerFactory.Object, templateEngineFactoryService); + manager.OnSubscribed(tracker); // Start tracking the import. + + var anotherFilePath = "C:\\path\\to\\project\\anotherFile.cshtml"; + var anotherTracker = Mock.Of(t => t.FilePath == anotherFilePath && t.ProjectPath == projectPath); + + // Act + manager.OnSubscribed(anotherTracker); + + // Assert + Assert.Equal(1, callCount); + } + + [ForegroundFact] + public void OnUnsubscribed_StopsFileChangeTracker() + { + // Arrange + var filePath = "C:\\path\\to\\project\\file.cshtml"; + var projectPath = "C:\\path\\to\\project\\project.csproj"; + var tracker = Mock.Of(t => t.FilePath == filePath && t.ProjectPath == projectPath); + var templateEngineFactoryService = GetTemplateEngineFactoryService(); + + var fileChangeTracker = new Mock(); + fileChangeTracker.Setup(f => f.StopListening()).Verifiable(); + var fileChangeTrackerFactory = new Mock(MockBehavior.Strict); + fileChangeTrackerFactory + .Setup(f => f.Create("C:\\path\\to\\project\\_ViewImports.cshtml")) + .Returns(fileChangeTracker.Object) + .Verifiable(); + + var manager = new DefaultImportDocumentManager(Dispatcher, new DefaultErrorReporter(), fileChangeTrackerFactory.Object, templateEngineFactoryService); + manager.OnSubscribed(tracker); // Start tracking the import. + + // Act + manager.OnUnsubscribed(tracker); + + // Assert + fileChangeTrackerFactory.Verify(); + fileChangeTracker.Verify(); + } + + [ForegroundFact] + public void OnUnsubscribed_AnotherDocumentTrackingImport_DoesNotStopFileChangeTracker() + { + // Arrange + var filePath = "C:\\path\\to\\project\\file.cshtml"; + var projectPath = "C:\\path\\to\\project\\project.csproj"; + var tracker = Mock.Of(t => t.FilePath == filePath && t.ProjectPath == projectPath); + var templateEngineFactoryService = GetTemplateEngineFactoryService(); + + var fileChangeTracker = new Mock(); + fileChangeTracker + .Setup(f => f.StopListening()) + .Throws(new InvalidOperationException()); + var fileChangeTrackerFactory = new Mock(); + fileChangeTrackerFactory + .Setup(f => f.Create(It.IsAny())) + .Returns(fileChangeTracker.Object); + + var manager = new DefaultImportDocumentManager(Dispatcher, new DefaultErrorReporter(), fileChangeTrackerFactory.Object, templateEngineFactoryService); + manager.OnSubscribed(tracker); // Starts tracking import for the first document. + + var anotherFilePath = "C:\\path\\to\\project\\anotherFile.cshtml"; + var anotherTracker = Mock.Of(t => t.FilePath == anotherFilePath && t.ProjectPath == projectPath); + manager.OnSubscribed(anotherTracker); // Starts tracking import for the second document. + + // Act & Assert (Does not throw) + manager.OnUnsubscribed(tracker); + } + + private RazorTemplateEngineFactoryService GetTemplateEngineFactoryService() + { + var projectManager = new Mock(); + projectManager.Setup(p => p.Projects).Returns(Array.Empty()); + + var service = new DefaultTemplateEngineFactoryService(projectManager.Object); + return service; + } + } +} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultVisualStudioDocumentTrackerTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultVisualStudioDocumentTrackerTest.cs index 6c2a7c0be2..484cc1c8fe 100644 --- a/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultVisualStudioDocumentTrackerTest.cs +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultVisualStudioDocumentTrackerTest.cs @@ -137,7 +137,7 @@ namespace Microsoft.VisualStudio.Editor.Razor called = true; }; - var importChangedArgs = new ImportChangedEventArgs("path/to/import", ImportChangeKind.Changed, new[] { FilePath }); + var importChangedArgs = new ImportChangedEventArgs("path/to/import", FileChangeKind.Changed, new[] { FilePath }); // Act documentTracker.Import_Changed(null, importChangedArgs); @@ -157,7 +157,7 @@ namespace Microsoft.VisualStudio.Editor.Razor throw new InvalidOperationException(); }; - var importChangedArgs = new ImportChangedEventArgs("path/to/import", ImportChangeKind.Changed, new[] { "path/to/differentfile" }); + var importChangedArgs = new ImportChangedEventArgs("path/to/import", FileChangeKind.Changed, new[] { "path/to/differentfile" }); // Act & Assert (Does not throw) documentTracker.Import_Changed(null, importChangedArgs); diff --git a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultFileChangeTrackerTest.cs b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultFileChangeTrackerTest.cs new file mode 100644 index 0000000000..082ce9212d --- /dev/null +++ b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultFileChangeTrackerTest.cs @@ -0,0 +1,128 @@ +// 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.CodeAnalysis.Razor; +using Microsoft.VisualStudio.Editor.Razor; +using Microsoft.VisualStudio.Shell.Interop; +using Moq; +using Xunit; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + public class DefaultFileChangeTrackerTest : ForegroundDispatcherTestBase + { + private ErrorReporter ErrorReporter { get; } = new DefaultErrorReporter(); + + [ForegroundFact] + public void StartListening_AdvisesForFileChange() + { + // Arrange + uint cookie; + var fileChangeService = new Mock(); + fileChangeService + .Setup(f => f.AdviseFileChange(It.IsAny(), It.IsAny(), It.IsAny(), out cookie)) + .Returns(VSConstants.S_OK) + .Verifiable(); + var tracker = new DefaultFileChangeTracker("C:/_ViewImports.cshtml", Dispatcher, ErrorReporter, fileChangeService.Object); + + // Act + tracker.StartListening(); + + // Assert + fileChangeService.Verify(); + } + + [ForegroundFact] + public void StartListening_AlreadyListening_DoesNothing() + { + // Arrange + uint cookie = 100; + var callCount = 0; + var fileChangeService = new Mock(); + fileChangeService + .Setup(f => f.AdviseFileChange(It.IsAny(), It.IsAny(), It.IsAny(), out cookie)) + .Returns(VSConstants.S_OK) + .Callback(() => callCount++); + var tracker = new DefaultFileChangeTracker("C:/_ViewImports.cshtml", Dispatcher, ErrorReporter, fileChangeService.Object); + tracker.StartListening(); + + // Act + tracker.StartListening(); + + // Assert + Assert.Equal(1, callCount); + } + + [ForegroundFact] + public void StopListening_UnadvisesForFileChange() + { + // Arrange + uint cookie = 100; + var fileChangeService = new Mock(MockBehavior.Strict); + fileChangeService + .Setup(f => f.AdviseFileChange(It.IsAny(), It.IsAny(), It.IsAny(), out cookie)) + .Returns(VSConstants.S_OK) + .Verifiable(); + fileChangeService + .Setup(f => f.UnadviseFileChange(cookie)) + .Returns(VSConstants.S_OK) + .Verifiable(); + var tracker = new DefaultFileChangeTracker("C:/_ViewImports.cshtml", Dispatcher, ErrorReporter, fileChangeService.Object); + tracker.StartListening(); // Start listening for changes. + + // Act + tracker.StopListening(); + + // Assert + fileChangeService.Verify(); + } + + [ForegroundFact] + public void StartListening_NotListening_DoesNothing() + { + // Arrange + uint cookie = VSConstants.VSCOOKIE_NIL; + var fileChangeService = new Mock(MockBehavior.Strict); + fileChangeService + .Setup(f => f.UnadviseFileChange(cookie)) + .Throws(new InvalidOperationException()); + var tracker = new DefaultFileChangeTracker("C:/_ViewImports.cshtml", Dispatcher, ErrorReporter, fileChangeService.Object); + + // Act & Assert + tracker.StopListening(); + } + + [ForegroundTheory] + [InlineData((uint)_VSFILECHANGEFLAGS.VSFILECHG_Size, (int)FileChangeKind.Changed)] + [InlineData((uint)_VSFILECHANGEFLAGS.VSFILECHG_Time, (int)FileChangeKind.Changed)] + [InlineData((uint)_VSFILECHANGEFLAGS.VSFILECHG_Add, (int)FileChangeKind.Added)] + [InlineData((uint)_VSFILECHANGEFLAGS.VSFILECHG_Del, (int)FileChangeKind.Removed)] + public void FilesChanged_WithSpecificFlags_InvokesChangedHandler_WithExpectedArguments(uint fileChangeFlag, int expectedKind) + { + // Arrange + var filePath = "C:\\path\\to\\project\\_ViewImports.cshtml"; + uint cookie; + var fileChangeService = new Mock(); + fileChangeService + .Setup(f => f.AdviseFileChange(It.IsAny(), It.IsAny(), It.IsAny(), out cookie)) + .Returns(VSConstants.S_OK); + var tracker = new DefaultFileChangeTracker(filePath, Dispatcher, ErrorReporter, fileChangeService.Object); + + var called = false; + tracker.Changed += (sender, args) => + { + called = true; + Assert.Same(sender, tracker); + Assert.Equal(filePath, args.FilePath); + Assert.Equal((FileChangeKind)expectedKind, args.Kind); + }; + + // Act + tracker.FilesChanged(fileCount: 1, filePaths: new[] { filePath }, fileChangeFlags: new[] { fileChangeFlag }); + + // Assert + Assert.True(called); + } + } +} diff --git a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultImportDocumentManagerTest.cs b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultImportDocumentManagerTest.cs deleted file mode 100644 index 1802ffde93..0000000000 --- a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultImportDocumentManagerTest.cs +++ /dev/null @@ -1,193 +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 Microsoft.CodeAnalysis.Razor; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.VisualStudio.Editor.Razor; -using Microsoft.VisualStudio.Shell.Interop; -using Moq; -using Xunit; - -namespace Microsoft.VisualStudio.LanguageServices.Razor -{ - public class DefaultImportDocumentManagerTest : ForegroundDispatcherTestBase - { - [ForegroundFact] - public void OnSubscribed_StartTrackingImport() - { - // Arrange - var filePath = "C:\\path\\to\\project\\Views\\Home\\file.cshtml"; - var projectPath = "C:\\path\\to\\project\\project.csproj"; - var tracker = Mock.Of(t => t.FilePath == filePath && t.ProjectPath == projectPath); - var templateEngineFactoryService = GetTemplateEngineFactoryService(); - - uint cookie; - var fileChangeService = new Mock(MockBehavior.Strict); - fileChangeService - .Setup(f => f.AdviseFileChange("C:\\path\\to\\project\\Views\\Home\\_ViewImports.cshtml", It.IsAny(), It.IsAny(), out cookie)) - .Returns(VSConstants.S_OK) - .Verifiable(); - fileChangeService - .Setup(f => f.AdviseFileChange("C:\\path\\to\\project\\Views\\_ViewImports.cshtml", It.IsAny(), It.IsAny(), out cookie)) - .Returns(VSConstants.S_OK) - .Verifiable(); - fileChangeService - .Setup(f => f.AdviseFileChange("C:\\path\\to\\project\\_ViewImports.cshtml", It.IsAny(), It.IsAny(), out cookie)) - .Returns(VSConstants.S_OK) - .Verifiable(); - - var manager = new DefaultImportDocumentManager(fileChangeService.Object, templateEngineFactoryService, Dispatcher, new DefaultErrorReporter()); - - // Act - manager.OnSubscribed(tracker); - - // Assert - fileChangeService.Verify(); - } - - [ForegroundFact] - public void OnSubscribed_AlreadyTrackingImport_DoesNothing() - { - // Arrange - var filePath = "C:\\path\\to\\project\\file.cshtml"; - var projectPath = "C:\\path\\to\\project\\project.csproj"; - var tracker = Mock.Of(t => t.FilePath == filePath && t.ProjectPath == projectPath); - var templateEngineFactoryService = GetTemplateEngineFactoryService(); - - uint cookie; - var callCount = 0; - var fileChangeService = new Mock(); - fileChangeService - .Setup(f => f.AdviseFileChange(It.IsAny(), It.IsAny(), It.IsAny(), out cookie)) - .Returns(VSConstants.S_OK) - .Callback(() => callCount++); - - var manager = new DefaultImportDocumentManager(fileChangeService.Object, templateEngineFactoryService, Dispatcher, new DefaultErrorReporter()); - manager.OnSubscribed(tracker); // Start tracking the import. - - var anotherFilePath = "C:\\path\\to\\project\\anotherFile.cshtml"; - var anotherTracker = Mock.Of(t => t.FilePath == anotherFilePath && t.ProjectPath == projectPath); - - // Act - manager.OnSubscribed(anotherTracker); - - // Assert - Assert.Equal(1, callCount); - } - - [ForegroundFact] - public void OnUnsubscribed_StopsTrackingImport() - { - // Arrange - var filePath = "C:\\path\\to\\project\\file.cshtml"; - var projectPath = "C:\\path\\to\\project\\project.csproj"; - var tracker = Mock.Of(t => t.FilePath == filePath && t.ProjectPath == projectPath); - var templateEngineFactoryService = GetTemplateEngineFactoryService(); - - uint cookie = 100; - var fileChangeService = new Mock(MockBehavior.Strict); - fileChangeService - .Setup(f => f.AdviseFileChange("C:\\path\\to\\project\\_ViewImports.cshtml", It.IsAny(), It.IsAny(), out cookie)) - .Returns(VSConstants.S_OK) - .Verifiable(); - fileChangeService - .Setup(f => f.UnadviseFileChange(cookie)) - .Returns(VSConstants.S_OK) - .Verifiable(); - - var manager = new DefaultImportDocumentManager(fileChangeService.Object, templateEngineFactoryService, Dispatcher, new DefaultErrorReporter()); - manager.OnSubscribed(tracker); // Start tracking the import. - - // Act - manager.OnUnsubscribed(tracker); - - // Assert - fileChangeService.Verify(); - } - - [ForegroundFact] - public void OnUnsubscribed_AnotherDocumentTrackingImport_DoesNotStopTrackingImport() - { - // Arrange - var filePath = "C:\\path\\to\\project\\file.cshtml"; - var projectPath = "C:\\path\\to\\project\\project.csproj"; - var tracker = Mock.Of(t => t.FilePath == filePath && t.ProjectPath == projectPath); - var templateEngineFactoryService = GetTemplateEngineFactoryService(); - - uint cookie; - var fileChangeService = new Mock(); - fileChangeService - .Setup(f => f.AdviseFileChange(It.IsAny(), It.IsAny(), It.IsAny(), out cookie)) - .Returns(VSConstants.S_OK); - fileChangeService - .Setup(f => f.UnadviseFileChange(It.IsAny())) - .Returns(VSConstants.S_OK) - .Callback(() => throw new InvalidOperationException()); - - var manager = new DefaultImportDocumentManager(fileChangeService.Object, templateEngineFactoryService, Dispatcher, new DefaultErrorReporter()); - manager.OnSubscribed(tracker); // Starts tracking import for the first document. - - var anotherFilePath = "C:\\path\\to\\project\\anotherFile.cshtml"; - var anotherTracker = Mock.Of(t => t.FilePath == anotherFilePath && t.ProjectPath == projectPath); - manager.OnSubscribed(anotherTracker); // Starts tracking import for the second document. - - // Act & Assert (Does not throw) - manager.OnUnsubscribed(tracker); - } - - [ForegroundTheory] - [InlineData((uint)_VSFILECHANGEFLAGS.VSFILECHG_Size, (int)ImportChangeKind.Changed)] - [InlineData((uint)_VSFILECHANGEFLAGS.VSFILECHG_Time, (int)ImportChangeKind.Changed)] - [InlineData((uint)_VSFILECHANGEFLAGS.VSFILECHG_Add, (int)ImportChangeKind.Added)] - [InlineData((uint)_VSFILECHANGEFLAGS.VSFILECHG_Del, (int)ImportChangeKind.Removed)] - public void OnFilesChanged_WithSpecificFlags_InvokesChangedHandler_WithExpectedArguments(uint fileChangeFlag, int expectedKind) - { - // Arrange - var filePath = "C:\\path\\to\\project\\file.cshtml"; - var projectPath = "C:\\path\\to\\project\\project.csproj"; - var tracker = Mock.Of(t => t.FilePath == filePath && t.ProjectPath == projectPath); - var templateEngineFactoryService = GetTemplateEngineFactoryService(); - - var anotherFilePath = "C:\\path\\to\\project\\anotherFile.cshtml"; - var anotherTracker = Mock.Of(t => t.FilePath == anotherFilePath && t.ProjectPath == projectPath); - - uint cookie; - var fileChangeService = new Mock(); - fileChangeService - .Setup(f => f.AdviseFileChange(It.IsAny(), It.IsAny(), It.IsAny(), out cookie)) - .Returns(VSConstants.S_OK); - var manager = new DefaultImportDocumentManager(fileChangeService.Object, templateEngineFactoryService, Dispatcher, new DefaultErrorReporter()); - manager.OnSubscribed(tracker); - manager.OnSubscribed(anotherTracker); - - var called = false; - manager.Changed += (sender, args) => - { - called = true; - Assert.Same(sender, manager); - Assert.Equal("C:\\path\\to\\project\\_ViewImports.cshtml", args.FilePath); - Assert.Equal((ImportChangeKind)expectedKind, args.Kind); - Assert.Collection( - args.AssociatedDocuments, - f => Assert.Equal(filePath, f), - f => Assert.Equal(anotherFilePath, f)); - }; - - // Act - manager.OnFilesChanged(fileCount: 1, filePaths: new[] { "C:\\path\\to\\project\\_ViewImports.cshtml" }, fileChangeFlags: new[] { fileChangeFlag }); - - // Assert - Assert.True(called); - } - - private RazorTemplateEngineFactoryService GetTemplateEngineFactoryService() - { - var projectManager = new Mock(); - projectManager.Setup(p => p.Projects).Returns(Array.Empty()); - - var service = new DefaultTemplateEngineFactoryService(projectManager.Object); - return service; - } - } -}