diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ffcdc8e..fec6a6d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -21,6 +21,7 @@ jobs: steps: - uses: actions/checkout@v2 + - uses: actions/setup-dotnet@v5 - name: Build BulkAnalysisRunner run: dotnet build src --configuration ${{ matrix.configuration }} - name: Test BulkAnalysisRunner diff --git a/Directory.Build.props b/Directory.Build.props index a21f0bc..f35dbf2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ 4 true true - 11 + 12 enable 4.0.0.0 @@ -19,6 +19,7 @@ $(MSBuildThisFileDirectory)\build\CodeAnalysis.ruleset true AllEnabledByDefault + true $(MSBuildThisFileDirectory)bin diff --git a/global.json b/global.json new file mode 100644 index 0000000..50baaf1 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.300", + "rollForward": "latestPatch" + } +} diff --git a/src/WTG.BulkAnalysis.Core/Internal/AnalyzerCache.cs b/src/WTG.BulkAnalysis.Core/Internal/AnalyzerCache.cs index 7afe424..6b55536 100644 --- a/src/WTG.BulkAnalysis.Core/Internal/AnalyzerCache.cs +++ b/src/WTG.BulkAnalysis.Core/Internal/AnalyzerCache.cs @@ -13,153 +13,198 @@ namespace WTG.BulkAnalysis.Core { abstract class AnalyzerCache { - public static AnalyzerCache Create(ImmutableHashSet diagnosticIds, string loadDir, ImmutableArray loadList) + protected AnalyzerCache(ImmutableHashSet diagnosticIds, ILog log) + { + this.diagnosticIds = diagnosticIds; + this.log = log; + analyzerFilter = a => a.SupportedDiagnostics.Any(x => diagnosticIds.Contains(x.Id)); + providerFilter = p => p.FixableDiagnosticIds.Any(diagnosticIds.Contains); + providerLookup = new ConcurrentDictionary>(); + referenceCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + subscribed = new HashSet(); + } + + readonly ImmutableHashSet diagnosticIds; + readonly ILog log; + readonly Predicate analyzerFilter; + readonly Predicate providerFilter; + readonly ConcurrentDictionary> providerLookup; + readonly ConcurrentDictionary referenceCache; + readonly HashSet subscribed; + + public static AnalyzerCache Create(ImmutableHashSet diagnosticIds, string loadDir, ImmutableArray loadList, ILog log) { if (loadList.Length > 0) { - return new Explicit(diagnosticIds, loadDir, loadList); + return new Explicit(diagnosticIds, loadDir, loadList, log); } else { - return new Implicit(diagnosticIds, loadDir); + return new Implicit(diagnosticIds, loadDir, log); } } public abstract ImmutableArray GetAnalyzers(Project project); public abstract ImmutableDictionary> GetAllCodeFixProviders(Project project); - static IEnumerable Get(string assemblyPath, Predicate filter) + protected ImmutableArray CollectAnalyzers(IEnumerable references, string? language) { - var assembly = Assembly.LoadFile(assemblyPath); + var builder = ImmutableArray.CreateBuilder(); - foreach (var type in assembly.GetTypes()) + foreach (var reference in references) { - if (!type.IsAbstract && type.IsSubclassOf(typeof(T))) + EnsureSubscribed(reference); + + var analyzers = language is not null + ? reference.GetAnalyzers(language) + : reference.GetAnalyzersForAllLanguages(); + + builder.AddRange(analyzers.Where(a => analyzerFilter(a))); + } + + return builder.ToImmutable(); + } + + protected ImmutableDictionary> CollectCodeFixProviders(IEnumerable references) + { + return ImmutableDictionary.ToImmutableDictionary( + from reference in references + from provider in GetCodeFixProviders(reference) + from id in provider.FixableDiagnosticIds + where diagnosticIds.Contains(id) + group provider by id into g + select g, + x => x.Key, + x => x.ToImmutableList()); + } + + protected AnalyzerFileReference GetOrCreateReference(string path, IAnalyzerAssemblyLoader loader) + => referenceCache.GetOrAdd(path, p => new AnalyzerFileReference(p, loader)); + + ImmutableArray GetCodeFixProviders(AnalyzerFileReference reference) + => providerLookup.GetOrAdd(reference.FullPath, key => LoadCodeFixProviders(reference)); + + ImmutableArray LoadCodeFixProviders(AnalyzerFileReference reference) + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var type in GetLoadableTypes(reference)) + { + if (!type.IsAbstract && type.IsSubclassOf(typeof(CodeFixProvider))) { - var instance = (T)Activator.CreateInstance(type); + var provider = (CodeFixProvider)Activator.CreateInstance(type); - if (filter(instance)) + if (providerFilter(provider)) { - yield return instance; + builder.Add(provider); } } } + + return builder.ToImmutable(); } - sealed class Implicit : AnalyzerCache + IEnumerable GetLoadableTypes(AnalyzerFileReference reference) { - public Implicit(ImmutableHashSet diagnosticIds, string loadDir) + Assembly assembly; + + try { - this.loadDir = loadDir; - analyzerFilter = a => a.SupportedDiagnostics.Any(x => diagnosticIds.Contains(x.Id)); - providerFilter = p => p.FixableDiagnosticIds.Any(diagnosticIds.Contains); - analyzerLookup = new ConcurrentDictionary>(); - providerLookup = new ConcurrentDictionary>(); + assembly = reference.GetAssembly(); } - - public override ImmutableArray GetAnalyzers(Project project) + catch (Exception ex) { - var paths = GetAnalyzerRefs(project); - - if (!string.IsNullOrEmpty(loadDir)) - { - paths = Remap(paths, loadDir); - } + log.WriteFormatted($" - Unable to load code fixes from '{reference.FullPath}': {ex.Message}", LogLevel.Warning); + return []; + } - return ImmutableArray.CreateRange( - from analyzerRef in paths - from analyzer in GetAnalyzers(analyzerRef) - select analyzer); + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + // A provider whose dependencies can't be resolved appears as a null entry here; + // keep the types that did load. This mirrors how Roslyn's AnalyzerFileReference + // tolerates partial load failures when enumerating analyzers. + return ex.Types.Where(t => t != null).ToArray()!; } + } - public override ImmutableDictionary> GetAllCodeFixProviders(Project project) + void EnsureSubscribed(AnalyzerFileReference reference) + { + if (subscribed.Add(reference)) { - var paths = GetAnalyzerRefs(project); + reference.AnalyzerLoadFailed += OnAnalyzerLoadFailed; + } + } - if (!string.IsNullOrEmpty(loadDir)) - { - paths = Remap(paths, loadDir); - } + void OnAnalyzerLoadFailed(object? sender, AnalyzerLoadFailureEventArgs e) + { + var path = (sender as AnalyzerFileReference)?.FullPath; + var detail = string.IsNullOrEmpty(e.Message) ? e.Exception?.Message : e.Message; + var suffix = string.IsNullOrEmpty(detail) ? string.Empty : $": {detail}"; + log.WriteFormatted($" - Skipping an analyzer in '{path}' ({e.ErrorCode}){suffix}", LogLevel.Warning); + } - return ImmutableDictionary.ToImmutableDictionary( - from analyzerRef in paths - from codeFixProvider in GetCodeFixProviders(analyzerRef) - from diagnosticId in codeFixProvider.FixableDiagnosticIds - group codeFixProvider by diagnosticId into g - select g, - x => x.Key, - x => x.ToImmutableList()); + sealed class Implicit : AnalyzerCache + { + public Implicit(ImmutableHashSet diagnosticIds, string loadDir, ILog log) + : base(diagnosticIds, log) + { + this.loadDir = loadDir; } - ImmutableArray GetAnalyzers(string assemblyName) => GetCached(assemblyName, analyzerLookup, analyzerFilter); - ImmutableArray GetCodeFixProviders(string assemblyName) => GetCached(assemblyName, providerLookup, providerFilter); + public override ImmutableArray GetAnalyzers(Project project) + => CollectAnalyzers(GetReferences(project), project.Language); + + public override ImmutableDictionary> GetAllCodeFixProviders(Project project) + => CollectCodeFixProviders(GetReferences(project)); - static ImmutableArray GetCached(string assemblyName, ConcurrentDictionary> lookup, Predicate filter) + IEnumerable GetReferences(Project project) { - if (!lookup.TryGetValue(assemblyName, out var result)) + if (string.IsNullOrEmpty(loadDir)) { - result = Get(assemblyName, filter).ToImmutableArray(); - result = lookup.GetOrAdd(assemblyName, result); + return project.AnalyzerReferences.OfType(); } - return result; + return Remap(project, loadDir); } - static IEnumerable GetAnalyzerRefs(Project project) + IEnumerable Remap(Project project, string loadDir) { - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var reference in project.AnalyzerReferences) + foreach (var reference in project.AnalyzerReferences.OfType()) { - if (!string.IsNullOrEmpty(reference.FullPath)) + if (string.IsNullOrEmpty(reference.FullPath)) { - result.Add(reference.FullPath!); + continue; } - } - - return result; - } - static IEnumerable Remap(IEnumerable source, string loadDir) - { - foreach (var item in source) - { - var proposal = Path.Combine(loadDir, Path.GetFileName(item)); + var proposal = Path.Combine(loadDir, Path.GetFileName(reference.FullPath)); if (File.Exists(proposal)) { - yield return proposal; + var loader = reference.AssemblyLoader; + loader.AddDependencyLocation(proposal); + yield return GetOrCreateReference(proposal, loader); } } } readonly string loadDir; - readonly Predicate analyzerFilter; - readonly Predicate providerFilter; - readonly ConcurrentDictionary> analyzerLookup; - readonly ConcurrentDictionary> providerLookup; } sealed class Explicit : AnalyzerCache { - public Explicit(ImmutableHashSet diagnosticIds, string loadDir, ImmutableArray loadList) + public Explicit(ImmutableHashSet diagnosticIds, string loadDir, ImmutableArray loadList, ILog log) + : base(diagnosticIds, log) { - Predicate analyzerFilter = a => a.SupportedDiagnostics.Any(x => diagnosticIds.Contains(x.Id)); - Predicate providerFilter = p => p.FixableDiagnosticIds.Any(diagnosticIds.Contains); - - var paths = PrefixPaths(loadDir, loadList); - - analyzers = paths.SelectMany(x => Get(x, analyzerFilter)).ToImmutableArray(); - - providers = ImmutableDictionary.ToImmutableDictionary( - from path in paths - from provider in Get(path, providerFilter) - from id in provider.FixableDiagnosticIds - where diagnosticIds.Contains(id) - group provider by id into g - select g, - x => x.Key, - x => x.ToImmutableList()); + var references = PrefixPaths(loadDir, loadList) + .Select(path => GetOrCreateReference(path, FallbackAssemblyLoader.Instance)) + .ToImmutableArray(); + + analyzers = CollectAnalyzers(references, language: null); + providers = CollectCodeFixProviders(references); } public override ImmutableArray GetAnalyzers(Project project) => analyzers; @@ -180,5 +225,16 @@ static IEnumerable PrefixPaths(string loadDir, ImmutableArray lo readonly ImmutableArray analyzers; readonly ImmutableDictionary> providers; } + + sealed class FallbackAssemblyLoader : IAnalyzerAssemblyLoader + { + public static readonly FallbackAssemblyLoader Instance = new FallbackAssemblyLoader(); + + public void AddDependencyLocation(string fullPath) + { + } + + public Assembly LoadFromPath(string fullPath) => Assembly.LoadFrom(fullPath); + } } } diff --git a/src/WTG.BulkAnalysis.Core/Processor.cs b/src/WTG.BulkAnalysis.Core/Processor.cs index 1db9220..f2e2706 100644 --- a/src/WTG.BulkAnalysis.Core/Processor.cs +++ b/src/WTG.BulkAnalysis.Core/Processor.cs @@ -31,7 +31,7 @@ public static async Task ProcessAsync(RunContext context) var counter = 0; var numSolutions = context.SolutionPaths.Length; - var cache = AnalyzerCache.Create(context.RuleIds, context.LoadDir, context.LoadList); + var cache = AnalyzerCache.Create(context.RuleIds, context.LoadDir, context.LoadList, context.Log); foreach (var solutionPath in context.SolutionPaths) { diff --git a/src/WTG.BulkAnalysis.Core/WTG.BulkAnalysis.Core.csproj b/src/WTG.BulkAnalysis.Core/WTG.BulkAnalysis.Core.csproj index dd2f9a5..08f8eb9 100644 --- a/src/WTG.BulkAnalysis.Core/WTG.BulkAnalysis.Core.csproj +++ b/src/WTG.BulkAnalysis.Core/WTG.BulkAnalysis.Core.csproj @@ -5,13 +5,13 @@ - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/WTG.BulkAnalysis.Runner/App.config b/src/WTG.BulkAnalysis.Runner/App.config index ad20a97..954f56e 100644 --- a/src/WTG.BulkAnalysis.Runner/App.config +++ b/src/WTG.BulkAnalysis.Runner/App.config @@ -8,58 +8,50 @@ - - - - - + - + - + - + - + - - - - - + - + - + - + - + - + diff --git a/src/WTG.BulkAnalysis.Runner/Program.cs b/src/WTG.BulkAnalysis.Runner/Program.cs index 7344d11..a7c6994 100644 --- a/src/WTG.BulkAnalysis.Runner/Program.cs +++ b/src/WTG.BulkAnalysis.Runner/Program.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Immutable; using System.Diagnostics; -using System.IO; -using System.Reflection; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -23,8 +21,6 @@ static async Task Main(string[] args) return; } - AppDomain.CurrentDomain.AssemblyResolve += OnAppDomainAssemblyResolve; - using var cts = new CancellationTokenSource(); Console.CancelKeyPress += (sender, e) => @@ -141,19 +137,6 @@ static RunContext NewContext(CommandLineArgs arguments, XmlReportGenerator? repo return ((Parsed)parseResult).Value; } - static Assembly? OnAppDomainAssemblyResolve(object sender, ResolveEventArgs args) - { - if (args.RequestingAssembly == null) - { - return null; - } - - var requesterPath = new Uri(args.RequestingAssembly.CodeBase, UriKind.Absolute).LocalPath; - var directory = Path.GetDirectoryName(requesterPath); - var assemblyPath = Path.Combine(directory, new AssemblyName(args.Name).Name + ".dll"); - return Assembly.LoadFile(assemblyPath); - } - static Func? CreateFilter(CommandLineArgs arguments) { if (arguments.Filter == null) diff --git a/src/WTG.BulkAnalysis.Runner/WTG.BulkAnalysis.Runner.csproj b/src/WTG.BulkAnalysis.Runner/WTG.BulkAnalysis.Runner.csproj index 557d1ac..112439d 100644 --- a/src/WTG.BulkAnalysis.Runner/WTG.BulkAnalysis.Runner.csproj +++ b/src/WTG.BulkAnalysis.Runner/WTG.BulkAnalysis.Runner.csproj @@ -8,12 +8,9 @@ - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/WTG.BulkAnalysis.Test/.editorconfig b/src/WTG.BulkAnalysis.Test/.editorconfig new file mode 100644 index 0000000..5221cb1 --- /dev/null +++ b/src/WTG.BulkAnalysis.Test/.editorconfig @@ -0,0 +1,7 @@ +[*.cs] +# These Roslyn Analyzer rules are designed for production grade projects, not for our little test stubs. +# Ignore these rules in the test project to avoid unnecessary noise. +dotnet_diagnostic.RS1036.severity = none +dotnet_diagnostic.RS1038.severity = none +dotnet_diagnostic.RS1041.severity = none +dotnet_diagnostic.RS2008.severity = none \ No newline at end of file diff --git a/src/WTG.BulkAnalysis.Test/AnalyzerCacheTest.cs b/src/WTG.BulkAnalysis.Test/AnalyzerCacheTest.cs new file mode 100644 index 0000000..7f78bc1 --- /dev/null +++ b/src/WTG.BulkAnalysis.Test/AnalyzerCacheTest.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using WTG.BulkAnalysis.Core; + +namespace WTG.BulkAnalysis.Test; + +public class AnalyzerCacheTest +{ + [Test] + public void LoadsAnalyzersFromAssembly() + { + var cache = CreateCache(SampleAnalyzer.DiagnosticId); + + var analyzers = cache.GetAnalyzers(CreateProject()); + + Assert.That(analyzers.Select(a => a.GetType()), Has.Member(typeof(SampleAnalyzer))); + } + + [Test] + public void OnlyReturnsAnalyzersForTheRequestedDiagnosticIds() + { + var cache = CreateCache("SomeOtherIdThatNothingSupports"); + + var analyzers = cache.GetAnalyzers(CreateProject()); + + Assert.That(analyzers.Select(a => a.GetType()), Has.No.Member(typeof(SampleAnalyzer))); + } + + static AnalyzerCache CreateCache(string diagnosticId) + { + return AnalyzerCache.Create( + ImmutableHashSet.Create(diagnosticId), + loadDir: string.Empty, + loadList: ImmutableArray.Create(typeof(AnalyzerCacheTest).Assembly.Location), + log: NullLog.Instance); + } + + static Project CreateProject() + { + var workspace = new AdhocWorkspace(); + return workspace.AddProject("Sample", LanguageNames.CSharp); + } + + sealed class NullLog : ILog + { + public static readonly NullLog Instance = new NullLog(); + + public void WriteFormatted(FormattableString message, LogLevel level = LogLevel.Normal) + { + } + + public void WriteLine(string message, LogLevel level = LogLevel.Normal) + { + } + + public void WriteLine() + { + } + } + + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class SampleAnalyzer : DiagnosticAnalyzer + { + public const string DiagnosticId = "WTGTEST01"; + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + } + + static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( + DiagnosticId, + "Sample", + "Sample", + "Test", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + } +}