// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. #nullable enable using System; using System.Collections.Generic; using System.ComponentModel.Composition; using System.IO; using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem; using Microsoft.VisualStudio.LanguageServices.Implementation.TaskList; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; using Roslyn.Utilities; namespace Microsoft.VisualStudio.LanguageServices.Implementation { [Export(typeof(AnalyzerFileWatcherService))] internal sealed class AnalyzerFileWatcherService { private static readonly object s_analyzerChangedErrorId = new(); private readonly VisualStudioWorkspaceImpl _workspace; private readonly HostDiagnosticUpdateSource _updateSource; private readonly IVsFileChangeEx _fileChangeService; private readonly Dictionary _fileChangeTrackers = new(StringComparer.OrdinalIgnoreCase); /// /// Holds a list of assembly modified times that we can use to detect a file change prior to the being in place. /// Once it's in place and subscribed, we'll remove the entry because any further changes will be detected that way. /// private readonly Dictionary _assemblyUpdatedTimesUtc = new(StringComparer.OrdinalIgnoreCase); private readonly object _guard = new(); private readonly DiagnosticDescriptor _analyzerChangedRule = new( id: IDEDiagnosticIds.AnalyzerChangedId, title: ServicesVSResources.AnalyzerChangedOnDisk, messageFormat: ServicesVSResources.The_analyzer_assembly_0_has_changed_Diagnostics_may_be_incorrect_until_Visual_Studio_is_restarted, category: FeaturesResources.Roslyn_HostError, defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); [ImportingConstructor] [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] public AnalyzerFileWatcherService( VisualStudioWorkspaceImpl workspace, HostDiagnosticUpdateSource hostDiagnosticUpdateSource, SVsServiceProvider serviceProvider) { _workspace = workspace; _updateSource = hostDiagnosticUpdateSource; _fileChangeService = (IVsFileChangeEx)serviceProvider.GetService(typeof(SVsFileChangeEx)); } internal void RemoveAnalyzerAlreadyLoadedDiagnostics(ProjectId projectId, string analyzerPath) => _updateSource.ClearDiagnosticsForProject(projectId, Tuple.Create(s_analyzerChangedErrorId, analyzerPath)); private void RaiseAnalyzerChangedWarning(ProjectId projectId, string analyzerPath) { var messageArguments = new string[] { analyzerPath }; var project = _workspace.CurrentSolution.GetProject(projectId); if (project != null && DiagnosticData.TryCreate(_analyzerChangedRule, messageArguments, project, out var diagnostic)) { _updateSource.UpdateDiagnosticsForProject(projectId, Tuple.Create(s_analyzerChangedErrorId, analyzerPath), SpecializedCollections.SingletonEnumerable(diagnostic)); } } private DateTime? GetLastUpdateTimeUtc(string fullPath) { try { var creationTimeUtc = File.GetCreationTimeUtc(fullPath); var writeTimeUtc = File.GetLastWriteTimeUtc(fullPath); return writeTimeUtc > creationTimeUtc ? writeTimeUtc : creationTimeUtc; } catch (IOException) { return null; } catch (UnauthorizedAccessException) { return null; } } internal void TrackFilePathAndReportErrorIfChanged(string filePath, ProjectId projectId) { lock (_guard) { if (!_fileChangeTrackers.TryGetValue(filePath, out var tracker)) { tracker = new FileChangeTracker(_fileChangeService, filePath); tracker.UpdatedOnDisk += Tracker_UpdatedOnDisk; _ = tracker.StartFileChangeListeningAsync(); _fileChangeTrackers.Add(filePath, tracker); } if (_assemblyUpdatedTimesUtc.TryGetValue(filePath, out var assemblyUpdatedTime)) { var currentFileUpdateTime = GetLastUpdateTimeUtc(filePath); if (currentFileUpdateTime != null) { if (currentFileUpdateTime != assemblyUpdatedTime) { RaiseAnalyzerChangedWarning(projectId, filePath); } // If the the tracker is in place, at this point we can stop checking any further for this assembly if (tracker.PreviousCallToStartFileChangeHasAsynchronouslyCompleted) { _assemblyUpdatedTimesUtc.Remove(filePath); } } } else { // We don't have an assembly updated time. This means we either haven't ever checked it, or we have a file watcher in place. // If the file watcher is in place, then nothing further to do. Otherwise we'll add the update time to the map for future checking if (!tracker.PreviousCallToStartFileChangeHasAsynchronouslyCompleted) { var currentFileUpdateTime = GetLastUpdateTimeUtc(filePath); if (currentFileUpdateTime != null) { _assemblyUpdatedTimesUtc[filePath] = currentFileUpdateTime.Value; } } } } } private void Tracker_UpdatedOnDisk(object sender, EventArgs e) { var tracker = (FileChangeTracker)sender; var filePath = tracker.FilePath; lock (_guard) { // Once we've created a diagnostic for a given analyzer file, there's // no need to keep watching it. _fileChangeTrackers.Remove(filePath); } tracker.Dispose(); tracker.UpdatedOnDisk -= Tracker_UpdatedOnDisk; // Traverse the chain of requesting assemblies to get back to the original analyzer // assembly. foreach (var project in _workspace.CurrentSolution.Projects) { var analyzerFileReferences = project.AnalyzerReferences.OfType(); if (analyzerFileReferences.Any(a => a.FullPath.Equals(filePath, StringComparison.OrdinalIgnoreCase))) { RaiseAnalyzerChangedWarning(project.Id, filePath); } } } } }