diff --git a/src/VisualStudio/CSharp/Test/ProjectSystemShim/SdkAnalyzerAssemblyRedirectorTests.cs b/src/VisualStudio/CSharp/Test/ProjectSystemShim/SdkAnalyzerAssemblyRedirectorTests.cs new file mode 100644 index 0000000000000..9d95f6ce2267c --- /dev/null +++ b/src/VisualStudio/CSharp/Test/ProjectSystemShim/SdkAnalyzerAssemblyRedirectorTests.cs @@ -0,0 +1,126 @@ +// 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", new() { Version = a, Files = [@"analyzers\dotnet\cs\Microsoft.AspNetCore.App.Analyzers.dll"] } } }); + var vsAnalyzerPath = FakeDll(vsDir, @$"AspNetCoreAnalyzers\analyzers\dotnet\cs", "Microsoft.AspNetCore.App.Analyzers"); + var sdkAnalyzerPath = @$"Z:\Program Files\dotnet\sdk\packs\Microsoft.AspNetCore.App.Ref\{b}\analyzers\dotnet\cs\Microsoft.AspNetCore.App.Analyzers.dll"; + + var resolver = new SdkAnalyzerAssemblyRedirectorCore(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", new() { Version = "9.0.0-preview.5.24306.11", Files = [@"analyzers\dotnet\cs\Microsoft.AspNetCore.App.Analyzers.dll"] } } }); + FakeDll(vsDir, @"AspNetCoreAnalyzers\analyzers\dotnet\cs", "Microsoft.AspNetCore.App.Analyzers"); + var sdkAnalyzerPath = @"Z:\Program Files\dotnet\sdk\packs\Microsoft.AspNetCore.App.Ref\9.0.0-preview.7.24406.2\analyzers\dotnet\vb\Microsoft.AspNetCore.App.Analyzers.dll"; + + var resolver = new SdkAnalyzerAssemblyRedirectorCore(vsDir); + var redirected = resolver.RedirectPath(sdkAnalyzerPath); + Assert.Null(redirected); + } + + [Fact] + public void DifferentPathSuffix_NoParentDirectory() + { + var testDir = Temp.CreateDirectory(); + + var vsDir = Path.Combine(testDir.Path, "vs"); + Metadata(vsDir, new() { { "AspNetCoreAnalyzers", new() { Version = "9.0.0-preview.5.24306.11", Files = [@"analyzers\dotnet\cs\Microsoft.AspNetCore.App.Analyzers.dll"] } } }); + var vsAnalyzerPath = FakeDll(vsDir, @"AspNetCoreAnalyzers\analyzers\dotnet\cs", "Microsoft.AspNetCore.App.Analyzers"); + + // The suffix matches but there is no parent directory. + var sdkAnalyzerPath = @"\sdk\packs\Microsoft.AspNetCore.App.Ref\9.0.0-preview.7.24406.2\analyzers\dotnet\cs\Microsoft.AspNetCore.App.Analyzers.dll"; + + var resolver = new SdkAnalyzerAssemblyRedirectorCore(vsDir); + var redirected = resolver.RedirectPath(sdkAnalyzerPath); + AssertEx.Equal(vsAnalyzerPath, redirected); + } + + [Fact] + public void TwoMajorVersions() + { + var testDir = Temp.CreateDirectory(); + + var vsDir = Path.Combine(testDir.Path, "vs"); + Metadata(vsDir, new() + { + { "AspNetCoreAnalyzers9", new() { Version = "9.0.0-preview.5.24306.11", Files = [@"analyzers\dotnet\cs\Microsoft.AspNetCore.App.Analyzers.dll"] } }, + { "AspNetCoreAnalyzers10", new() { Version = "10.0.0-preview.5.24306.11", Files = [@"analyzers\dotnet\cs\Microsoft.AspNetCore.App.Analyzers.dll"] } }, + }); + var vsAnalyzerPath9 = FakeDll(vsDir, @"AspNetCoreAnalyzers9\analyzers\dotnet\cs", "Microsoft.AspNetCore.App.Analyzers"); + var vsAnalyzerPath10 = FakeDll(vsDir, @"AspNetCoreAnalyzers10\analyzers\dotnet\cs", "Microsoft.AspNetCore.App.Analyzers"); + + var resolver = new SdkAnalyzerAssemblyRedirectorCore(vsDir); + AssertEx.Equal(vsAnalyzerPath9, resolver.RedirectPath(@"Z:\sdk\packs\Microsoft.AspNetCore.App.Ref\9.0.0-preview.7.24406.2\analyzers\dotnet\cs\Microsoft.AspNetCore.App.Analyzers.dll")); + AssertEx.Equal(vsAnalyzerPath10, resolver.RedirectPath(@"Z:\sdk\packs\Microsoft.AspNetCore.App.Ref\10.0.0-preview.7.24406.2\analyzers\dotnet\cs\Microsoft.AspNetCore.App.Analyzers.dll")); + } + + [Theory] + [InlineData("8.0.100", "9.0.0-preview.7.24406.2")] + [InlineData("8.0.1", "8.01.1")] + [InlineData("8.01.1", "8.0.1")] + [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")] + [InlineData("111.111.0", "1.1.0")] + [InlineData("1.1.0", "111.111.0")] + public void DifferentMajorMinorVersion(string a, string b) + { + var testDir = Temp.CreateDirectory(); + + var vsDir = Path.Combine(testDir.Path, "vs"); + Metadata(vsDir, new() { { "AspNetCoreAnalyzers", new() { Version = a, Files = [@"analyzers\dotnet\cs\Microsoft.AspNetCore.App.Analyzers.dll"] } } }); + FakeDll(vsDir, @$"AspNetCoreAnalyzers\analyzers\dotnet\cs", "Microsoft.AspNetCore.App.Analyzers"); + var sdkAnalyzerPath = @$"Z:\Program Files\dotnet\sdk\packs\Microsoft.AspNetCore.App.Ref\{b}\analyzers\dotnet\cs\Microsoft.AspNetCore.App.Analyzers.dll"; + + var resolver = new SdkAnalyzerAssemblyRedirectorCore(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 metadata) + { + var metadataFilePath = Path.Combine(root, "metadata.json"); + Directory.CreateDirectory(Path.GetDirectoryName(metadataFilePath)); + File.WriteAllText(metadataFilePath, JsonSerializer.Serialize(metadata)); + } +} diff --git a/src/VisualStudio/Core/Def/ProjectSystem/SdkAnalyzerAssemblyRedirector.cs b/src/VisualStudio/Core/Def/ProjectSystem/SdkAnalyzerAssemblyRedirector.cs new file mode 100644 index 0000000000000..76bb89ff96a6c --- /dev/null +++ b/src/VisualStudio/Core/Def/ProjectSystem/SdkAnalyzerAssemblyRedirector.cs @@ -0,0 +1,206 @@ +// 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.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))] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class SdkAnalyzerAssemblyRedirector(SVsServiceProvider serviceProvider) : SdkAnalyzerAssemblyRedirectorCore( + GetInsertedAnalyzersDirectory(), + (IVsActivityLog)serviceProvider.GetService(typeof(SVsActivityLog))) +{ + private static string? GetInsertedAnalyzersDirectory() + { + var enable = Environment.GetEnvironmentVariable("DOTNET_ANALYZER_REDIRECTING"); + if (!"0".Equals(enable, StringComparison.OrdinalIgnoreCase) && !"false".Equals(enable, StringComparison.OrdinalIgnoreCase)) + { + return Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"CommonExtensions\Microsoft\DotNet")); + } + + return null; + } +} + +/// +/// Core functionality of extracted for testing. +/// +internal class SdkAnalyzerAssemblyRedirectorCore : IAnalyzerAssemblyRedirector +{ + private readonly IVsActivityLog? _log; + + /// + /// Map from analyzer assembly name (file name without extension) to a list of matching analyzers. + /// + private readonly Lazy>> _analyzerMap; + + public SdkAnalyzerAssemblyRedirectorCore(string? insertedAnalyzersDirectory, IVsActivityLog? log = null) + { + _log = log; + _analyzerMap = new(() => CreateAnalyzerMap(insertedAnalyzersDirectory)); + } + + private ImmutableDictionary> CreateAnalyzerMap(string? insertedAnalyzersDirectory) + { + if (insertedAnalyzersDirectory == null) + { + 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; + } + + Dictionary? metadata; + + try + { + metadata = JsonSerializer.Deserialize>(File.ReadAllText(metadataFilePath)); + } + catch (Exception ex) + { + Log($"Failed to read or parse metadata file '{metadataFilePath}'. {ex}", __ACTIVITYLOG_ENTRYTYPE.ALE_WARNING); + return ImmutableDictionary>.Empty; + } + + if (metadata is null || metadata.Count == 0) + { + Log($"Metadata dictionary is empty: {metadataFilePath}", __ACTIVITYLOG_ENTRYTYPE.ALE_WARNING); + return ImmutableDictionary>.Empty; + } + + var builder = ImmutableDictionary.CreateBuilder>(StringComparer.OrdinalIgnoreCase); + + // Expects layout like: + // VsInstallDir\DotNetRuntimeAnalyzers\WindowsDesktopAnalyzers\analyzers\dotnet\System.Windows.Forms.Analyzers.dll + // ~~~~~~~~~~~~~~~~~~~~~~~ = subsetName + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ = pathSuffix + + foreach (var (subsetName, entry) in metadata) + { + foreach (var file in entry.Files) + { + var analyzerFullPath = Path.Combine(insertedAnalyzersDirectory, subsetName, file); + + AnalyzerInfo analyzer = new() + { + FullPath = analyzerFullPath, + ProductVersion = entry.Version, + PathSuffix = Path.GetDirectoryName(file), + }; + + var analyzerName = Path.GetFileNameWithoutExtension(file); + + 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 (_analyzerMap.Value.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 + 1, StringComparison.OrdinalIgnoreCase); + } + } + + private void Log(string message, __ACTIVITYLOG_ENTRYTYPE level = __ACTIVITYLOG_ENTRYTYPE.ALE_INFORMATION) + { + _log?.LogEntry( + (uint)level, + "Roslyn" + nameof(SdkAnalyzerAssemblyRedirector), + message); + } +} + +internal sealed class MetadataEntry +{ + public required string Version { get; init; } + public ImmutableArray Files { get; init; } +}