Skip to content

Commit f0960aa

Browse files
committed
Initial development on a data generation tool + analyzer for detecting misuse of ContentManagers
1 parent dd411e9 commit f0960aa

File tree

8 files changed

+819
-1
lines changed

8 files changed

+819
-1
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using System.Text.Json;
2+
using Mono.Cecil;
3+
using Mono.Cecil.Cil;
4+
using Mono.Collections.Generic;
5+
using StardewModdingAPI.ModBuildConfig.Analyzer;
6+
using StardewModdingAPI.Toolkit;
7+
8+
Console.WriteLine("Hello, World!");
9+
10+
var stardew = AssemblyDefinition.ReadAssembly("Stardew Valley.dll");
11+
12+
Dictionary<string, string> AssetMap = new Dictionary<string, string>();
13+
14+
List<string> messagesForLater = new();
15+
foreach (var module in stardew.Modules)
16+
{
17+
var types = module.GetTypes().Where(type => type.BaseType != null);
18+
19+
foreach (var type in types)
20+
{
21+
foreach (var method in type.Methods)
22+
{
23+
if (method.HasBody)
24+
{
25+
26+
ILProcessor cil = method.Body.GetILProcessor();
27+
Collection<Instruction> instructions = cil.Body.Instructions;
28+
29+
Instruction prevInstruction = Instruction.Create(OpCodes.Nop);
30+
foreach (var instruction in instructions)
31+
{
32+
if (instruction.OpCode.Code == Code.Nop)
33+
continue;
34+
if (instruction.OpCode == OpCodes.Call || instruction.OpCode == OpCodes.Callvirt || instruction.OpCode == OpCodes.Newobj)
35+
{
36+
var methodInstruction = (MethodReference)instruction.Operand;
37+
if (methodInstruction.Name == "Load") {
38+
if (methodInstruction.DeclaringType.FullName != "StardewValley.LocalizedContentManager")
39+
{
40+
Console.WriteLine("Found a Load that was not StardewValley.LocalizedContentManager " + method.DeclaringType.FullName);
41+
continue;
42+
}
43+
var genericMethod = (GenericInstanceMethod)methodInstruction;
44+
string genericParameter = genericMethod.GenericArguments[0]?.FullName ?? "<unknown>";
45+
string? assetName = null;
46+
if (prevInstruction.OpCode == OpCodes.Ldstr)
47+
{
48+
assetName = (string)prevInstruction.Operand;
49+
}
50+
else
51+
{
52+
messagesForLater.Add($"{method.FullName} is calling Load {methodInstruction} (prev instruction: {prevInstruction})");
53+
}
54+
if (assetName != null)
55+
{
56+
if (AssetMap.TryGetValue(assetName, out string existingType))
57+
{
58+
if (genericParameter != existingType)
59+
{
60+
Console.WriteLine($"Found a new use of {assetName}: Previously {existingType} now {genericParameter}");
61+
}
62+
}
63+
else
64+
{
65+
AssetMap[assetName] = genericParameter;
66+
}
67+
}
68+
}
69+
}
70+
prevInstruction = instruction;
71+
}
72+
}
73+
}
74+
}
75+
}
76+
77+
78+
Directory.CreateDirectory("outputs");
79+
var currentVersion = new SemanticVersion(stardew.Name.Version);
80+
HashSet<string> missingAssets = new();
81+
foreach (string file in Directory.GetFiles("outputs"))
82+
{
83+
if (file.EndsWith(stardew.Name.Version + ".json")) continue;
84+
var otherVersion = new SemanticVersion(Path.GetFileNameWithoutExtension(file), true);
85+
if (otherVersion.IsNewerThan(currentVersion)) continue;
86+
try
87+
{
88+
var versionInfo = JsonSerializer.Deserialize<VersionModel>(File.ReadAllText(file));
89+
foreach (var (asset, type) in versionInfo.AssetMap)
90+
{
91+
if (!AssetMap.ContainsKey(asset))
92+
{
93+
missingAssets.Add(asset);
94+
}
95+
}
96+
}
97+
catch { }
98+
}
99+
string jsonOutput = JsonSerializer.Serialize(new VersionModel(AssetMap, missingAssets), new JsonSerializerOptions
100+
{
101+
WriteIndented = true,
102+
});
103+
File.WriteAllText($"outputs/{stardew.Name.Version}.json", jsonOutput);
104+
105+
106+
107+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net6.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
<Import Project="..\..\build\common.targets" />
11+
12+
<ItemGroup>
13+
<PackageReference Include="Mono.Cecil" Version="0.11.5" />
14+
<ProjectReference Include="..\SMAPI.ModBuildConfig.Analyzer\SMAPI.ModBuildConfig.Analyzer.csproj" />
15+
<ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" />
16+
<Reference Include="Stardew Valley" HintPath="$(GamePath)\Stardew Valley.dll" Private="False" />
17+
<Reference Include="StardewValley.GameData" HintPath="$(GamePath)\StardewValley.GameData.dll" Private="False" />
18+
</ItemGroup>
19+
20+
<Target Name="CopyStardew" AfterTargets="AfterBuild">
21+
<Copy SourceFiles="$(GamePath)\Stardew Valley.dll" DestinationFolder="$(TargetDir)" />
22+
<Copy SourceFiles="$(GamePath)\StardewValley.Gamedata.dll" DestinationFolder="$(TargetDir)" />
23+
</Target>
24+
25+
</Project>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.Diagnostics;
3+
using NUnit.Framework;
4+
using SMAPI.ModBuildConfig.Analyzer.Tests.Framework;
5+
using StardewModdingAPI.ModBuildConfig.Analyzer;
6+
7+
namespace SMAPI.ModBuildConfig.Analyzer.Tests
8+
{
9+
/// <summary>Unit tests for <see cref="ContentManagerAnalyzer"/>.</summary>
10+
[TestFixture]
11+
public class ContentManagerAnalyzerTests : DiagnosticVerifier
12+
{
13+
/*********
14+
** Fields
15+
*********/
16+
/// <summary>Sample C# mod code, with a {{test-code}} placeholder for the code in the Entry method to test.</summary>
17+
const string SampleProgram = @"
18+
using System;
19+
using System.Collections.Generic;
20+
using StardewValley;
21+
using Netcode;
22+
using SObject = StardewValley.Object;
23+
24+
namespace SampleMod
25+
{
26+
class ModEntry
27+
{
28+
public void Entry()
29+
{
30+
{{test-code}}
31+
}
32+
}
33+
}
34+
";
35+
36+
/// <summary>The line number where the unit tested code is injected into <see cref="SampleProgram"/>.</summary>
37+
private const int SampleCodeLine = 14;
38+
39+
/// <summary>The column number where the unit tested code is injected into <see cref="SampleProgram"/>.</summary>
40+
private const int SampleCodeColumn = 25;
41+
42+
43+
/*********
44+
** Unit tests
45+
*********/
46+
/// <summary>Test that no diagnostics are raised for an empty code block.</summary>
47+
[TestCase]
48+
public void EmptyCode_HasNoDiagnostics()
49+
{
50+
// arrange
51+
string test = @"";
52+
53+
// assert
54+
this.VerifyCSharpDiagnostic(test);
55+
}
56+
57+
/// <summary>Test that the expected diagnostic message is raised for avoidable net field references.</summary>
58+
/// <param name="codeText">The code line to test.</param>
59+
/// <param name="column">The column within the code line where the diagnostic message should be reported.</param>
60+
/// <param name="expression">The expression which should be reported.</param>
61+
/// <param name="netType">The net type name which should be reported.</param>
62+
/// <param name="suggestedProperty">The suggested property name which should be reported.</param>
63+
[TestCase("Game1.content.Load<Dictionary<int, string>>(\"Data\\\\Fish\");", 0, "Data\\Fish", "System.Collections.Generic.Dictionary<System.Int32, System.String>", "System.Collections.Generic.Dictionary<System.String,System.String>")]
64+
public void BadType_RaisesDiagnostic(string codeText, int column, string assetName, string expectedType, string suggestedType)
65+
{
66+
// arrange
67+
string code = SampleProgram.Replace("{{test-code}}", codeText);
68+
DiagnosticResult expected = new()
69+
{
70+
Id = "AvoidContentManagerBadType",
71+
Message = $"'{assetName}' uses the {suggestedType} type, but {expectedType} is in use instead. See https://smapi.io/package/avoid-contentmanager-type for details.",
72+
Severity = DiagnosticSeverity.Error,
73+
Locations = new[] { new DiagnosticResultLocation("Test0.cs", SampleCodeLine, SampleCodeColumn + column) }
74+
};
75+
76+
// assert
77+
this.VerifyCSharpDiagnostic(code, expected);
78+
}
79+
80+
81+
/*********
82+
** Helpers
83+
*********/
84+
/// <summary>Get the analyzer being tested.</summary>
85+
protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
86+
{
87+
return new ContentManagerAnalyzer();
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)