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);
+ }
+}