Merge branch 'feature/mod-code-analysis' into develop

This commit is contained in:
Jesse Plamondon-Willard 2018-04-10 18:27:09 -04:00
commit 77b4d1e9a2
30 changed files with 1523 additions and 41 deletions

View File

@ -0,0 +1,20 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!--
This build task is run from the ModBuildConfig project after it's been compiled, and copies the
package files to the bin\Pathoschild.Stardew.ModBuildConfig folder.
-->
<Target Name="AfterBuild">
<PropertyGroup>
<PackagePath>$(SolutionDir)\..\bin\Pathoschild.Stardew.ModBuildConfig</PackagePath>
</PropertyGroup>
<RemoveDir Directories="$(PackagePath)" />
<Copy SourceFiles="$(ProjectDir)/package.nuspec" DestinationFolder="$(PackagePath)" />
<Copy SourceFiles="$(ProjectDir)/build/smapi.targets" DestinationFiles="$(PackagePath)/build/Pathoschild.Stardew.ModBuildConfig.targets" />
<Copy SourceFiles="$(TargetDir)/StardewModdingAPI.ModBuildConfig.dll" DestinationFiles="$(PackagePath)/build/StardewModdingAPI.ModBuildConfig.dll" />
<Copy SourceFiles="$(SolutionDir)/SMAPI.ModBuildConfig.Analyzer/bin/netstandard1.3/StardewModdingAPI.ModBuildConfig.Analyzer.dll" DestinationFiles="$(PackagePath)/analyzers/dotnet/cs/StardewModdingAPI.ModBuildConfig.Analyzer.dll" />
<Copy SourceFiles="$(SolutionDir)/SMAPI.ModBuildConfig.Analyzer/tools/install.ps1" DestinationFiles="$(PackagePath)/tools/install.ps1" />
<Copy SourceFiles="$(SolutionDir)/SMAPI.ModBuildConfig.Analyzer/tools/uninstall.ps1" DestinationFiles="$(PackagePath)/tools/uninstall.ps1" />
</Target>
</Project>

View File

