diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/ContentManagerAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ContentManagerAnalyzerTests.cs
new file mode 100644
index 000000000..6a7e6ccf0
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ContentManagerAnalyzerTests.cs
@@ -0,0 +1,153 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using NUnit.Framework;
+using SMAPI.ModBuildConfig.Analyzer.Tests.Framework;
+using StardewModdingAPI.ModBuildConfig.Analyzer;
+
+namespace SMAPI.ModBuildConfig.Analyzer.Tests
+{
+ /// Unit tests for .
+ [TestFixture]
+ public class ContentManagerAnalyzerTests : DiagnosticVerifier
+ {
+ /*********
+ ** Fields
+ *********/
+ /// Sample C# mod code, with a {{test-code}} placeholder for the code in the Entry method to test.
+ const string SampleProgram = @"
+ using System;
+ using System.Collections.Generic;
+ using StardewValley;
+ using Netcode;
+ using SObject = StardewValley.Object;
+
+ namespace SampleMod
+ {
+ class ModEntry
+ {
+ public void Entry()
+ {
+ {{test-code}}
+ }
+ }
+ }
+ ";
+
+ const string SampleUnrelatedGoodProgram = @"
+ using System;
+ using System.Collections.Generic;
+
+ namespace Sample;
+ class Loader
+ {
+ public T Load(string arg)
+ {
+ return default(T);
+ }
+ }
+ class ModEntry
+ {
+ public void Entry()
+ {
+ var loader = new Loader();
+ var test = loader.Load>(""Data\Fish"");
+ }
+ }
+ ";
+
+ /// The line number where the unit tested code is injected into .
+ private const int SampleCodeLine = 14;
+
+ /// The column number where the unit tested code is injected into .
+ private const int SampleCodeColumn = 25;
+
+
+ /*********
+ ** Unit tests
+ *********/
+ /// Test that no diagnostics are raised for an empty code block.
+ [TestCase]
+ public void EmptyCode_HasNoDiagnostics()
+ {
+ // arrange
+ string test = @"";
+
+ // assert
+ this.VerifyCSharpDiagnostic(test);
+ }
+
+ /// Test that the expected diagnostic message is raised for avoidable net field references.
+ /// The code line to test.
+ /// The column within the code line where the diagnostic message should be reported.
+ /// The expression which should be reported.
+ /// The net type name which should be reported.
+ /// The suggested property name which should be reported.
+ [TestCase("Game1.content.Load>(\"Data\\\\Fish\");", 0, "Data\\Fish", "System.Collections.Generic.Dictionary", "System.Collections.Generic.Dictionary")]
+ public void BadType_RaisesDiagnostic(string codeText, int column, string assetName, string expectedType, string suggestedType)
+ {
+ // arrange
+ string code = SampleProgram.Replace("{{test-code}}", codeText);
+ DiagnosticResult expected = new()
+ {
+ Id = "AvoidContentManagerBadType",
+ Message = $"'{assetName}' uses the {suggestedType} type, but {expectedType} is in use instead. See https://smapi.io/package/avoid-contentmanager-type for details.",
+ Severity = DiagnosticSeverity.Error,
+ Locations = [new DiagnosticResultLocation("Test0.cs", SampleCodeLine, SampleCodeColumn + column)]
+ };
+ DiagnosticResult preferDataLoader = new()
+ {
+ Id = "PreferContentManagerDataLoader",
+ Message = $"'{assetName}' can be accessed using 'DataLoader.{assetName[5..]}(LocalizedContentManager content)' instead. See https://smapi.io/package/prefer-contentmanager-dataloader for details.",
+ Severity = DiagnosticSeverity.Info,
+ Locations = [new DiagnosticResultLocation("Test0.cs", SampleCodeLine, SampleCodeColumn + column)]
+ };
+
+ // assert
+ this.VerifyCSharpDiagnostic(code, expected, preferDataLoader);
+ }
+
+
+
+ /// Test that the expected diagnostic message is raised for avoidable net field references.
+ /// The code line to test.
+ /// The column within the code line where the diagnostic message should be reported.
+ /// The expression which should be reported.
+ /// The net type name which should be reported.
+ /// The suggested property name which should be reported.
+ [TestCase("Game1.content.Load>(\"Data\\\\Fish\");", 0, "Data\\Fish")]
+ public void PreferDataLoader_RaisesDiagnostic(string codeText, int column, string assetName)
+ {
+ // arrange
+ string code = SampleProgram.Replace("{{test-code}}", codeText);
+ DiagnosticResult preferDataLoader = new()
+ {
+ Id = "PreferContentManagerDataLoader",
+ Message = $"'{assetName}' can be accessed using 'DataLoader.{assetName[5..]}(LocalizedContentManager content)' instead. See https://smapi.io/package/prefer-contentmanager-dataloader for details.",
+ Severity = DiagnosticSeverity.Info,
+ Locations = [new DiagnosticResultLocation("Test0.cs", SampleCodeLine, SampleCodeColumn + column)]
+ };
+
+ // assert
+ this.VerifyCSharpDiagnostic(code, preferDataLoader);
+ }
+
+ [TestCase("Game1.content.Load>(\"Data\\\\Custom_Asset\");", true)]
+ [TestCase(SampleUnrelatedGoodProgram, false)]
+
+ public void ValidCode_HasNoDiagnostics(string codeText, bool useWrapper)
+ {
+ string code = useWrapper ? SampleProgram.Replace("{{test-code}}", codeText) : codeText;
+ this.VerifyCSharpDiagnostic(code);
+ }
+
+
+ /*********
+ ** Helpers
+ *********/
+ /// Get the analyzer being tested.
+ protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
+ {
+ return new ContentManagerAnalyzer();
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/DataLoader.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/DataLoader.cs
new file mode 100644
index 000000000..646b8c7e7
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/DataLoader.cs
@@ -0,0 +1,15 @@
+// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
+// ReSharper disable UnusedMember.Global -- used dynamically for unit tests
+using System.Collections.Generic;
+
+namespace StardewValley
+{
+ /// A simplified version of Stardew Valley's StardewValley.DataLoader class for unit testing.
+ public static class DataLoader
+ {
+ public static Dictionary Fish(LocalizedContentManager content)
+ {
+ return default!;
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Game1.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Game1.cs
new file mode 100644
index 000000000..b5849eaf0
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Game1.cs
@@ -0,0 +1,7 @@
+namespace StardewValley
+{
+ public class Game1
+ {
+ public static LocalizedContentManager content = new();
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/LocalizedContentManager.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/LocalizedContentManager.cs
new file mode 100644
index 000000000..3b88a8c56
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/LocalizedContentManager.cs
@@ -0,0 +1,13 @@
+// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
+// ReSharper disable UnusedMember.Global -- used dynamically for unit tests
+namespace StardewValley
+{
+ /// A simplified version of Stardew Valley's StardewValley.LocalizedContentManager class for unit testing.
+ public class LocalizedContentManager
+ {
+ public T Load(string assetName)
+ {
+ return default!;
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer/ContentManagerAnalyzer.cs b/src/SMAPI.ModBuildConfig.Analyzer/ContentManagerAnalyzer.cs
new file mode 100644
index 000000000..3330af31e
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer/ContentManagerAnalyzer.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using System.Reflection;
+using System.Linq;
+
+namespace StardewModdingAPI.ModBuildConfig.Analyzer
+{
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public class ContentManagerAnalyzer : DiagnosticAnalyzer
+ {
+ public override ImmutableArray SupportedDiagnostics { get; }
+
+
+ /// The diagnostic info for an avoidable runtime casting error.
+ private readonly DiagnosticDescriptor AvoidBadTypeRule = new(
+ id: "AvoidContentManagerBadType",
+ title: "Avoid incorrectly typing ContentManager Loads",
+ messageFormat: "'{0}' uses the {1} type, but {2} is in use instead. See https://smapi.io/package/avoid-contentmanager-type for details.",
+ category: "SMAPI.CommonErrors",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ helpLinkUri: "https://smapi.io/package/avoid-contentmanager-type"
+ );
+ /// The diagnostic info for best practices using DataLoader
+ private readonly DiagnosticDescriptor PreferDataLoader = new(
+ id: "PreferContentManagerDataLoader",
+ title: "Prefer using DataLoader to ContentManager Loads",
+ messageFormat: "'{0}' can be accessed using 'DataLoader.{1}(LocalizedContentManager content)' instead. See https://smapi.io/package/prefer-contentmanager-dataloader for details.",
+ category: "SMAPI.CommonErrors",
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: true,
+ helpLinkUri: "https://smapi.io/package/prefer-contentmanager-dataloader"
+ );
+
+ public ContentManagerAnalyzer()
+ {
+ this.SupportedDiagnostics = ImmutableArray.CreateRange(new[] { this.AvoidBadTypeRule, this.PreferDataLoader });
+ }
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);
+ context.EnableConcurrentExecution();
+
+ context.RegisterSyntaxNodeAction(
+ this.AnalyzeContentManagerLoads,
+ SyntaxKind.InvocationExpression
+ );
+ }
+
+ private void AnalyzeContentManagerLoads(SyntaxNodeAnalysisContext context)
+ {
+ var invocation = (InvocationExpressionSyntax)context.Node;
+ var memberAccess = invocation.Expression as MemberAccessExpressionSyntax;
+ if (memberAccess == null || memberAccess.Name.Identifier.ValueText != "Load")
+ return;
+ string? loadNamespace = context.SemanticModel.GetSymbolInfo(memberAccess).Symbol?.ContainingNamespace.Name;
+ if (!(loadNamespace == "StardewValley" || loadNamespace == "StardewModdingAPI"))
+ return;
+ // "Data\\Fish" -> Data\Fish
+ string assetName = invocation.ArgumentList.Arguments[0].ToString().Replace("\"", "").Replace("\\\\", "\\");
+
+ if (!assetName.StartsWith("Data", StringComparison.InvariantCultureIgnoreCase)) return;
+ string dataAsset = assetName.Substring(5);
+
+ var dataLoader = context.Compilation.GetTypeByMetadataName("StardewValley.DataLoader");
+ var dataMatch = dataLoader.GetMembers().FirstOrDefault(m => m.Name == dataAsset);
+ if (dataMatch == null) return;
+ if (dataMatch is IMethodSymbol method)
+ {
+ var genericArgument = context.SemanticModel.GetTypeInfo((memberAccess.Name as GenericNameSyntax).TypeArgumentList.Arguments[0]).Type;
+ // Can't use the proper way of using SymbolEquityComparer due to System.Collections overlapping with CoreLib.
+ if (method.ReturnType.ToString() != genericArgument.ToString())
+ {
+ context.ReportDiagnostic(Diagnostic.Create(this.AvoidBadTypeRule, context.Node.GetLocation(), assetName, method.ReturnType.ToString(), genericArgument.ToString()));
+ }
+ context.ReportDiagnostic(Diagnostic.Create(this.PreferDataLoader, context.Node.GetLocation(), assetName, dataAsset));
+ }
+ }
+ }
+}