diff --git a/src/VisualStudio/CSharp/Test/ProjectSystemShim/SdkAnalyzerAssemblyRedirectorTests.cs b/src/VisualStudio/CSharp/Test/ProjectSystemShim/SdkAnalyzerAssemblyRedirectorTests.cs new file mode 100644 index 0000000000000..93a8ce2e88862 --- /dev/null +++ b/src/VisualStudio/CSharp/Test/ProjectSystemShim/SdkAnalyzerAssemblyRedirectorTests.cs @@ -0,0 +1,86 @@ +// 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. + +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Microsoft.VisualStudio.LanguageServices.ProjectSystem; +using Roslyn.Test.Utilities; +using Xunit; + +namespace Roslyn.VisualStudio.CSharp.UnitTests.ProjectSystemShim; + +public sealed class SdkAnalyzerAssemblyRedirectorTests : TestBase +{ + [Theory] + [InlineData("9.0.0-preview.5.24306.11", "9.0.0-preview.7.24406.2")] + [InlineData("9.0.0-preview.5.24306.11", "9.0.1-preview.7.24406.2")] + [InlineData("9.0.100", "9.0.0-preview.7.24406.2")] + [InlineData("9.0.100", "9.0.200")] + [InlineData("9.0.100", "9.0.101")] + public void SameMajorMinorVersion(string a, string b) + { + var testDir = Temp.CreateDirectory(); + + var vsDir = Path.Combine(testDir.Path, "vs"); + Metadata(vsDir, new() { { "AspNetCoreAnalyzers", a } }); + var vsAnalyzerPath = FakeDll(vsDir, @$"AspNetCoreAnalyzers\analyzers\dotnet\cs", "Microsoft.AspNetCore.App.Analyzers"); + var sdkAnalyzerPath = FakeDll(testDir.Path, @$"sdk\packs\Microsoft.AspNetCore.App.Ref\{b}\analyzers\dotnet\cs", "Microsoft.AspNetCore.App.Analyzers"); + + var resolver = new SdkAnalyzerAssemblyRedirector(vsDir); + var redirected = resolver.RedirectPath(sdkAnalyzerPath); + AssertEx.Equal(vsAnalyzerPath, redirected); + } + + [Fact] + public void DifferentPathSuffix() + { + var testDir = Temp.CreateDirectory(); + + var vsDir = Path.Combine(testDir.Path, "vs"); + Metadata(vsDir, new() { { "AspNetCoreAnalyzers", "9.0.0-preview.5.24306.11" } }); + FakeDll(vsDir, @"AspNetCoreAnalyzers\analyzers\dotnet\cs", "Microsoft.AspNetCore.App.Analyzers"); + var sdkAnalyzerPath = FakeDll(testDir.Path, @"sdk\packs\Microsoft.AspNetCore.App.Ref\9.0.0-preview.7.24406.2\analyzers\dotnet\vb", "Microsoft.AspNetCore.App.Analyzers"); + + var resolver = new SdkAnalyzerAssemblyRedirector(vsDir); + var redirected = resolver.RedirectPath(sdkAnalyzerPath); + Assert.Null(redirected); + } + + [Theory] + [InlineData("8.0.100", "9.0.0-preview.7.24406.2")] + [InlineData("9.1.100", "9.0.0-preview.7.24406.2")] + [InlineData("9.1.0-preview.5.24306.11", "9.0.0-preview.7.24406.2")] + [InlineData("9.0.100", "9.1.100")] + [InlineData("9.0.100", "10.0.100")] + [InlineData("9.9.100", "9.10.100")] + public void DifferentMajorMinorVersion(string a, string b) + { + var testDir = Temp.CreateDirectory(); + + var vsDir = Path.Combine(testDir.Path, "vs"); + Metadata(vsDir, new() { { "AspNetCoreAnalyzers", a } }); + FakeDll(vsDir, @$"AspNetCoreAnalyzers\analyzers\dotnet\cs", "Microsoft.AspNetCore.App.Analyzers"); + var sdkAnalyzerPath = FakeDll(testDir.Path, @$"sdk\packs\Microsoft.AspNetCore.App.Ref\{b}\analyzers\dotnet\cs", "Microsoft.AspNetCore.App.Analyzers"); + + var resolver = new SdkAnalyzerAssemblyRedirector(vsDir); + var redirected = resolver.RedirectPath(sdkAnalyzerPath); + Assert.Null(redirected); + } + + private static string FakeDll(string root, string subdir, string name) + { + var dllPath = Path.Combine(root, subdir, $"{name}.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(dllPath)); + File.WriteAllText(dllPath, ""); + return dllPath; + } + + private static void Metadata(string root, Dictionary versions) + { + var metadataFilePath = Path.Combine(root, "metadata.json"); + Directory.CreateDirectory(Path.GetDirectoryName(metadataFilePath)); + File.WriteAllText(metadataFilePath, JsonSerializer.Serialize(versions)); + } +} diff --git a/src/VisualStudio/Core/Def/ProjectSystem/SdkAnalyzerAssemblyRedirector.cs b/src/VisualStudio/Core/Def/ProjectSystem/SdkAnalyzerAssemblyRedirector.cs new file mode 100644 index 0000000000000..c11a1d961b6ca --- /dev/null +++ b/src/VisualStudio/Core/Def/ProjectSystem/SdkAnalyzerAssemblyRedirector.cs @@ -0,0 +1,193 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel.Composition; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text.Json; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.Workspaces.AnalyzerRedirecting; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; + +// Example: +// FullPath: "C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.8\analyzers\dotnet\System.Windows.Forms.Analyzers.dll" +// ProductVersion: "8.0.8" +// PathSuffix: "analyzers\dotnet" +using AnalyzerInfo = (string FullPath, string ProductVersion, string PathSuffix); + +namespace Microsoft.VisualStudio.LanguageServices.ProjectSystem; + +/// +/// See . +/// +[Export(typeof(IAnalyzerAssemblyRedirector))] +internal sealed class SdkAnalyzerAssemblyRedirector : IAnalyzerAssemblyRedirector +{ + private readonly IVsActivityLog? _log; + + private readonly bool _enabled; + + private readonly string? _insertedAnalyzersDirectory; + + /// + /// Map from analyzer assembly name (file name without extension) to a list of matching analyzers. + /// + private readonly ImmutableDictionary> _analyzerMap; + + [ImportingConstructor] + [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] + public SdkAnalyzerAssemblyRedirector(SVsServiceProvider serviceProvider) : this( + Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"CommonExtensions\Microsoft\DotNet")), + serviceProvider.GetServiceOnMainThread()) + { + } + + // Internal for testing. + [SuppressMessage("RoslynDiagnosticsReliability", "RS0034: Exported parts should have a public constructor marked with 'ImportingConstructorAttribute'", + Justification = "This is an internal constructor exposed for testing and delegated to by the public importing constructor")] + internal SdkAnalyzerAssemblyRedirector(string? insertedAnalyzersDirectory, IVsActivityLog? log = null) + { + _log = log; + var enable = Environment.GetEnvironmentVariable("DOTNET_ANALYZER_REDIRECTING"); + _enabled = !"0".Equals(enable, StringComparison.OrdinalIgnoreCase) && !"false".Equals(enable, StringComparison.OrdinalIgnoreCase); + _insertedAnalyzersDirectory = insertedAnalyzersDirectory; + _analyzerMap = CreateAnalyzerMap(); + } + + private ImmutableDictionary> CreateAnalyzerMap() + { + if (!_enabled) + { + Log("Analyzer redirecting is disabled."); + return ImmutableDictionary>.Empty; + } + + var metadataFilePath = Path.Combine(_insertedAnalyzersDirectory, "metadata.json"); + if (!File.Exists(metadataFilePath)) + { + Log($"File does not exist: {metadataFilePath}"); + return ImmutableDictionary>.Empty; + } + + var versions = JsonSerializer.Deserialize>(File.ReadAllText(metadataFilePath)); + if (versions is null || versions.Count == 0) + { + Log($"Versions are empty: {metadataFilePath}"); + return ImmutableDictionary>.Empty; + } + + var builder = ImmutableDictionary.CreateBuilder>(StringComparer.OrdinalIgnoreCase); + + // Expects layout like: + // VsInstallDir\DotNetRuntimeAnalyzers\WindowsDesktopAnalyzers\analyzers\dotnet\System.Windows.Forms.Analyzers.dll + // ~~~~~~~~~~~~~~~~~~~~~~~ = topLevelDirectory + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ = analyzerPath + + foreach (var topLevelDirectory in Directory.EnumerateDirectories(_insertedAnalyzersDirectory)) + { + foreach (var analyzerPath in Directory.EnumerateFiles(topLevelDirectory, "*.dll", SearchOption.AllDirectories)) + { + if (!analyzerPath.StartsWith(topLevelDirectory, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var subsetName = Path.GetFileName(topLevelDirectory); + if (!versions.TryGetValue(subsetName, out var version)) + { + continue; + } + + var analyzerName = Path.GetFileNameWithoutExtension(analyzerPath); + var pathSuffix = analyzerPath.Substring(topLevelDirectory.Length + 1 /* slash */); + pathSuffix = Path.GetDirectoryName(pathSuffix); + + AnalyzerInfo analyzer = new() { FullPath = analyzerPath, ProductVersion = version, PathSuffix = pathSuffix }; + + if (builder.TryGetValue(analyzerName, out var existing)) + { + existing.Add(analyzer); + } + else + { + builder.Add(analyzerName, [analyzer]); + } + } + } + + Log($"Loaded analyzer map ({builder.Count}): {_insertedAnalyzersDirectory}"); + + return builder.ToImmutable(); + } + + public string? RedirectPath(string fullPath) + { + if (_enabled && _analyzerMap.TryGetValue(Path.GetFileNameWithoutExtension(fullPath), out var analyzers)) + { + foreach (var analyzer in analyzers) + { + var directoryPath = Path.GetDirectoryName(fullPath); + + // Note that both paths we compare here are normalized via netfx's Path.GetDirectoryName. + if (directoryPath.EndsWith(analyzer.PathSuffix, StringComparison.OrdinalIgnoreCase) && + MajorAndMinorVersionsMatch(directoryPath, analyzer.PathSuffix, analyzer.ProductVersion)) + { + return analyzer.FullPath; + } + } + } + + return null; + + static bool MajorAndMinorVersionsMatch(string directoryPath, string pathSuffix, string version) + { + // Find the version number in the directory path - it is in the directory name before the path suffix. + // Example: + // "C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\8.0.8\analyzers\dotnet\" = directoryPath + // ~~~~~~~~~~~~~~~~ = pathSuffix + // ~~~~~ = directoryPathVersion + // This can match also a NuGet package because the version number is at the same position: + // "C:\.nuget\packages\Microsoft.WindowsDesktop.App.Ref\8.0.8\analyzers\dotnet\" + + var index = directoryPath.LastIndexOf(pathSuffix, StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + return false; + } + + var directoryPathVersion = Path.GetFileName(Path.GetDirectoryName(directoryPath.Substring(0, index))); + + return AreVersionMajorMinorPartEqual(directoryPathVersion, version); + } + + static bool AreVersionMajorMinorPartEqual(string version1, string version2) + { + var firstDotIndex = version1.IndexOf('.'); + if (firstDotIndex < 0) + { + return false; + } + + var secondDotIndex = version1.IndexOf('.', firstDotIndex + 1); + if (secondDotIndex < 0) + { + return false; + } + + return 0 == string.Compare(version1, 0, version2, 0, secondDotIndex, StringComparison.OrdinalIgnoreCase); + } + } + + private void Log(string message) + { + _log?.LogEntry( + (uint)__ACTIVITYLOG_ENTRYTYPE.ALE_INFORMATION, + "Roslyn" + nameof(SdkAnalyzerAssemblyRedirector), + message); + } +}