@ -3,15 +3,16 @@ for SMAPI mods.
The package...
* lets your code compile on any computer (Linux/Mac/Windows) without needing to change the assembly
references or game path.
* packages the mod into the game's `Mods` folder when you rebuild the code (configurable).
* configures Visual Studio so you can debug into the mod code when the game is running (_Windows
only_).
* detects your game install path;
* adds the assembly references you need (with automatic support for Linux/Mac/Windows);
* packages the mod into your `Mods` folder when you rebuild the code (configurable);
* configures Visual Studio to enable debugging into the code when the game is running (_Windows only_);
* adds C# analyzers to warn for Stardew Valley-specific issues.
## Contents
* [Install](#install)
* [Configure](#configure)
* [Code analysis warnings](#code-analysis-warnings)
* [Troubleshoot](#troubleshoot)
* [Release notes](#release-notes)
@ -121,7 +122,7 @@ The configuration will check your custom path first, then fall back to the defau
still compile on a different computer).
### Unit test projects
**(upcoming in 2.0.3)**
**(upcoming in 2.1)**
You can use the package in unit test projects too. Its optional unit test mode...
@ -134,15 +135,81 @@ To enable it, add this above the first `</PropertyGroup>` in your `.csproj`:
<ModUnitTests>True</ModUnitTests>
```
## Code warnings
### Overview
The NuGet package adds code warnings in Visual Studio specific to Stardew Valley. For example:
![](screenshots/code-analyzer-example.png)
You can hide the warnings...
* [for specific code](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/preprocessor-pragma-warning);
* for a method using this attribute:
```cs
[System.Diagnostics.CodeAnalysis.SuppressMessage("SMAPI.CommonErrors", "SMAPI001")] // implicit net field conversion
```
* for an entire project:
1. Expand the _References_ node for the project in Visual Studio.
2. Right-click on _Analyzers_ and choose _Open Active Rule Set_.
4. Expand _StardewModdingAPI.ModBuildConfig.Analyzer_ and uncheck the warnings you want to hide.
See below for help with each specific warning.
### SMAPI001
**Implicit net field conversion:**
> This implicitly converts '{{expression}}' from {{net type}} to {{other type}}, but
> {{net type}} has unintuitive implicit conversion rules. Consider comparing against the actual
> value instead to avoid bugs.
Stardew Valley uses net types (like `NetBool` and `NetInt`) to handle multiplayer sync. These types
can implicitly convert to their equivalent normal values (like `bool x = new NetBool()`), but their
conversion rules are unintuitive and error-prone. For example,
`item?.category == null && item?.category != null` can both be true at once, and
`building.indoors != null` will be true for a null value in some cases.
Suggested fix:
* Some net fields have an equivalent non-net property like `monster.Health` (`int`) instead of
`monster.health` (`NetInt`). The package will add a separate [SMAPI002](#smapi002) warning for
these. Use the suggested property instead.
* For a reference type (i.e. one that can contain `null`), you can use the `.Value` property:
```c#
if (building.indoors.Value == null)
```
Or convert the value before comparison:
```c#
GameLocation indoors = building.indoors;
if(indoors == null)
// ...
```
* For a value type (i.e. one that can't contain `null`), check if the object is null (if applicable)
and compare with `.Value`:
```cs
if (item != null && item.category.Value == 0)
```
### SMAPI002
**Avoid net fields when possible:**
> '{{expression}}' is a {{net type}} field; consider using the {{property name}} property instead.
Your code accesses a net field, which has some unusual behavior (see [SMAPI001](#smapi001)). This
field has an equivalent non-net property that avoids those issues.
Suggested fix: access the suggested property name instead.
### SMAPI003
**Avoid obsolete fields:**
> The '{{old field}}' field is obsolete and should be replaced with '{{new field}}'.
Your code accesses a field which is obsolete or no longer works. Use the suggested field instead.
## Troubleshoot
### "Failed to find the game install path"
That error means the package couldn't find your game. You can specify the game path yourself; see
_[Game path](#game-path)_ above.
## Release notes
### 2.0.3 alpha
### 2.1 alpha
* Added support for Stardew Valley 1.3.
* Added support for unit test projects.
* Added C# analyzers to warn about implicit conversions of Netcode fields in Stardew Valley 1.3.
### 2.0.2
* Fixed compatibility issue on Linux.

View File

@ -8,6 +8,7 @@
* Fixed SMAPI update checks not showing newer beta versions when using a beta version.
* For modders:
* Added code analysis to mod build config package to flag common issues as warnings.
* Dropped some deprecated APIs.
* Fixed assets loaded by temporary content managers not being editable.
* Fixed issue where assets didn't reload correctly when the player switches language.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -20,6 +20,7 @@ mods, this section isn't relevant to you; see the main README to use or create m
* [Development](#development-2)
* [Local development](#local-development)
* [Deploying to Amazon Beanstalk](#deploying-to-amazon-beanstalk)
* [Mod build config package](#mod-build-config-package)
# SMAPI
## Development
@ -222,3 +223,31 @@ property name | description
`LogParser:SectionUrl` | The root URL of the log page, like `https://log.smapi.io/`.
`ModUpdateCheck:GitHubPassword` | The password with which to authenticate to GitHub when fetching release info.
`ModUpdateCheck:GitHubUsername` | The username with which to authenticate to GitHub when fetching release info.
## Mod build config package
### Overview
The mod build config package is a NuGet package that mods reference to automatically set up
references, configure the build, and add analyzers specific to Stardew Valley mods.
This involves three projects:
project | purpose
------------------------------------------------- | ----------------
`StardewModdingAPI.ModBuildConfig` | Configures the build (references, deploying the mod files, setting up debugging, etc).
`StardewModdingAPI.ModBuildConfig.Analyzer` | Adds C# analyzers which show code warnings in Visual Studio.
`StardewModdingAPI.ModBuildConfig.Analyzer.Tests` | Unit tests for the C# analyzers.
When the projects are built, the relevant files are copied into `bin/Pathoschild.Stardew.ModBuildConfig`.
### Preparing a build
To prepare a build of the NuGet package:
1. Install the [NuGet CLI](https://docs.microsoft.com/en-us/nuget/install-nuget-client-tools#nugetexe-cli).
1. Change the version and release notes in `package.nuspec`.
2. Rebuild the solution in _Release_ mode.
3. Open a terminal in the `bin/Pathoschild.Stardew.ModBuildConfig` package and run this command:
```bash
nuget.exe pack
```
That will create a `Pathoschild.Stardew.ModBuildConfig-<version>.nupkg` file in the same directory
which can be uploaded to NuGet or referenced directly.

View File

@ -0,0 +1,88 @@
// <generated />
using Microsoft.CodeAnalysis;
using System;
namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
{
/// <summary>
/// Location where the diagnostic appears, as determined by path, line number, and column number.
/// </summary>
public struct DiagnosticResultLocation
{
public DiagnosticResultLocation(string path, int line, int column)
{
if (line < -1)
{
throw new ArgumentOutOfRangeException(nameof(line), "line must be >= -1");
}
if (column < -1)
{
throw new ArgumentOutOfRangeException(nameof(column), "column must be >= -1");
}
this.Path = path;
this.Line = line;
this.Column = column;
}
public string Path { get; }
public int Line { get; }
public int Column { get; }
}
/// <summary>
/// Struct that stores information about a Diagnostic appearing in a source
/// </summary>
public struct DiagnosticResult
{
private DiagnosticResultLocation[] locations;
public DiagnosticResultLocation[] Locations
{
get
{
if (this.locations == null)
{
this.locations = new DiagnosticResultLocation[] { };
}
return this.locations;
}
set
{
this.locations = value;
}
}
public DiagnosticSeverity Severity { get; set; }
public string Id { get; set; }
public string Message { get; set; }
public string Path
{
get
{
return this.Locations.Length > 0 ? this.Locations[0].Path : "";
}
}
public int Line
{
get
{
return this.Locations.Length > 0 ? this.Locations[0].Line : -1;
}
}
public int Column
{
get
{
return this.Locations.Length > 0 ? this.Locations[0].Column : -1;
}
}
}
}

View File

@ -0,0 +1,173 @@
// <generated />
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
{
/// <summary>
/// Class for turning strings into documents and getting the diagnostics on them
/// All methods are static
/// </summary>
public abstract partial class DiagnosticVerifier
{
private static readonly MetadataReference CorlibReference = MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
private static readonly MetadataReference SystemCoreReference = MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location);
private static readonly MetadataReference CSharpSymbolsReference = MetadataReference.CreateFromFile(typeof(CSharpCompilation).Assembly.Location);
private static readonly MetadataReference CodeAnalysisReference = MetadataReference.CreateFromFile(typeof(Compilation).Assembly.Location);
private static readonly MetadataReference SelfReference = MetadataReference.CreateFromFile(typeof(DiagnosticVerifier).Assembly.Location);
internal static string DefaultFilePathPrefix = "Test";
internal static string CSharpDefaultFileExt = "cs";
internal static string VisualBasicDefaultExt = "vb";
internal static string TestProjectName = "TestProject";
#region Get Diagnostics
/// <summary>
/// Given classes in the form of strings, their language, and an IDiagnosticAnalyzer to apply to it, return the diagnostics found in the string after converting it to a document.
/// </summary>
/// <param name="sources">Classes in the form of strings</param>
/// <param name="language">The language the source classes are in</param>
/// <param name="analyzer">The analyzer to be run on the sources</param>
/// <returns>An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location</returns>
private static Diagnostic[] GetSortedDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer)
{
return GetSortedDiagnosticsFromDocuments(analyzer, GetDocuments(sources, language));
}
/// <summary>
/// Given an analyzer and a document to apply it to, run the analyzer and gather an array of diagnostics found in it.
/// The returned diagnostics are then ordered by location in the source document.
/// </summary>
/// <param name="analyzer">The analyzer to run on the documents</param>
/// <param name="documents">The Documents that the analyzer will be run on</param>
/// <returns>An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location</returns>
protected static Diagnostic[] GetSortedDiagnosticsFromDocuments(DiagnosticAnalyzer analyzer, Document[] documents)
{
var projects = new HashSet<Project>();
foreach (var document in documents)
{
projects.Add(document.Project);
}
var diagnostics = new List<Diagnostic>();
foreach (var project in projects)
{
var compilationWithAnalyzers = project.GetCompilationAsync().Result.WithAnalyzers(ImmutableArray.Create(analyzer));
var diags = compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync().Result;
foreach (var diag in diags)
{
if (diag.Location == Location.None || diag.Location.IsInMetadata)
{
diagnostics.Add(diag);
}
else
{
for (int i = 0; i < documents.Length; i++)
{
var document = documents[i];
var tree = document.GetSyntaxTreeAsync().Result;
if (tree == diag.Location.SourceTree)
{
diagnostics.Add(diag);
}
}
}
}
}
var results = SortDiagnostics(diagnostics);
diagnostics.Clear();
return results;
}
/// <summary>
/// Sort diagnostics by location in source document
/// </summary>
/// <param name="diagnostics">The list of Diagnostics to be sorted</param>
/// <returns>An IEnumerable containing the Diagnostics in order of Location</returns>
private static Diagnostic[] SortDiagnostics(IEnumerable<Diagnostic> diagnostics)
{
return diagnostics.OrderBy(d => d.Location.SourceSpan.Start).ToArray();
}
#endregion
#region Set up compilation and documents
/// <summary>
/// Given an array of strings as sources and a language, turn them into a project and return the documents and spans of it.
/// </summary>
/// <param name="sources">Classes in the form of strings</param>
/// <param name="language">The language the source code is in</param>
/// <returns>A Tuple containing the Documents produced from the sources and their TextSpans if relevant</returns>
private static Document[] GetDocuments(string[] sources, string language)
{
if (language != LanguageNames.CSharp && language != LanguageNames.VisualBasic)
{
throw new ArgumentException("Unsupported Language");
}
var project = CreateProject(sources, language);
var documents = project.Documents.ToArray();
if (sources.Length != documents.Length)
{
throw new InvalidOperationException("Amount of sources did not match amount of Documents created");
}
return documents;
}
/// <summary>
/// Create a Document from a string through creating a project that contains it.
/// </summary>
/// <param name="source">Classes in the form of a string</param>
/// <param name="language">The language the source code is in</param>
/// <returns>A Document created from the source string</returns>
protected static Document CreateDocument(string source, string language = LanguageNames.CSharp)
{
return CreateProject(new[] { source }, language).Documents.First();
}
/// <summary>
/// Create a project using the inputted strings as sources.
/// </summary>
/// <param name="sources">Classes in the form of strings</param>
/// <param name="language">The language the source code is in</param>
/// <returns>A Project created out of the Documents created from the source strings</returns>
private static Project CreateProject(string[] sources, string language = LanguageNames.CSharp)
{
string fileNamePrefix = DefaultFilePathPrefix;
string fileExt = language == LanguageNames.CSharp ? CSharpDefaultFileExt : VisualBasicDefaultExt;
var projectId = ProjectId.CreateNewId(debugName: TestProjectName);
var solution = new AdhocWorkspace()
.CurrentSolution
.AddProject(projectId, TestProjectName, TestProjectName, language)
.AddMetadataReference(projectId, DiagnosticVerifier.SelfReference)
.AddMetadataReference(projectId, CorlibReference)
.AddMetadataReference(projectId, SystemCoreReference)
.AddMetadataReference(projectId, CSharpSymbolsReference)
.AddMetadataReference(projectId, CodeAnalysisReference);
int count = 0;
foreach (var source in sources)
{
var newFileName = fileNamePrefix + count + "." + fileExt;
var documentId = DocumentId.CreateNewId(projectId, debugName: newFileName);
solution = solution.AddDocument(documentId, newFileName, SourceText.From(source));
count++;
}
return solution.GetProject(projectId);
}
#endregion
}
}

View File

@ -0,0 +1,248 @@
// <generated />
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using NUnit.Framework;
namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
{
/// <summary>
/// Superclass of all Unit Tests for DiagnosticAnalyzers
/// </summary>
public abstract partial class DiagnosticVerifier
{
#region To be implemented by Test classes
/// <summary>
/// Get the CSharp analyzer being tested - to be implemented in non-abstract class
/// </summary>
protected virtual DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
{
return null;
}
/// <summary>
/// Get the Visual Basic analyzer being tested (C#) - to be implemented in non-abstract class
/// </summary>
protected virtual DiagnosticAnalyzer GetBasicDiagnosticAnalyzer()
{
return null;
}
#endregion
#region Verifier wrappers
/// <summary>
/// Called to test a C# DiagnosticAnalyzer when applied on the single inputted string as a source
/// Note: input a DiagnosticResult for each Diagnostic expected
/// </summary>
/// <param name="source">A class in the form of a string to run the analyzer on</param>
/// <param name="expected"> DiagnosticResults that should appear after the analyzer is run on the source</param>
protected void VerifyCSharpDiagnostic(string source, params DiagnosticResult[] expected)
{
VerifyDiagnostics(new[] { source }, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected);
}
/// <summary>
/// Called to test a C# DiagnosticAnalyzer when applied on the inputted strings as a source
/// Note: input a DiagnosticResult for each Diagnostic expected
/// </summary>
/// <param name="sources">An array of strings to create source documents from to run the analyzers on</param>
/// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the sources</param>
protected void VerifyCSharpDiagnostic(string[] sources, params DiagnosticResult[] expected)
{
VerifyDiagnostics(sources, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected);
}
/// <summary>
/// General method that gets a collection of actual diagnostics found in the source after the analyzer is run,
/// then verifies each of them.
/// </summary>
/// <param name="sources">An array of strings to create source documents from to run the analyzers on</param>
/// <param name="language">The language of the classes represented by the source strings</param>
/// <param name="analyzer">The analyzer to be run on the source code</param>
/// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the sources</param>
private void VerifyDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expected)
{
var diagnostics = GetSortedDiagnostics(sources, language, analyzer);
VerifyDiagnosticResults(diagnostics, analyzer, expected);
}
#endregion
#region Actual comparisons and verifications
/// <summary>
/// Checks each of the actual Diagnostics found and compares them with the corresponding DiagnosticResult in the array of expected results.
/// Diagnostics are considered equal only if the DiagnosticResultLocation, Id, Severity, and Message of the DiagnosticResult match the actual diagnostic.
/// </summary>
/// <param name="actualResults">The Diagnostics found by the compiler after running the analyzer on the source code</param>
/// <param name="analyzer">The analyzer that was being run on the sources</param>
/// <param name="expectedResults">Diagnostic Results that should have appeared in the code</param>
private static void VerifyDiagnosticResults(IEnumerable<Diagnostic> actualResults, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expectedResults)
{
int expectedCount = expectedResults.Count();
int actualCount = actualResults.Count();
if (expectedCount != actualCount)
{
string diagnosticsOutput = actualResults.Any() ? FormatDiagnostics(analyzer, actualResults.ToArray()) : " NONE.";
Assert.IsTrue(false,
string.Format("Mismatch between number of diagnostics returned, expected \"{0}\" actual \"{1}\"\r\n\r\nDiagnostics:\r\n{2}\r\n", expectedCount, actualCount, diagnosticsOutput));
}
for (int i = 0; i < expectedResults.Length; i++)
{
var actual = actualResults.ElementAt(i);
var expected = expectedResults[i];
if (expected.Line == -1 && expected.Column == -1)
{
if (actual.Location != Location.None)
{
Assert.IsTrue(false,
string.Format("Expected:\nA project diagnostic with No location\nActual:\n{0}",
FormatDiagnostics(analyzer, actual)));
}
}
else
{
VerifyDiagnosticLocation(analyzer, actual, actual.Location, expected.Locations.First());
var additionalLocations = actual.AdditionalLocations.ToArray();
if (additionalLocations.Length != expected.Locations.Length - 1)
{
Assert.IsTrue(false,
string.Format("Expected {0} additional locations but got {1} for Diagnostic:\r\n {2}\r\n",
expected.Locations.Length - 1, additionalLocations.Length,
FormatDiagnostics(analyzer, actual)));
}
for (int j = 0; j < additionalLocations.Length; ++j)
{
VerifyDiagnosticLocation(analyzer, actual, additionalLocations[j], expected.Locations[j + 1]);
}
}
if (actual.Id != expected.Id)
{
Assert.IsTrue(false,
string.Format("Expected diagnostic id to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Id, actual.Id, FormatDiagnostics(analyzer, actual)));
}
if (actual.Severity != expected.Severity)
{
Assert.IsTrue(false,
string.Format("Expected diagnostic severity to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Severity, actual.Severity, FormatDiagnostics(analyzer, actual)));
}
if (actual.GetMessage() != expected.Message)
{
Assert.IsTrue(false,
string.Format("Expected diagnostic message to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Message, actual.GetMessage(), FormatDiagnostics(analyzer, actual)));
}
}
}
/// <summary>
/// Helper method to VerifyDiagnosticResult that checks the location of a diagnostic and compares it with the location in the expected DiagnosticResult.
/// </summary>
/// <param name="analyzer">The analyzer that was being run on the sources</param>
/// <param name="diagnostic">The diagnostic that was found in the code</param>
/// <param name="actual">The Location of the Diagnostic found in the code</param>
/// <param name="expected">The DiagnosticResultLocation that should have been found</param>
private static void VerifyDiagnosticLocation(DiagnosticAnalyzer analyzer, Diagnostic diagnostic, Location actual, DiagnosticResultLocation expected)
{
var actualSpan = actual.GetLineSpan();
Assert.IsTrue(actualSpan.Path == expected.Path || (actualSpan.Path != null && actualSpan.Path.Contains("Test0.") && expected.Path.Contains("Test.")),
string.Format("Expected diagnostic to be in file \"{0}\" was actually in file \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Path, actualSpan.Path, FormatDiagnostics(analyzer, diagnostic)));
var actualLinePosition = actualSpan.StartLinePosition;
// Only check line position if there is an actual line in the real diagnostic
if (actualLinePosition.Line > 0)
{
if (actualLinePosition.Line + 1 != expected.Line)
{
Assert.IsTrue(false,
string.Format("Expected diagnostic to be on line \"{0}\" was actually on line \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Line, actualLinePosition.Line + 1, FormatDiagnostics(analyzer, diagnostic)));
}
}
// Only check column position if there is an actual column position in the real diagnostic
if (actualLinePosition.Character > 0)
{
if (actualLinePosition.Character + 1 != expected.Column)
{
Assert.IsTrue(false,
string.Format("Expected diagnostic to start at column \"{0}\" was actually at column \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
expected.Column, actualLinePosition.Character + 1, FormatDiagnostics(analyzer, diagnostic)));
}
}
}
#endregion
#region Formatting Diagnostics
/// <summary>
/// Helper method to format a Diagnostic into an easily readable string
/// </summary>
/// <param name="analyzer">The analyzer that this verifier tests</param>
/// <param name="diagnostics">The Diagnostics to be formatted</param>
/// <returns>The Diagnostics formatted as a string</returns>
private static string FormatDiagnostics(DiagnosticAnalyzer analyzer, params Diagnostic[] diagnostics)
{
var builder = new StringBuilder();
for (int i = 0; i < diagnostics.Length; ++i)
{
builder.AppendLine("// " + diagnostics[i].ToString());
var analyzerType = analyzer.GetType();
var rules = analyzer.SupportedDiagnostics;
foreach (var rule in rules)
{
if (rule != null && rule.Id == diagnostics[i].Id)
{
var location = diagnostics[i].Location;
if (location == Location.None)
{
builder.AppendFormat("GetGlobalResult({0}.{1})", analyzerType.Name, rule.Id);
}
else
{
Assert.IsTrue(location.IsInSource,
$"Test base does not currently handle diagnostics in metadata locations. Diagnostic in metadata: {diagnostics[i]}\r\n");
string resultMethodName = diagnostics[i].Location.SourceTree.FilePath.EndsWith(".cs") ? "GetCSharpResultAt" : "GetBasicResultAt";
var linePosition = diagnostics[i].Location.GetLineSpan().StartLinePosition;
builder.AppendFormat("{0}({1}, {2}, {3}.{4})",
resultMethodName,
linePosition.Line + 1,
linePosition.Character + 1,
analyzerType.Name,
rule.Id);
}
if (i != diagnostics.Length - 1)
{
builder.Append(',');
}
builder.AppendLine();
break;
}
}
}
return builder.ToString();
}
#endregion
}
}

View File

@ -0,0 +1,16 @@
// ReSharper disable CheckNamespace -- matches Stardew Valley's code
namespace Netcode
{
/// <summary>A simplified version of Stardew Valley's <c>Netcode.NetFieldBase</c> for unit testing.</summary>
/// <typeparam name="T">The type of the synchronised value.</typeparam>
/// <typeparam name="TSelf">The type of the current instance.</typeparam>
public class NetFieldBase<T, TSelf> where TSelf : NetFieldBase<T, TSelf>
{
/// <summary>The synchronised value.</summary>
public T Value { get; set; }
/// <summary>Implicitly convert a net field to the its type.</summary>
/// <param name="field">The field to convert.</param>
public static implicit operator T(NetFieldBase<T, TSelf> field) => field.Value;
}
}

View File

@ -0,0 +1,6 @@
// ReSharper disable CheckNamespace -- matches Stardew Valley's code
namespace Netcode
{
/// <summary>A simplified version of Stardew Valley's <c>Netcode.NetInt</c> for unit testing.</summary>
public class NetInt : NetFieldBase<int, NetInt> { }
}

View File

@ -0,0 +1,6 @@
// ReSharper disable CheckNamespace -- matches Stardew Valley's code
namespace Netcode
{
/// <summary>A simplified version of Stardew Valley's <c>Netcode.NetRef</c> for unit testing.</summary>
public class NetRef<T> : NetFieldBase<T, NetRef<T>> where T : class { }
}

View File

@ -0,0 +1,11 @@
// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
using System.Collections.Generic;
namespace StardewValley
{
/// <summary>A simplified version of Stardew Valley's <c>StardewValley.Farmer</c> class for unit testing.</summary>
internal class Farmer
{
public IDictionary<string, int[]> friendships;
}
}

View File

@ -0,0 +1,24 @@
// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
using Netcode;
namespace StardewValley
{
/// <summary>A simplified version of Stardew Valley's <c>StardewValley.Item</c> class for unit testing.</summary>
public class Item
{
/// <summary>A net int field with an equivalent non-net <c>Category</c> property.</summary>
public readonly NetInt category = new NetInt { Value = 42 };
/// <summary>A generic net int field with no equivalent non-net property.</summary>
public readonly NetInt netIntField = new NetInt { Value = 42 };
/// <summary>A generic net ref field with no equivalent non-net property.</summary>
public readonly NetRef<object> netRefField = new NetRef<object>();
/// <summary>A generic net int property with no equivalent non-net property.</summary>
public NetInt netIntProperty = new NetInt { Value = 42 };
/// <summary>A generic net ref property with no equivalent non-net property.</summary>
public NetRef<object> netRefProperty { get; } = new NetRef<object>();
}
}

View File

@ -0,0 +1,12 @@
// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
using Netcode;
namespace StardewValley
{
/// <summary>A simplified version of Stardew Valley's <c>StardewValley.Object</c> class for unit testing.</summary>
public class Object : Item
{
/// <summary>A net int field with an equivalent non-net property.</summary>
public NetInt type = new NetInt { Value = 42 };
}
}

View File

@ -0,0 +1,140 @@
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
{
/// <summary>Unit tests for <see cref="NetFieldAnalyzer"/>.</summary>
[TestFixture]
public class NetFieldAnalyzerTests : DiagnosticVerifier
{
/*********
** Properties
*********/
/// <summary>Sample C# mod code, with a {{test-code}} placeholder for the code in the Entry method to test.</summary>
const string SampleProgram = @"
using System;
using StardewValley;
using Netcode;
using SObject = StardewValley.Object;
namespace SampleMod
{
class ModEntry
{
public void Entry()
{
{{test-code}}
}
}
}
";
/// <summary>The line number where the unit tested code is injected into <see cref="SampleProgram"/>.</summary>
private const int SampleCodeLine = 13;
/// <summary>The column number where the unit tested code is injected into <see cref="SampleProgram"/>.</summary>
private const int SampleCodeColumn = 25;
/*********
** Unit tests
*********/
/// <summary>Test that no diagnostics are raised for an empty code block.</summary>
[TestCase]
public void EmptyCode_HasNoDiagnostics()
{
// arrange
string test = @"";
// assert
this.VerifyCSharpDiagnostic(test);
}
/// <summary>Test that the expected diagnostic message is raised for implicit net field comparisons.</summary>
/// <param name="codeText">The code line to test.</param>
/// <param name="column">The column within the code line where the diagnostic message should be reported.</param>
/// <param name="expression">The expression which should be reported.</param>
/// <param name="fromType">The source type name which should be reported.</param>
/// <param name="toType">The target type name which should be reported.</param>
[TestCase("Item item = null; if (item.netIntField < 42);", 22, "item.netIntField", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntField <= 42);", 22, "item.netIntField", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntField > 42);", 22, "item.netIntField", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntField >= 42);", 22, "item.netIntField", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntField == 42);", 22, "item.netIntField", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntField != 42);", 22, "item.netIntField", "NetInt", "int")]
[TestCase("Item item = null; if (item?.netIntField != 42);", 22, "item?.netIntField", "NetInt", "int")]
[TestCase("Item item = null; if (item?.netIntField != null);", 22, "item?.netIntField", "NetInt", "object")]
[TestCase("Item item = null; if (item.netIntProperty < 42);", 22, "item.netIntProperty", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntProperty <= 42);", 22, "item.netIntProperty", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntProperty > 42);", 22, "item.netIntProperty", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntProperty >= 42);", 22, "item.netIntProperty", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntProperty == 42);", 22, "item.netIntProperty", "NetInt", "int")]
[TestCase("Item item = null; if (item.netIntProperty != 42);", 22, "item.netIntProperty", "NetInt", "int")]
[TestCase("Item item = null; if (item?.netIntProperty != 42);", 22, "item?.netIntProperty", "NetInt", "int")]
[TestCase("Item item = null; if (item?.netIntProperty != null);", 22, "item?.netIntProperty", "NetInt", "object")]
[TestCase("Item item = null; if (item.netRefField == null);", 22, "item.netRefField", "NetRef", "object")]
[TestCase("Item item = null; if (item.netRefField != null);", 22, "item.netRefField", "NetRef", "object")]
[TestCase("Item item = null; if (item.netRefProperty == null);", 22, "item.netRefProperty", "NetRef", "object")]
[TestCase("Item item = null; if (item.netRefProperty != null);", 22, "item.netRefProperty", "NetRef", "object")]
[TestCase("SObject obj = null; if (obj.netIntField != 42);", 24, "obj.netIntField", "NetInt", "int")] // ↓ same as above, but inherited from base class
[TestCase("SObject obj = null; if (obj.netIntProperty != 42);", 24, "obj.netIntProperty", "NetInt", "int")]
[TestCase("SObject obj = null; if (obj.netRefField == null);", 24, "obj.netRefField", "NetRef", "object")]
[TestCase("SObject obj = null; if (obj.netRefField != null);", 24, "obj.netRefField", "NetRef", "object")]
[TestCase("SObject obj = null; if (obj.netRefProperty == null);", 24, "obj.netRefProperty", "NetRef", "object")]
[TestCase("SObject obj = null; if (obj.netRefProperty != null);", 24, "obj.netRefProperty", "NetRef", "object")]
public void AvoidImplicitNetFieldComparisons_RaisesDiagnostic(string codeText, int column, string expression, string fromType, string toType)
{
// arrange
string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
DiagnosticResult expected = new DiagnosticResult
{
Id = "SMAPI001",
Message = $"This implicitly converts '{expression}' from {fromType} to {toType}, but {fromType} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/buildmsg/smapi001 for details.",
Severity = DiagnosticSeverity.Warning,
Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) }
};
// assert
this.VerifyCSharpDiagnostic(code, expected);
}
/// <summary>Test that the expected diagnostic message is raised for avoidable net field references.</summary>
/// <param name="codeText">The code line to test.</param>
/// <param name="column">The column within the code line where the diagnostic message should be reported.</param>
/// <param name="expression">The expression which should be reported.</param>
/// <param name="netType">The net type name which should be reported.</param>
/// <param name="suggestedProperty">The suggested property name which should be reported.</param>
[TestCase("Item item = null; int category = item.category;", 33, "item.category", "NetInt", "Category")]
[TestCase("Item item = null; int category = (item).category;", 33, "(item).category", "NetInt", "Category")]
[TestCase("Item item = null; int category = ((Item)item).category;", 33, "((Item)item).category", "NetInt", "Category")]
[TestCase("SObject obj = null; int category = obj.category;", 35, "obj.category", "NetInt", "Category")]
public void AvoidNetFields_RaisesDiagnostic(string codeText, int column, string expression, string netType, string suggestedProperty)
{
// arrange
string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
DiagnosticResult expected = new DiagnosticResult
{
Id = "SMAPI002",
Message = $"'{expression}' is a {netType} field; consider using the {suggestedProperty} property instead. See https://smapi.io/buildmsg/smapi002 for details.",
Severity = DiagnosticSeverity.Warning,
Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) }
};
// assert
this.VerifyCSharpDiagnostic(code, expected);
}
/*********
** Helpers
*********/
/// <summary>Get the analyzer being tested.</summary>
protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
{
return new NetFieldAnalyzer();
}
}
}

View File

@ -0,0 +1,88 @@
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
{
/// <summary>Unit tests for <see cref="ObsoleteFieldAnalyzer"/>.</summary>
[TestFixture]
public class ObsoleteFieldAnalyzerTests : DiagnosticVerifier
{
/*********
** Properties
*********/
/// <summary>Sample C# mod code, with a {{test-code}} placeholder for the code in the Entry method to test.</summary>
const string SampleProgram = @"
using System;
using StardewValley;
using Netcode;
using SObject = StardewValley.Object;
namespace SampleMod
{
class ModEntry
{
public void Entry()
{
{{test-code}}
}
}
}
";
/// <summary>The line number where the unit tested code is injected into <see cref="SampleProgram"/>.</summary>
private const int SampleCodeLine = 13;
/// <summary>The column number where the unit tested code is injected into <see cref="SampleProgram"/>.</summary>
private const int SampleCodeColumn = 25;
/*********
** Unit tests
*********/
/// <summary>Test that no diagnostics are raised for an empty code block.</summary>
[TestCase]
public void EmptyCode_HasNoDiagnostics()
{
// arrange
string test = @"";
// assert
this.VerifyCSharpDiagnostic(test);
}
/// <summary>Test that the expected diagnostic message is raised for an obsolete field reference.</summary>
/// <param name="codeText">The code line to test.</param>
/// <param name="column">The column within the code line where the diagnostic message should be reported.</param>
/// <param name="oldName">The old field name which should be reported.</param>
/// <param name="newName">The new field name which should be reported.</param>
[TestCase("var x = new Farmer().friendships;", 8, "StardewValley.Farmer.friendships", "friendshipData")]
public void AvoidObsoleteField_RaisesDiagnostic(string codeText, int column, string oldName, string newName)
{
// arrange
string code = ObsoleteFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
DiagnosticResult expected = new DiagnosticResult
{
Id = "SMAPI003",
Message = $"The '{oldName}' field is obsolete and should be replaced with '{newName}'. See https://smapi.io/buildmsg/smapi003 for details.",
Severity = DiagnosticSeverity.Warning,
Locations = new[] { new DiagnosticResultLocation("Test0.cs", ObsoleteFieldAnalyzerTests.SampleCodeLine, ObsoleteFieldAnalyzerTests.SampleCodeColumn + column) }
};
// assert
this.VerifyCSharpDiagnostic(code, expected);
}
/*********
** Helpers
*********/
/// <summary>Get the analyzer being tested.</summary>
protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
{
return new ObsoleteFieldAnalyzer();
}
}
}

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.4.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.3.0" />
<PackageReference Include="NUnit" Version="3.10.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.10.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SMAPI.ModBuildConfig.Analyzer\StardewModdingAPI.ModBuildConfig.Analyzer.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace StardewModdingAPI.ModBuildConfig.Analyzer
{
/// <summary>Detects implicit conversion from Stardew Valley's <c>Netcode</c> types. These have very unintuitive implicit conversion rules, so mod authors should always explicitly convert the type with appropriate null checks.</summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class NetFieldAnalyzer : DiagnosticAnalyzer
{
/*********
** Properties
*********/
/// <summary>The namespace for Stardew Valley's <c>Netcode</c> types.</summary>
private const string NetcodeNamespace = "Netcode";
/// <summary>Maps net fields to their equivalent non-net properties where available.</summary>
private readonly IDictionary<string, string> NetFieldWrapperProperties = new Dictionary<string, string>
{
// Character
["StardewValley.Character::currentLocationRef"] = "currentLocation",
["StardewValley.Character::facingDirection"] = "FacingDirection",
["StardewValley.Character::name"] = "Name",
["StardewValley.Character::position"] = "Position",
["StardewValley.Character::scale"] = "Scale",
["StardewValley.Character::speed"] = "Speed",
["StardewValley.Character::sprite"] = "Sprite",
// Chest
["StardewValley.Objects.Chest::tint"] = "Tint",
// Farmer
["StardewValley.Farmer::houseUpgradeLevel"] = "HouseUpgradeLevel",
["StardewValley.Farmer::isMale"] = "IsMale",
["StardewValley.Farmer::items"] = "Items",
["StardewValley.Farmer::magneticRadius"] = "MagneticRadius",
["StardewValley.Farmer::stamina"] = "Stamina",
["StardewValley.Farmer::uniqueMultiplayerID"] = "UniqueMultiplayerID",
["StardewValley.Farmer::usingTool"] = "UsingTool",
// Forest
["StardewValley.Locations.Forest::netTravelingMerchantDay"] = "travelingMerchantDay",
["StardewValley.Locations.Forest::netLog"] = "log",
// FruitTree
["StardewValley.TerrainFeatures.FruitTree::greenHouseTileTree"] = "GreenHouseTileTree",
["StardewValley.TerrainFeatures.FruitTree::greenHouseTree"] = "GreenHouseTree",
// GameLocation
["StardewValley.GameLocation::isFarm"] = "IsFarm",
["StardewValley.GameLocation::isOutdoors"] = "IsOutdoors",
["StardewValley.GameLocation::lightLevel"] = "LightLevel",
["StardewValley.GameLocation::name"] = "Name",
// Item
["StardewValley.Item::category"] = "Category",
["StardewValley.Item::netName"] = "Name",
["StardewValley.Item::parentSheetIndex"] = "ParentSheetIndex",
["StardewValley.Item::specialVariable"] = "SpecialVariable",
// Junimo
["StardewValley.Characters.Junimo::eventActor"] = "EventActor",
// LightSource
["StardewValley.LightSource::identifier"] = "Identifier",
// Monster
["StardewValley.Monsters.Monster::damageToFarmer"] = "DamageToFarmer",
["StardewValley.Monsters.Monster::experienceGained"] = "ExperienceGained",
["StardewValley.Monsters.Monster::health"] = "Health",
["StardewValley.Monsters.Monster::maxHealth"] = "MaxHealth",
["StardewValley.Monsters.Monster::netFocusedOnFarmers"] = "focusedOnFarmers",
["StardewValley.Monsters.Monster::netWildernessFarmMonster"] = "wildernessFarmMonster",
["StardewValley.Monsters.Monster::slipperiness"] = "Slipperiness",
// NPC
["StardewValley.NPC::age"] = "Age",
["StardewValley.NPC::birthday_Day"] = "Birthday_Day",
["StardewValley.NPC::birthday_Season"] = "Birthday_Season",
["StardewValley.NPC::breather"] = "Breather",
["StardewValley.NPC::defaultMap"] = "DefaultMap",
["StardewValley.NPC::gender"] = "Gender",
["StardewValley.NPC::hideShadow"] = "HideShadow",
["StardewValley.NPC::isInvisible"] = "IsInvisible",
["StardewValley.NPC::isWalkingTowardPlayer"] = "IsWalkingTowardPlayer",
["StardewValley.NPC::manners"] = "Manners",
["StardewValley.NPC::optimism"] = "Optimism",
["StardewValley.NPC::socialAnxiety"] = "SocialAnxiety",
// Object
["StardewValley.Object::canBeGrabbed"] = "CanBeGrabbed",
["StardewValley.Object::canBeSetDown"] = "CanBeSetDown",
["StardewValley.Object::edibility"] = "Edibility",
["StardewValley.Object::flipped"] = "Flipped",
["StardewValley.Object::fragility"] = "Fragility",
["StardewValley.Object::hasBeenPickedUpByFarmer"] = "HasBeenPickedUpByFarmer",
["StardewValley.Object::isHoedirt"] = "IsHoeDirt",
["StardewValley.Object::isOn"] = "IsOn",
["StardewValley.Object::isRecipe"] = "IsRecipe",
["StardewValley.Object::isSpawnedObject"] = "IsSpawnedObject",
["StardewValley.Object::minutesUntilReady"] = "MinutesUntilReady",
["StardewValley.Object::netName"] = "name",
["StardewValley.Object::price"] = "Price",
["StardewValley.Object::quality"] = "Quality",
["StardewValley.Object::scale"] = "Scale",
["StardewValley.Object::stack"] = "Stack",
["StardewValley.Object::tileLocation"] = "TileLocation",
["StardewValley.Object::type"] = "Type",
// Projectile
["StardewValley.Projectiles.Projectile::ignoreLocationCollision"] = "IgnoreLocationCollision",
// Tool
["StardewValley.Tool::currentParentTileIndex"] = "CurrentParentTileIndex",
["StardewValley.Tool::indexOfMenuItemView"] = "IndexOfMenuItemView",
["StardewValley.Tool::initialParentTileIndex"] = "InitialParentTileIndex",
["StardewValley.Tool::instantUse"] = "InstantUse",
["StardewValley.Tool::netName"] = "BaseName",
["StardewValley.Tool::stackable"] = "Stackable",
["StardewValley.Tool::upgradeLevel"] = "UpgradeLevel"
};
/// <summary>Describes the diagnostic rule covered by the analyzer.</summary>
private readonly IDictionary<string, DiagnosticDescriptor> Rules = new Dictionary<string, DiagnosticDescriptor>
{
["SMAPI001"] = new DiagnosticDescriptor(
id: "SMAPI001",
title: "Netcode types shouldn't be implicitly converted",
messageFormat: "This implicitly converts '{0}' from {1} to {2}, but {1} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/buildmsg/smapi001 for details.",
category: "SMAPI.CommonErrors",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
helpLinkUri: "https://smapi.io/buildmsg/smapi001"
),
["SMAPI002"] = new DiagnosticDescriptor(
id: "SMAPI002",
title: "Avoid Netcode types when possible",
messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/buildmsg/smapi002 for details.",
category: "SMAPI.CommonErrors",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
helpLinkUri: "https://smapi.io/buildmsg/smapi001"
)
};
/*********
** Accessors
*********/
/// <summary>The descriptors for the diagnostics that this analyzer is capable of producing.</summary>
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public NetFieldAnalyzer()
{
this.SupportedDiagnostics = ImmutableArray.CreateRange(this.Rules.Values);
}
/// <summary>Called once at session start to register actions in the analysis context.</summary>
/// <param name="context">The analysis context.</param>
public override void Initialize(AnalysisContext context)
{
// SMAPI002: avoid net fields if possible
context.RegisterSyntaxNodeAction(
this.AnalyzeAvoidableNetField,
SyntaxKind.SimpleMemberAccessExpression
);
// SMAPI001: avoid implicit net field conversion
context.RegisterSyntaxNodeAction(
this.AnalyseNetFieldConversions,
SyntaxKind.EqualsExpression,
SyntaxKind.NotEqualsExpression,
SyntaxKind.GreaterThanExpression,
SyntaxKind.GreaterThanOrEqualExpression,
SyntaxKind.LessThanExpression,
SyntaxKind.LessThanOrEqualExpression
);
}
/*********
** Private methods
*********/
/// <summary>Analyse a syntax node and add a diagnostic message if it references a net field when there's a non-net equivalent available.</summary>
/// <param name="context">The analysis context.</param>
private void AnalyzeAvoidableNetField(SyntaxNodeAnalysisContext context)
{
try
{
// check member type
MemberAccessExpressionSyntax node = (MemberAccessExpressionSyntax)context.Node;
TypeInfo memberType = context.SemanticModel.GetTypeInfo(node);
if (!this.IsNetType(memberType.Type))
return;
// get reference info
ITypeSymbol declaringType = context.SemanticModel.GetTypeInfo(node.Expression).Type;
string propertyName = node.Name.Identifier.Text;
// suggest replacement
for (ITypeSymbol type = declaringType; type != null; type = type.BaseType)
{
if (this.NetFieldWrapperProperties.TryGetValue($"{type}::{propertyName}", out string suggestedPropertyName))
{
context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI002"], context.Node.GetLocation(), node, memberType.Type.Name, suggestedPropertyName));
break;
}
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
}
}
/// <summary>Analyse a syntax node and add a diagnostic message if it implicitly converts a net field.</summary>
/// <param name="context">The analysis context.</param>
private void AnalyseNetFieldConversions(SyntaxNodeAnalysisContext context)
{
try
{
BinaryExpressionSyntax binaryExpression = (BinaryExpressionSyntax)context.Node;
foreach (var pair in new[] { Tuple.Create(binaryExpression.Left, binaryExpression.Right), Tuple.Create(binaryExpression.Right, binaryExpression.Left) })
{
// get node info
ExpressionSyntax curExpression = pair.Item1; // the side of the comparison being examined
ExpressionSyntax otherExpression = pair.Item2; // the other side
TypeInfo typeInfo = context.SemanticModel.GetTypeInfo(curExpression);
if (!this.IsNetType(typeInfo.Type))
continue;
// warn for implicit conversion
if (!this.IsNetType(typeInfo.ConvertedType))
{
context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI001"], context.Node.GetLocation(), curExpression, typeInfo.Type.Name, typeInfo.ConvertedType));
break;
}
// warn for comparison to null
// An expression like `building.indoors != null` will sometimes convert `building.indoors` to NetFieldBase instead of object before comparison. Haven't reproduced this in unit tests yet.
Optional<object> otherValue = context.SemanticModel.GetConstantValue(otherExpression);
if (otherValue.HasValue && otherValue.Value == null)
{
context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI001"], context.Node.GetLocation(), curExpression, typeInfo.Type.Name, "null"));
break;
}
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
}
}
/// <summary>Get whether a type symbol references a <c>Netcode</c> type.</summary>
/// <param name="typeSymbol">The type symbol.</param>
private bool IsNetType(ITypeSymbol typeSymbol)
{
return typeSymbol?.ContainingNamespace?.Name == NetFieldAnalyzer.NetcodeNamespace;
}
}
}

View File

@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace StardewModdingAPI.ModBuildConfig.Analyzer
{
/// <summary>Detects references to a field which has been replaced.</summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ObsoleteFieldAnalyzer : DiagnosticAnalyzer
{
/*********
** Properties
*********/
/// <summary>Maps obsolete fields/properties to their non-obsolete equivalent.</summary>
private readonly IDictionary<string, string> ReplacedFields = new Dictionary<string, string>
{
// Farmer
["StardewValley.Farmer::friendships"] = "friendshipData"
};
/// <summary>Describes the diagnostic rule covered by the analyzer.</summary>
private readonly IDictionary<string, DiagnosticDescriptor> Rules = new Dictionary<string, DiagnosticDescriptor>
{
["SMAPI003"] = new DiagnosticDescriptor(
id: "SMAPI003",
title: "Reference to obsolete field",
messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/buildmsg/smapi003 for details.",
category: "SMAPI.CommonErrors",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
helpLinkUri: "https://smapi.io/buildmsg/smapi003"
)
};
/*********
** Accessors
*********/
/// <summary>The descriptors for the diagnostics that this analyzer is capable of producing.</summary>
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public ObsoleteFieldAnalyzer()
{
this.SupportedDiagnostics = ImmutableArray.CreateRange(this.Rules.Values);
}
/// <summary>Called once at session start to register actions in the analysis context.</summary>
/// <param name="context">The analysis context.</param>
public override void Initialize(AnalysisContext context)
{
// SMAPI003: avoid obsolete fields
context.RegisterSyntaxNodeAction(
this.AnalyzeObsoleteFields,
SyntaxKind.SimpleMemberAccessExpression
);
}
/*********
** Private methods
*********/
/// <summary>Analyse a syntax node and add a diagnostic message if it references an obsolete field.</summary>
/// <param name="context">The analysis context.</param>
private void AnalyzeObsoleteFields(SyntaxNodeAnalysisContext context)
{
try
{
// get reference info
MemberAccessExpressionSyntax node = (MemberAccessExpressionSyntax)context.Node;
ITypeSymbol declaringType = context.SemanticModel.GetTypeInfo(node.Expression).Type;
string propertyName = node.Name.Identifier.Text;
// suggest replacement
for (ITypeSymbol type = declaringType; type != null; type = type.BaseType)
{
if (this.ReplacedFields.TryGetValue($"{type}::{propertyName}", out string replacement))
{
context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI003"], context.Node.GetLocation(), $"{type}.{propertyName}", replacement));
break;
}
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
}
}
}
}

View File

@ -0,0 +1,4 @@
using System.Reflection;
[assembly: AssemblyTitle("SMAPI.ModBuildConfig.Analyzer")]
[assembly: AssemblyDescription("")]

View File

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard1.3</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<IncludeBuildOutput>false</IncludeBuildOutput>
<OutputPath>bin</OutputPath>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.4.0" PrivateAssets="all" />
<PackageReference Update="NETStandard.Library" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,58 @@
param($installPath, $toolsPath, $package, $project)
if($project.Object.SupportsPackageDependencyResolution)
{
if($project.Object.SupportsPackageDependencyResolution())
{
# Do not install analyzers via install.ps1, instead let the project system handle it.
return
}
}
$analyzersPaths = Join-Path (Join-Path (Split-Path -Path $toolsPath -Parent) "analyzers") * -Resolve
foreach($analyzersPath in $analyzersPaths)
{
if (Test-Path $analyzersPath)
{
# Install the language agnostic analyzers.
foreach ($analyzerFilePath in Get-ChildItem -Path "$analyzersPath\*.dll" -Exclude *.resources.dll)
{
if($project.Object.AnalyzerReferences)
{
$project.Object.AnalyzerReferences.Add($analyzerFilePath.FullName)
}
}
}
}
# $project.Type gives the language name like (C# or VB.NET)
$languageFolder = ""
if($project.Type -eq "C#")
{
$languageFolder = "cs"
}
if($project.Type -eq "VB.NET")
{
$languageFolder = "vb"
}
if($languageFolder -eq "")
{
return
}
foreach($analyzersPath in $analyzersPaths)
{
# Install language specific analyzers.
$languageAnalyzersPath = join-path $analyzersPath $languageFolder
if (Test-Path $languageAnalyzersPath)
{
foreach ($analyzerFilePath in Get-ChildItem -Path "$languageAnalyzersPath\*.dll" -Exclude *.resources.dll)
{
if($project.Object.AnalyzerReferences)
{
$project.Object.AnalyzerReferences.Add($analyzerFilePath.FullName)
}
}
}
}

View File

@ -0,0 +1,65 @@
param($installPath, $toolsPath, $package, $project)
if($project.Object.SupportsPackageDependencyResolution)
{
if($project.Object.SupportsPackageDependencyResolution())
{
# Do not uninstall analyzers via uninstall.ps1, instead let the project system handle it.
return
}
}
$analyzersPaths = Join-Path (Join-Path (Split-Path -Path $toolsPath -Parent) "analyzers") * -Resolve
foreach($analyzersPath in $analyzersPaths)
{
# Uninstall the language agnostic analyzers.
if (Test-Path $analyzersPath)
{
foreach ($analyzerFilePath in Get-ChildItem -Path "$analyzersPath\*.dll" -Exclude *.resources.dll)
{
if($project.Object.AnalyzerReferences)
{
$project.Object.AnalyzerReferences.Remove($analyzerFilePath.FullName)
}
}
}
}
# $project.Type gives the language name like (C# or VB.NET)
$languageFolder = ""
if($project.Type -eq "C#")
{
$languageFolder = "cs"
}
if($project.Type -eq "VB.NET")
{
$languageFolder = "vb"
}
if($languageFolder -eq "")
{
return
}
foreach($analyzersPath in $analyzersPaths)
{
# Uninstall language specific analyzers.
$languageAnalyzersPath = join-path $analyzersPath $languageFolder
if (Test-Path $languageAnalyzersPath)
{
foreach ($analyzerFilePath in Get-ChildItem -Path "$languageAnalyzersPath\*.dll" -Exclude *.resources.dll)
{
if($project.Object.AnalyzerReferences)
{
try
{
$project.Object.AnalyzerReferences.Remove($analyzerFilePath.FullName)
}
catch
{
}
}
}
}
}

View File

@ -57,4 +57,5 @@
</ItemGroup>
<Import Project="..\SMAPI.Common\StardewModdingAPI.Common.projitems" Label="Shared" />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\..\build\prepare-nuget-package.targets" />
</Project>

View File

@ -142,7 +142,7 @@
<Target Name="BeforeBuild">
<Error Condition="'$(OS)' != 'OSX' AND '$(OS)' != 'Unix' AND '$(OS)' != 'Windows_NT'" Text="The mod build package doesn't recognise OS type '$(OS)'." />
<Error Condition="!Exists('$(GamePath)')" Text="The mod build package can't find your game folder. You can specify where to find it; see details at https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#game-path." />
<Error Condition="!Exists('$(GamePath)')" Text="The mod build package can't find your game folder. You can specify where to find it; see https://smapi.io/buildmsg/game-path." />
<Error Condition="'$(OS)' == 'Windows_NT' AND !Exists('$(GamePath)\Stardew Valley.exe')" Text="The mod build package found a game folder at $(GamePath), but it doesn't contain the Stardew Valley.exe file. If this folder is invalid, delete it and the package will autodetect another game install path." />
<Error Condition="'$(OS)' != 'Windows_NT' AND !Exists('$(GamePath)\StardewValley.exe')" Text="The mod build package found a game folder at $(GamePath), but it doesn't contain the StardewValley.exe file. If this folder is invalid, delete it and the package will autodetect another game install path." />
<Error Condition="!Exists('$(GamePath)\StardewModdingAPI.exe')" Text="The mod build package found a game folder at $(GamePath), but it doesn't contain SMAPI. You need to install SMAPI before building the mod." />

View File

@ -2,7 +2,7 @@
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>Pathoschild.Stardew.ModBuildConfig</id>
<version>2.0.3-alpha20180325</version>
<version>2.1-alpha20180409</version>
<title>Build package for SMAPI mods</title>
<authors>Pathoschild</authors>
<owners>Pathoschild</owners>
@ -12,28 +12,10 @@
<iconUrl>https://raw.githubusercontent.com/Pathoschild/SMAPI/develop/src/SMAPI.ModBuildConfig/assets/nuget-icon.png</iconUrl>
<description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods.</description>
<releaseNotes>
2.0:
- Added: mods are now copied into the `Mods` folder automatically (configurable).
- Added: release zips are now created automatically in your build output folder (configurable).
- Added: mod deploy and release zips now exclude Json.NET automatically, since it's provided by SMAPI.
- Added mod's version to release zip filename.
- Improved errors to simplify troubleshooting.
- Fixed release zip not having a mod folder.
- Fixed release zip failing if mod name contains characters that aren't valid in a filename.
2.0.1:
- Fixed mod deploy failing to create subfolders if they don't already exist.
2.0.2:
- Fixed compatibility issue on Linux.
2.0.3:
2.1:
- Added support for Stardew Valley 1.3.
- Added support for unit test projects.
- Added C# analyzers to warn about implicit conversions of Netcode fields in Stardew Valley 1.3.
</releaseNotes>
</metadata>
<files>
<file src="build/smapi.targets" target="build/Pathoschild.Stardew.ModBuildConfig.targets" />
<file src="bin/StardewModdingAPI.ModBuildConfig.dll" target="build/StardewModdingAPI.ModBuildConfig.dll" />
</files>
</package>

View File

@ -66,6 +66,9 @@
<Name>StardewModdingAPI</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\..\build\common.targets" />
</Project>

View File

@ -12,11 +12,8 @@ namespace StardewModdingAPI.Web.Framework.RewriteRules
/*********
** Properties
*********/
/// <summary>A predicate which indicates when the rule should be applied.</summary>
private readonly Func<HttpRequest, bool> ShouldRewrite;
/// <summary>The new URL to which to redirect.</summary>
private readonly string NewUrl;
/// <summary>Get the new URL to which to redirect (or <c>null</c> to skip).</summary>
private readonly Func<HttpRequest, string> NewUrl;
/*********
@ -27,8 +24,7 @@ namespace StardewModdingAPI.Web.Framework.RewriteRules
/// <param name="url">The new URL to which to redirect.</param>
public RedirectToUrlRule(Func<HttpRequest, bool> shouldRewrite, string url)
{
this.ShouldRewrite = shouldRewrite ?? (req => true);
this.NewUrl = url;
this.NewUrl = req => shouldRewrite(req) ? url : null;
}
/// <summary>Construct an instance.</summary>
@ -37,8 +33,7 @@ namespace StardewModdingAPI.Web.Framework.RewriteRules
public RedirectToUrlRule(string pathRegex, string url)
{
Regex regex = new Regex(pathRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled);
this.ShouldRewrite = req => req.Path.HasValue && regex.IsMatch(req.Path.Value);
this.NewUrl = url;
this.NewUrl = req => req.Path.HasValue ? regex.Replace(req.Path.Value, url) : null;
}
/// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
@ -47,14 +42,15 @@ namespace StardewModdingAPI.Web.Framework.RewriteRules
{
HttpRequest request = context.HttpContext.Request;
// check condition
if (!this.ShouldRewrite(request))
// check rewrite
string newUrl = this.NewUrl(request);
if (newUrl == null || newUrl == request.Path.Value)
return;
// redirect request
HttpResponse response = context.HttpContext.Response;
response.StatusCode = (int)HttpStatusCode.Redirect;
response.Headers["Location"] = this.NewUrl;
response.Headers["Location"] = newUrl;
context.Result = RuleResult.EndResponse;
}
}

View File

@ -155,6 +155,7 @@ namespace StardewModdingAPI.Web
// shortcut redirects
redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://stardewvalleywiki.com/Modding:SMAPI_compatibility"));
redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index"));
redirects.Add(new RedirectToUrlRule(@"^/buildmsg(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#$1"));
// redirect legacy canimod.com URLs
var wikiRedirects = new Dictionary<string, string[]>

View File

@ -45,9 +45,17 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{09CF91E5
..\build\common.targets = ..\build\common.targets
..\build\GlobalAssemblyInfo.cs = ..\build\GlobalAssemblyInfo.cs
..\build\prepare-install-package.targets = ..\build\prepare-install-package.targets
..\build\prepare-nuget-package.targets = ..\build\prepare-nuget-package.targets
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.ModBuildConfig", "SMAPI.ModBuildConfig\StardewModdingAPI.ModBuildConfig.csproj", "{EA4F1E80-743F-4A1D-9757-AE66904A196A}"
ProjectSection(ProjectDependencies) = postProject
{80AD8528-AA49-4731-B4A6-C691845815A1} = {80AD8528-AA49-4731-B4A6-C691845815A1}
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.ModBuildConfig.Analyzer", "SMAPI.ModBuildConfig.Analyzer\StardewModdingAPI.ModBuildConfig.Analyzer.csproj", "{80AD8528-AA49-4731-B4A6-C691845815A1}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig.Analyzer.Tests", "SMAPI.ModBuildConfig.Analyzer.Tests\SMAPI.ModBuildConfig.Analyzer.Tests.csproj", "{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}"
EndProject
Global
GlobalSection(SharedMSBuildProjectFiles) = preSolution
@ -137,6 +145,30 @@ Global
{EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|Mixed Platforms.Build.0 = Release|x86
{EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|x86.ActiveCfg = Release|x86
{EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|x86.Build.0 = Release|x86
{80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|x86.ActiveCfg = Debug|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|x86.Build.0 = Debug|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Any CPU.Build.0 = Release|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Mixed Platforms.Build.0 = Release|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Release|x86.ActiveCfg = Release|Any CPU
{80AD8528-AA49-4731-B4A6-C691845815A1}.Release|x86.Build.0 = Release|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|x86.ActiveCfg = Debug|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|x86.Build.0 = Debug|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Any CPU.Build.0 = Release|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Mixed Platforms.Build.0 = Release|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|x86.ActiveCfg = Release|Any CPU
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -147,6 +179,7 @@ Global
{2AA02FB6-FF03-41CF-A215-2EE60AB4F5DC} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}
{EB35A917-67B9-4EFA-8DFC-4FB49B3949BB} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA}
{09CF91E5-5BAB-4650-A200-E5EA9A633046} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA}
{0CF97929-B0D0-4D73-B7BF-4FF7191035F9} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {70143042-A862-47A8-A677-7C819DDC90DC}