diff --git a/Directory.Packages.props b/Directory.Packages.props
index 284615d5c8..4721f9bd6d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,6 +10,8 @@
+
+
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/.gitignore b/ExternalProjects/BizHawk.AnalyzersTests/.gitignore
new file mode 100644
index 0000000000..cfac4d0ad2
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/.gitignore
@@ -0,0 +1,3 @@
+/bin
+/obj
+/TestResults
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/.run_tests_with_configuration.sh b/ExternalProjects/BizHawk.AnalyzersTests/.run_tests_with_configuration.sh
new file mode 100755
index 0000000000..51430928f4
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/.run_tests_with_configuration.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -e
+config="$1"
+shift
+dotnet test -c "$config" \
+ -l "junit;LogFilePath=TestResults/{assembly}.coverage.xml;MethodFormat=Class;FailureBodyFormat=Verbose" \
+ -l "console;verbosity=detailed" \
+ "$@"
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/FeatureNotImplementedAnalyzerTests.cs b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/FeatureNotImplementedAnalyzerTests.cs
new file mode 100644
index 0000000000..01966574e2
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/FeatureNotImplementedAnalyzerTests.cs
@@ -0,0 +1,61 @@
+namespace BizHawk.Tests.Analyzers;
+
+using System.Threading.Tasks;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
+ BizHawk.Analyzers.FeatureNotImplementedAnalyzer,
+ Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
+
+[TestClass]
+public sealed class FeatureNotImplementedAnalyzerTests
+{
+ [TestMethod]
+ public Task CheckMisuseOfFeatureNotImplementedAttr()
+ => Verify.VerifyAnalyzerAsync("""
+ using System;
+ using BizHawk.Emulation.Common;
+ public static class Cases {
+ [FeatureNotImplemented] private static int X => throw new NotImplementedException();
+ private static int Y {
+ [FeatureNotImplemented] get => throw new NotImplementedException();
+ [FeatureNotImplemented] set => throw new NotImplementedException();
+ }
+ [FeatureNotImplemented] private static int Z()
+ => throw new NotImplementedException();
+ {|BHI3300:[FeatureNotImplemented] private static int A => default;|}
+ private static int B {
+ {|BHI3300:[FeatureNotImplemented] get => default;|}
+ {|BHI3300:[FeatureNotImplemented] set => _ = value;|}
+ }
+ {|BHI3300:[FeatureNotImplemented] private static int C()
+ => default;|}
+ // wrong exception type, same code but different message:
+ [FeatureNotImplemented] private static int D => {|BHI3300:throw new InvalidOperationException()|};
+ private static int E {
+ [FeatureNotImplemented] get => {|BHI3300:throw new InvalidOperationException()|};
+ [FeatureNotImplemented] set => {|BHI3300:throw new InvalidOperationException()|};
+ }
+ [FeatureNotImplemented] private static int F()
+ => {|BHI3300:throw new InvalidOperationException()|};
+ // same code but different message, since only the simplest of expected syntaxes is checked for:
+ [FeatureNotImplemented] private static int G => {|BHI3300:throw (new NotImplementedException())|};
+ private static int H {
+ [FeatureNotImplemented] get => {|BHI3300:throw (new NotImplementedException())|};
+ [FeatureNotImplemented] set => {|BHI3300:throw (new NotImplementedException())|};
+ }
+ [FeatureNotImplemented] private static int I()
+ => {|BHI3300:throw (new NotImplementedException())|};
+ // the "wat" cases (at least the ones that are reachable in practice)
+ {|BHI3300:[FeatureNotImplemented] private static int K {
+ get => default;
+ set => _ = value;
+ }|}
+ }
+ namespace BizHawk.Emulation.Common {
+ [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property)]
+ public sealed class FeatureNotImplementedAttribute: Attribute {}
+ }
+ """);
+}
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/FirstOrDefaultOnStructAnalyzerTests.cs b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/FirstOrDefaultOnStructAnalyzerTests.cs
new file mode 100644
index 0000000000..b36bef47e4
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/FirstOrDefaultOnStructAnalyzerTests.cs
@@ -0,0 +1,37 @@
+namespace BizHawk.Tests.Analyzers;
+
+using System.Threading.Tasks;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
+ BizHawk.Analyzers.FirstOrDefaultOnStructAnalyzer,
+ Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
+
+[TestClass]
+public sealed class FirstOrDefaultOnStructAnalyzerTests
+{
+ [TestMethod]
+ public Task CheckMisuseOfFirstOrDefault()
+ => Verify.VerifyAnalyzerAsync("""
+ using System.Collections.Generic;
+ using System.Linq;
+ public static class Cases {
+ private static string? Y()
+ => new[] { 0x80.ToString(), 0x20.ToString(), 0x40.ToString() }.FirstOrDefault(static s => s.Length > 2);
+ private static string? Z()
+ => new List { 0x80, 0x20, 0x40 }.Select(static n => n.ToString()).FirstOrDefault();
+ private static int A()
+ => {|BHI3100:new[] { 0x80, 0x20, 0x40 }.FirstOrDefault()|};
+ private static int B()
+ => {|BHI3100:new List { 0x80, 0x20, 0x40 }.FirstOrDefault()|};
+ private static int C()
+ => {|BHI3100:new[] { 0x80, 0x20, 0x40 }.FirstOrDefault(static n => n.ToString().Length > 2)|};
+ private static int D()
+ => {|BHI3100:new List { 0x80, 0x20, 0x40 }.FirstOrDefault(static n => n.ToString().Length > 2)|};
+ }
+ namespace BizHawk.Common.CollectionExtensions {
+ public static class CollectionExtensions {} // Analyzer short-circuits if this doesn't exist, since that's where the extension lives
+ }
+ """);
+}
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/HawkSourceAnalyzerTests.cs b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/HawkSourceAnalyzerTests.cs
new file mode 100644
index 0000000000..472e1a2402
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/HawkSourceAnalyzerTests.cs
@@ -0,0 +1,146 @@
+namespace BizHawk.Tests.Analyzers;
+
+using System.Threading.Tasks;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
+ BizHawk.Analyzers.HawkSourceAnalyzer,
+ Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
+
+[TestClass]
+public sealed class HawkSourceAnalyzerTests
+{
+ [TestMethod]
+ public Task CheckMisuseOfAnonymousClasses()
+ => Verify.VerifyAnalyzerAsync("""
+ using System.Linq;
+ public static class Cases {
+ private static int Z()
+ => new[] { 0x80, 0x20, 0x40 }
+ .Select(static n => (N: n, StrLen: n.ToString().Length))
+ .Where(static pair => pair.StrLen < 3)
+ .Sum(static pair => pair.N - pair.StrLen);
+ private static int A()
+ => new[] { 0x80, 0x20, 0x40 }
+ .Select(static n => {|BHI1002:new { N = n, StrLen = n.ToString().Length }|})
+ .Where(static pair => pair.StrLen < 3)
+ .Sum(static pair => pair.N - pair.StrLen);
+ }
+ """);
+
+ [TestMethod]
+ public Task CheckMisuseOfAnonymousDelegates()
+ => Verify.VerifyAnalyzerAsync("""
+ using System.Linq;
+ public static class Cases {
+ private static int Z()
+ => new[] { 0x80, 0x20, 0x40 }
+ .Where(static n => n.ToString().Length < 3)
+ .Sum();
+ private static int A()
+ => new[] { 0x80, 0x20, 0x40 }
+ .Where({|BHI1001:static delegate(int n) { return n.ToString().Length < 3; }|})
+ .Sum();
+ }
+ """);
+
+ [TestMethod]
+ public Task CheckMisuseOfDefaultSwitchBranches()
+ => Verify.VerifyAnalyzerAsync("""
+ using System;
+ public static class Cases {
+ private static int Y(string s) {
+ switch (s) {
+ case "zero":
+ return 0;
+ case "one":
+ return 1;
+ default:
+ throw new InvalidOperationException();
+ }
+ }
+ private static int Z(string s)
+ => s switch {
+ "zero" => 0,
+ "one" => 1,
+ _ => throw new InvalidOperationException()
+ };
+ private static int A(string s) {
+ switch (s) {
+ case "zero":
+ return 0;
+ case "one":
+ return 1;
+ default:
+ throw new NotImplementedException(); //TODO checking switch blocks was never implemented in the Analyzer
+ }
+ }
+ private static int B(string s)
+ => s switch {
+ "zero" => 0,
+ "one" => 1,
+ _ => {|BHI1005:throw new NotImplementedException()|}
+ };
+ private static int C(string s)
+ => s switch {
+ "zero" => 0,
+ "one" => 1,
+ _ => {|BHI1005:throw (new NotImplementedException())|} // same code but different message, since only the simplest of expected syntaxes is checked for
+ };
+ }
+ """);
+
+ [TestMethod]
+ public Task CheckMisuseOfDiscards()
+ => Verify.VerifyAnalyzerAsync("""
+ public static class Cases {
+ private static void Z() {
+ _ = string.Empty;
+ }
+ private static void A() {
+ var s = string.Empty;
+ {|BHI1006:_ = s|};
+ }
+ }
+ """);
+
+ [TestMethod]
+ public Task CheckMisuseOfInterpolatedString()
+ => Verify.VerifyAnalyzerAsync("""
+ public static class Cases {
+ private static readonly int Z = $@"{0x100}".Length;
+ private static readonly int A = {|BHI1004:@$"{0x100}"|}.Length;
+ }
+ """);
+
+ [TestMethod]
+ public Task CheckMisuseOfListSyntaxes()
+ => Verify.VerifyAnalyzerAsync("""
+ public static class Cases {
+ private static readonly int[] Y = [ 0x80, 0x20, 0x40 ];
+ private static readonly bool Z = Y is [ _, > 20, .. ];
+ private static readonly int[] A = {|BHI1110:[0x80, 0x20, 0x40 ]|};
+ private static readonly bool B = A is {|BHI1110:[ _, > 20, ..]|};
+ private static readonly bool C = A is {|BHI1110:[_, > 20, ..]|};
+ }
+ """);
+
+ [TestMethod]
+ public Task CheckMisuseOfQuerySyntax()
+ => Verify.VerifyAnalyzerAsync("""
+ using System.Linq;
+ using L = System.Collections.Generic.IEnumerable<(int N, int StrLen)>;
+ public static class Cases {
+ private static L Z()
+ => new[] { 0x80, 0x20, 0x40 }
+ .Select(static n => (N: n, StrLen: n.ToString().Length))
+ .OrderBy(static pair => pair.StrLen);
+ private static L A()
+ => {|BHI1003:from n in new[] { 0x80, 0x20, 0x40 }
+ let pair = (N: n, StrLen: n.ToString().Length)
+ orderby pair.StrLen
+ select pair|};
+ }
+ """);
+}
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/OrderBySelfAnalyzerTests.cs b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/OrderBySelfAnalyzerTests.cs
new file mode 100644
index 0000000000..3d03eca028
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/OrderBySelfAnalyzerTests.cs
@@ -0,0 +1,42 @@
+namespace BizHawk.Tests.Analyzers;
+
+using System.Threading.Tasks;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
+ BizHawk.Analyzers.OrderBySelfAnalyzer,
+ Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
+
+[TestClass]
+public sealed class OrderBySelfAnalyzerTests
+{
+ [TestMethod]
+ public Task CheckMisuseOfOrderBy()
+ => Verify.VerifyAnalyzerAsync("""
+ using System.Linq;
+ using L = System.Collections.Generic.IEnumerable;
+ public static class Cases {
+ private static readonly int[] Numbers = [ 0x80, 0x20, 0x40 ];
+ private static L Y()
+ => Numbers.OrderBy(static delegate(int n) { return n.ToString().Length; });
+ private static L Z()
+ => Numbers.OrderByDescending(static n => n.ToString().Length);
+ private static L A()
+ => Numbers.OrderBy({|BHI3101:static delegate(int n) { return n; }|});
+ private static L B()
+ => Numbers.OrderByDescending({|BHI3101:static delegate(int n) { return n; }|});
+ private static L C()
+ => Numbers.OrderBy({|BHI3101:static (n) => n|});
+ private static L D()
+ => Numbers.OrderByDescending({|BHI3101:static (n) => n|});
+ private static L E()
+ => Numbers.OrderBy({|BHI3101:static n => n|});
+ private static L F()
+ => Numbers.OrderByDescending({|BHI3101:static n => n|});
+ }
+ namespace BizHawk.Common.CollectionExtensions {
+ public static class CollectionExtensions {} // Analyzer short-circuits if this doesn't exist, since that's where our backport lives
+ }
+ """);
+}
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/TryGetValueImplicitDiscardAnalyzerTests.cs b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/TryGetValueImplicitDiscardAnalyzerTests.cs
new file mode 100644
index 0000000000..936a6ec062
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/TryGetValueImplicitDiscardAnalyzerTests.cs
@@ -0,0 +1,39 @@
+namespace BizHawk.Tests.Analyzers;
+
+using System.Threading.Tasks;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
+ BizHawk.Analyzers.TryGetValueImplicitDiscardAnalyzer,
+ Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
+
+[TestClass]
+public sealed class TryGetValueImplicitDiscardAnalyzerTests
+{
+ [TestMethod]
+ public Task CheckMisuseOfTryGetValue()
+ => Verify.VerifyAnalyzerAsync("""
+ using System.Collections.Generic;
+ public static class Cases {
+ private static Dictionary MakeDict()
+ => new();
+ private sealed class CustomDict {
+ public bool TryGetValue(K key, out V val) {
+ val = default;
+ return false;
+ }
+ }
+ private static void Z()
+ => _ = MakeDict().TryGetValue("z", out _);
+ private static void A()
+ => {|BHI1200:MakeDict().TryGetValue("a", out _)|};
+ private static void B()
+ => {|BHI1200:((IDictionary) MakeDict()).TryGetValue("b", out _)|};
+ private static void C()
+ => {|BHI1200:((IReadOnlyDictionary) MakeDict()).TryGetValue("c", out _)|};
+ private static void D()
+ => {|BHI1200:new CustomDict().TryGetValue("d", out _)|};
+ }
+ """);
+}
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/UseNameofOperatorAnalyzerTests.cs b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/UseNameofOperatorAnalyzerTests.cs
new file mode 100644
index 0000000000..db7ce9b3dc
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/UseNameofOperatorAnalyzerTests.cs
@@ -0,0 +1,26 @@
+namespace BizHawk.Tests.Analyzers;
+
+using System.Threading.Tasks;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
+ BizHawk.Analyzers.UseNameofOperatorAnalyzer,
+ Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
+
+[TestClass]
+public sealed class UseNameofOperatorAnalyzerTests
+{
+ [TestMethod]
+ public Task CheckMisuseOfNameofOperator()
+ => Verify.VerifyAnalyzerAsync("""
+ public static class Cases {
+ private static readonly int Z = typeof(Cases).FullName.Length;
+ private static readonly int A = {|BHI1102:typeof(Cases).Name|}.Length;
+ private static readonly int B = {|BHI1103:typeof(Cases).ToString|}().Length; // the diagnostic is added to the method group part (MemberAccessExpressionSyntax) and not the invoke part; won't matter in practice
+ private static readonly int C = $"{{|BHI1103:typeof(Cases)|}}".Length;
+ private static readonly int D = (">" + {|BHI1103:typeof(Cases)|}).Length;
+ private static readonly int E = ({|BHI1103:typeof(Cases)|} + "<").Length;
+ }
+ """);
+}
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/UseTypeofOperatorAnalyzerTests.cs b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/UseTypeofOperatorAnalyzerTests.cs
new file mode 100644
index 0000000000..36325cc620
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.Analyzer/UseTypeofOperatorAnalyzerTests.cs
@@ -0,0 +1,32 @@
+namespace BizHawk.Tests.Analyzers;
+
+using System.Threading.Tasks;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
+ BizHawk.Analyzers.UseTypeofOperatorAnalyzer,
+ Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
+
+[TestClass]
+public sealed class UseTypeofOperatorAnalyzerTests
+{
+ [TestMethod]
+ public Task CheckMisuseOfTypeofOperator()
+ => Verify.VerifyAnalyzerAsync("""
+ public class Parent {
+ private string Z()
+ => 3.GetType().FullName;
+ private string A()
+ => {|BHI1101:this.GetType()|}.FullName;
+ }
+ public sealed class Child: Parent {
+ private string B()
+ => {|BHI1100:this.GetType()|}.FullName;
+ }
+ public readonly struct Struct {
+ private string C()
+ => {|BHI1100:this.GetType()|}.FullName;
+ }
+ """);
+}
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.AnalyzersTests.csproj b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.AnalyzersTests.csproj
new file mode 100644
index 0000000000..91ab46b7bb
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/BizHawk.AnalyzersTests.csproj
@@ -0,0 +1,21 @@
+
+
+ net8.0
+
+
+
+ true
+ $(NoWarn);IDE0065;SA1200
+ Exe
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/Properties/AssemblyInfo.cs b/ExternalProjects/BizHawk.AnalyzersTests/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..ae411c7a1b
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/run_tests_debug.sh b/ExternalProjects/BizHawk.AnalyzersTests/run_tests_debug.sh
new file mode 100755
index 0000000000..6bb3fec83e
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/run_tests_debug.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+cd "$(dirname "$(realpath "$0")")" && ./.run_tests_with_configuration.sh "Debug" "$@"
diff --git a/ExternalProjects/BizHawk.AnalyzersTests/run_tests_release.sh b/ExternalProjects/BizHawk.AnalyzersTests/run_tests_release.sh
new file mode 100755
index 0000000000..ef5dbe332c
--- /dev/null
+++ b/ExternalProjects/BizHawk.AnalyzersTests/run_tests_release.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+cd "$(dirname "$(realpath "$0")")" && ./.run_tests_with_configuration.sh "Release" "$@"