From e75d1319efc0bfc94138f9cf2509c0c7c380e82d Mon Sep 17 00:00:00 2001 From: Matt Parker <61717342+MattParkerDev@users.noreply.github.com> Date: Fri, 28 Nov 2025 22:26:39 +1000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Reload=20projects=20when=20analyzer?= =?UTF-8?q?=20dlls=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DependencyInjection.cs | 1 + .../Features/Analysis/RoslynAnalysis.cs | 34 +++++++++++- .../Features/Events/GlobalEvents.cs | 4 +- .../FileWatching/AnalyzerFileWatcher.cs | 55 +++++++++++++++++++ .../FileWatching/FileChangedService.cs | 11 +++- .../IdeFileExternalChangeHandler.cs | 9 ++- .../Features/Analysis/RoslynAnalysisTests.cs | 4 +- 7 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 src/SharpIDE.Application/Features/FileWatching/AnalyzerFileWatcher.cs diff --git a/src/SharpIDE.Application/DependencyInjection.cs b/src/SharpIDE.Application/DependencyInjection.cs index 2c4d35b..e1d1e6e 100644 --- a/src/SharpIDE.Application/DependencyInjection.cs +++ b/src/SharpIDE.Application/DependencyInjection.cs @@ -34,6 +34,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddLogging(); return services; } diff --git a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs index 53bb41e..b9901bc 100644 --- a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs +++ b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs @@ -30,6 +30,7 @@ using SharpIDE.Application.Features.Analysis.FixLoaders; using SharpIDE.Application.Features.Analysis.ProjectLoader; using SharpIDE.Application.Features.Analysis.Razor; using SharpIDE.Application.Features.Build; +using SharpIDE.Application.Features.FileWatching; using SharpIDE.Application.Features.SolutionDiscovery; using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence; using CodeAction = Microsoft.CodeAnalysis.CodeActions.CodeAction; @@ -40,10 +41,11 @@ using DiagnosticSeverity = Microsoft.CodeAnalysis.DiagnosticSeverity; namespace SharpIDE.Application.Features.Analysis; -public class RoslynAnalysis(ILogger logger, BuildService buildService) +public class RoslynAnalysis(ILogger logger, BuildService buildService, AnalyzerFileWatcher analyzerFileWatcher) { private readonly ILogger _logger = logger; private readonly BuildService _buildService = buildService; + private readonly AnalyzerFileWatcher _analyzerFileWatcher = analyzerFileWatcher; public static AdhocWorkspace? _workspace; private static CustomMsBuildProjectLoader? _msBuildProjectLoader; @@ -119,6 +121,13 @@ public class RoslynAnalysis(ILogger logger, BuildService buildSe //_msBuildProjectLoader!.LoadMetadataForReferencedProjects = true; var (solutionInfo, projectFileInfos) = await _msBuildProjectLoader!.LoadSolutionInfoAsync(_sharpIdeSolutionModel.FilePath, cancellationToken: cancellationToken); _projectFileInfoMap = projectFileInfos; + var analyzerReferencePaths = solutionInfo.Projects + .SelectMany(p => p.AnalyzerReferences.OfType().Select(a => a.FullPath)) + .OfType() + .Distinct() + .ToImmutableArray(); + + await _analyzerFileWatcher.StartWatchingFiles(analyzerReferencePaths); _workspace.ClearSolution(); var solution = _workspace.AddSolution(solutionInfo); } @@ -270,6 +279,29 @@ public class RoslynAnalysis(ILogger logger, BuildService buildSe }).ToImmutableArray(); } + public async Task ReloadProjectsWithAnyOfAnalyzerFileReferences(ImmutableArray analyzerFilePaths, CancellationToken cancellationToken = default) + { + using var _ = SharpIdeOtel.Source.StartActivity($"{nameof(RoslynAnalysis)}.{nameof(ReloadProjectsWithAnyOfAnalyzerFileReferences)}"); + await _solutionLoadedTcs.Task; + var projectsToReload = _workspace!.CurrentSolution.Projects + .Where(p => p.AnalyzerReferences + .OfType() + .Where(s => s.FullPath is not null) + .Any(a => analyzerFilePaths.Contains(a.FullPath!))) + .ToList(); + + if (projectsToReload.Count is 0) return false; + + _logger.LogInformation("RoslynAnalysis: Reloading {ProjectCount} projects that reference an analyzer that changed", projectsToReload.Count); + foreach (var project in projectsToReload) + { + var sharpIdeProjectModel = _sharpIdeSolutionModel!.AllProjects.Single(p => p.FilePath == project.FilePath); + await ReloadProject(sharpIdeProjectModel, cancellationToken); + } + + return true; + } + public async Task UpdateSolutionDiagnostics(CancellationToken cancellationToken = default) { using var _ = SharpIdeOtel.Source.StartActivity($"{nameof(RoslynAnalysis)}.{nameof(UpdateSolutionDiagnostics)}"); diff --git a/src/SharpIDE.Application/Features/Events/GlobalEvents.cs b/src/SharpIDE.Application/Features/Events/GlobalEvents.cs index af547f9..64c5274 100644 --- a/src/SharpIDE.Application/Features/Events/GlobalEvents.cs +++ b/src/SharpIDE.Application/Features/Events/GlobalEvents.cs @@ -1,4 +1,5 @@ -using SharpIDE.Application.Features.Debugging; +using System.Collections.Immutable; +using SharpIDE.Application.Features.Debugging; using SharpIDE.Application.Features.SolutionDiscovery; using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence; @@ -18,6 +19,7 @@ public class GlobalEvents public EventWrapper SolutionAltered { get; } = new(() => Task.CompletedTask); public FileSystemWatcherInternal FileSystemWatcherInternal { get; } = new(); + public EventWrapper, Task> AnalyzerDllsChanged { get; } = new(_ => Task.CompletedTask); } /// diff --git a/src/SharpIDE.Application/Features/FileWatching/AnalyzerFileWatcher.cs b/src/SharpIDE.Application/Features/FileWatching/AnalyzerFileWatcher.cs new file mode 100644 index 0000000..3ddad2c --- /dev/null +++ b/src/SharpIDE.Application/Features/FileWatching/AnalyzerFileWatcher.cs @@ -0,0 +1,55 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using FileWatcherEx; +using Microsoft.CodeAnalysis.Shared.TestHooks; +using Microsoft.CodeAnalysis.Threading; +using SharpIDE.Application.Features.Events; + +namespace SharpIDE.Application.Features.FileWatching; + +public class AnalyzerFileWatcher +{ + private readonly AsyncBatchingWorkQueue _fileChangedQueue; + + public AnalyzerFileWatcher() + { + _fileChangedQueue = new AsyncBatchingWorkQueue( + TimeSpan.FromMilliseconds(500), + async (filePaths, ct) => await GlobalEvents.Instance.AnalyzerDllsChanged.InvokeParallelAsync(filePaths.ToImmutableArray()), + new AsynchronousOperationListenerProvider.NullOperationListener(), + CancellationToken.None); + } + + private readonly ConcurrentDictionary _fileWatchers = new(); + + // I wanted to avoid this, but unfortunately we have to watch individual files in different directories + public async Task StartWatchingFiles(ImmutableArray filePaths) + { + // This can definitely be optimized to not recreate watchers unnecessarily + var existingFileWatchers = _fileWatchers.Values.ToList(); + foreach (var watcher in existingFileWatchers) + { + watcher.OnChanged -= OnFileChanged; + watcher.Dispose(); + } + _fileWatchers.Clear(); + + foreach (var filePath in filePaths) + { + var fileWatcher = new FileSystemWatcherEx(Path.GetDirectoryName(filePath)!) + { + Filter = Path.GetFileName(filePath), + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size + }; + + fileWatcher.OnChanged += OnFileChanged; + fileWatcher.Start(); + _fileWatchers.TryAdd(filePath, fileWatcher); + } + } + + private void OnFileChanged(object? sender, FileChangedEvent e) + { + _fileChangedQueue.AddWork(e.FullPath); + } +} diff --git a/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs b/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs index 5d09642..bb704cf 100644 --- a/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs +++ b/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs @@ -1,4 +1,5 @@ -using Microsoft.CodeAnalysis.Shared.TestHooks; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.CodeAnalysis.Threading; using Microsoft.VisualStudio.SolutionPersistence.Model; using SharpIDE.Application.Features.Analysis; @@ -71,6 +72,14 @@ public class FileChangedService } } + public async Task AnalyzerDllFilesChanged(ImmutableArray changedDllPaths) + { + var success = await _roslynAnalysis.ReloadProjectsWithAnyOfAnalyzerFileReferences(changedDllPaths); + if (success is false) return; + GlobalEvents.Instance.SolutionAltered.InvokeParallelFireAndForget(); + _updateSolutionDiagnosticsQueue.AddWork(); + } + // All file changes should go via this service public async Task SharpIdeFileChanged(SharpIdeFile file, string newContents, FileChangeType changeType, SharpIdeFileLinePosition? linePosition = null) { diff --git a/src/SharpIDE.Application/Features/FileWatching/IdeFileExternalChangeHandler.cs b/src/SharpIDE.Application/Features/FileWatching/IdeFileExternalChangeHandler.cs index 2f9bdde..f52ad09 100644 --- a/src/SharpIDE.Application/Features/FileWatching/IdeFileExternalChangeHandler.cs +++ b/src/SharpIDE.Application/Features/FileWatching/IdeFileExternalChangeHandler.cs @@ -1,4 +1,5 @@ -using Ardalis.GuardClauses; +using System.Collections.Immutable; +using Ardalis.GuardClauses; using Microsoft.Extensions.Logging; using SharpIDE.Application.Features.Events; using SharpIDE.Application.Features.SolutionDiscovery; @@ -24,6 +25,7 @@ public class IdeFileExternalChangeHandler GlobalEvents.Instance.FileSystemWatcherInternal.DirectoryCreated.Subscribe(OnFolderCreated); GlobalEvents.Instance.FileSystemWatcherInternal.DirectoryDeleted.Subscribe(OnFolderDeleted); GlobalEvents.Instance.FileSystemWatcherInternal.DirectoryRenamed.Subscribe(OnFolderRenamed); + GlobalEvents.Instance.AnalyzerDllsChanged.Subscribe(OnAnalyzerDllsChanged); } private async Task OnFileRenamed(string oldFilePath, string newFilePath) @@ -129,4 +131,9 @@ public class IdeFileExternalChangeHandler await _fileChangedService.SharpIdeFileChanged(file, await File.ReadAllTextAsync(file.Path), FileChangeType.ExternalChange); } } + + private async Task OnAnalyzerDllsChanged(ImmutableArray analyzerDllPaths) + { + await _fileChangedService.AnalyzerDllFilesChanged(analyzerDllPaths); + } } diff --git a/tests/SharpIDE.Application.IntegrationTests/Features/Analysis/RoslynAnalysisTests.cs b/tests/SharpIDE.Application.IntegrationTests/Features/Analysis/RoslynAnalysisTests.cs index 003edc5..c578102 100644 --- a/tests/SharpIDE.Application.IntegrationTests/Features/Analysis/RoslynAnalysisTests.cs +++ b/tests/SharpIDE.Application.IntegrationTests/Features/Analysis/RoslynAnalysisTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SharpIDE.Application.Features.Analysis; using SharpIDE.Application.Features.Build; +using SharpIDE.Application.Features.FileWatching; using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence; [assembly: CaptureConsole] @@ -29,8 +30,9 @@ public class RoslynAnalysisTests var services = serviceCollection.BuildServiceProvider(); var logger = services.GetRequiredService>(); var buildService = services.GetRequiredService(); + var analyzerFileWatcher = services.GetRequiredService(); - var roslynAnalysis = new RoslynAnalysis(logger, buildService); + var roslynAnalysis = new RoslynAnalysis(logger, buildService, analyzerFileWatcher); var solutionModel = await VsPersistenceMapper.GetSolutionModel(@"C:\Users\Matthew\Documents\Git\SharpIDE\SharpIDE.sln", TestContext.Current.CancellationToken); var sharpIdeApplicationProject = solutionModel.AllProjects.Single(p => p.Name == "SharpIDE.Application");