Skip to content

Commit

Permalink
Added analyzer project and a few initial analyzers (#1905)
Browse files Browse the repository at this point in the history
* Added analyzer project for analysis and code gen

* Added comment why netstandard2.0 is used for analyzers

Analyzers MUST target netstandard2.0, do not update unless Microsoft (MSDN) says it's possible.

* Added JetBrains.Annotation package and applied it to Validate.NotNull API

Co-authored-by: Jannify <[email protected]>

* Changed where Validate.IsTrue was used for null checking

* Added codefix for null propagation on UnityEngine.Object

* Added "is null" analyzer for unity object lifetime

* Added code fixer for "?." on UnityEngine.Object

* Fixed JetBrains namespace collision with UnityEngine.Core

* Fixed invalid null propagation on UnityEngine.Object

* Set "Nitrox" as default style and fixed expression bodied members getting reverted

* Added GetFixAllProvider override to UnitySkippedObjectLifetimeFixProvider

CodeFixProvider types should override it by convention.

* Added ?? analysis to UnitySkippedObjectLifetimeAnalyzer

* Disabled resharper analysis of ?? for UnityEngine.Object

* Set MSBuild property NitroxProject to false by default for all projects

* Added LocalizationAnalyzer.cs

This analyzer detects if localization key exists in the en.json (English localization). If it does not, then a warning is emitted.

* Disabled localization analyzer if en.json is missing

* Changed name of field invalidLocalizationKey to invalidLocalizationKeyRule

* Added analyzer to prefer interpolated strings over string concat

* Fixed typo in comment of StringUsageAnalyzer

* Refactored unity object lifetime analyzer

Added simple ITypeSymbol.IsType() that will properly resolve inheritance tree.

* Used string interpolation in Nitrox.Analyzer project where possible

* Added Increment source generator support to Nitrox.Analyzers

* Improved accuracy of type symbol compare in UnitySkippedObjectLifetimeAnalyzer.cs

Changed analyzer to get the type symbol of UnityEngine.Object from the compilation context at startup.

* Changed debug logger to apply on any type

* Added dynamic resolve for System.Text.Json based on runtime used

* Removed dependency on System.Text.Json

Visual Studio cannot find external libraries compiled with analyzers. See issue:
dotnet/roslyn#41785 (comment)
For now we regex parse the English localization JSON file.

Co-authored-by: Jannify <[email protected]>
  • Loading branch information
Measurity and Jannify authored Dec 4, 2022
1 parent 02789b6 commit 899dde6
Show file tree
Hide file tree
Showing 18 changed files with 640 additions and 36 deletions.
7 changes: 3 additions & 4 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ root = true
# See the documentation for reference:
# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference


[*]
indent_style = space

Expand Down Expand Up @@ -34,7 +33,7 @@ csharp_new_line_between_query_expression_clauses = true
# Indentation
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
csharp_indent_labels = false
csharp_indent_labels = flush_left

# Spacing
csharp_space_after_cast = false
Expand Down Expand Up @@ -67,13 +66,13 @@ dotnet_style_collection_initializer = true:warning
dotnet_style_coalesce_expression = true:suggestion # Prefer a ?? b
dotnet_style_null_propagation = false:warning # Do not prefer foo?.bar

# Implicit and explicity styles (disallow use of `var` - types have to be specified explicitly)
# Implicit and explicit styles (disallow use of `var` - types have to be specified explicitly)
csharp_style_var_for_built_in_types = false:warning
csharp_style_var_when_type_is_apparent = false:warning
csharp_style_var_elsewhere = false:warning

# Expression-bodied members
csharp_style_expression_bodied_methods = false:none
csharp_style_expression_bodied_methods = when_on_single_line:none

# Null checking preferences
csharp_style_conditional_delegate_call = false:warning # Do not prefer foo?.Invoke(bar); over if (foo != null) { foo(bar); }, with all it's newlines.
Expand Down
24 changes: 24 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<PropertyGroup>
<Version>1.6.0.1</Version>
<LangVersion>10</LangVersion>
<NitroxProject>false</NitroxProject>
<TestLibrary>false</TestLibrary>
<NitroxLibrary>false</NitroxLibrary>
<UnityModLibrary>false</UnityModLibrary>
Expand All @@ -18,6 +19,9 @@
</PropertyGroup>

<PropertyGroup Condition="$([System.Text.RegularExpressions.Regex]::IsMatch($(MSBuildProjectName), '^Nitrox.*$'))">
<NitroxProject>true</NitroxProject>
</PropertyGroup>
<PropertyGroup Condition="'$(NitroxProject)' == 'true' and '$(MSBuildProjectName)' != 'Nitrox.Analyzers'">
<NitroxLibrary>true</NitroxLibrary>
</PropertyGroup>
<PropertyGroup Condition="'$(NitroxLibrary)' == 'true' and '$(MSBuildProjectName)' != 'NitroxModel' and '$(MSBuildProjectName)' != 'NitroxServer' and '$(MSBuildProjectName)' != 'Nitrox.BuildTool'">
Expand All @@ -27,6 +31,26 @@
<TestLibrary>true</TestLibrary>
</PropertyGroup>

<!-- Shared dependencies for all Nitrox.* projects -->
<Choose>
<When Condition="'$(NitroxProject)' == 'true'">
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2022.3.1">
<Aliases>JB</Aliases>
</PackageReference>
</ItemGroup>
</When>
</Choose>

<!-- Include our analyzer and code gen library to all Nitrox projects -->
<Choose>
<When Condition="'$(NitroxLibrary)' == 'true'">
<ItemGroup>
<ProjectReference Include="..\Nitrox.Analyzers\Nitrox.Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</When>
</Choose>

<!-- Include default project references to all other "Nitrox*" projects -->
<Choose>
<When Condition="'$(UnityModLibrary)' == 'true'">
Expand Down
166 changes: 166 additions & 0 deletions Nitrox.Analyzers/Diagnostics/LocalizationAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Nitrox.Analyzers.Diagnostics;

/// <summary>
/// Tests that requested localization keys exist in the English localization file.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class LocalizationAnalyzer : DiagnosticAnalyzer
{
public const string INVALID_LOCALIZATION_KEY_DIAGNOSTIC_ID = $"{nameof(LocalizationAnalyzer)}001";

private const string NITROX_LOCALIZATION_PREFIX = "Nitrox_";
private static readonly string relativePathFromSolutionDirToEnglishLanguageFile = Path.Combine("Nitrox.Assets.Subnautica", "LanguageFiles", "en.json");
private static readonly Regex localizationParseRegex = new(@"^\s*""([^""]+)""\s*:\s*""([^""]+)""", RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly DiagnosticDescriptor invalidLocalizationKeyRule = new(INVALID_LOCALIZATION_KEY_DIAGNOSTIC_ID,
"Tests localization usages are valid",
"Localization key '{0}' does not exist in '{1}'",
"Usage",
DiagnosticSeverity.Warning,
true,
"Tests that requested localization keys exist in the English localization file");

/// <summary>
/// Gets the list of rules of supported diagnostics.
/// </summary>
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(invalidLocalizationKeyRule);

/// <summary>
/// Initializes the analyzer by registering on symbol occurrence in the targeted code.
/// </summary>
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterCompilationStartAction(startContext =>
{
startContext.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.projectdir", out string projectDir);
if (LocalizationHelper.Load(projectDir))
{
startContext.RegisterSyntaxNodeAction(AnalyzeStringNode, SyntaxKind.StringLiteralExpression);
}
});
}

/// <summary>
/// Analyzes string literals in code that are passed as argument to 'Language.main.Get'.
/// </summary>
private void AnalyzeStringNode(SyntaxNodeAnalysisContext context)
{
LiteralExpressionSyntax expression = (LiteralExpressionSyntax)context.Node;
if (expression.Parent is not ArgumentSyntax argument)
{
return;
}
if (argument.Parent is not { Parent: InvocationExpressionSyntax invocation })
{
return;
}
if (context.SemanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol method)
{
return;
}
if (method is not { ContainingType.Name: "Language", Name: "Get" })
{
return;
}
// Ignore language call for non-nitrox keys.
string stringValue = expression.Token.ValueText;
if (!stringValue.StartsWith(NITROX_LOCALIZATION_PREFIX, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (LocalizationHelper.ContainsKey(stringValue))
{
return;
}
context.ReportDiagnostic(Diagnostic.Create(invalidLocalizationKeyRule, context.Node.GetLocation(), stringValue, LocalizationHelper.FileName));
}

/// <summary>
/// Wrapper API for synchronized access to the English localization file.
/// </summary>
private static class LocalizationHelper
{
private static readonly object locker = new();
private static string EnglishLocalizationFileName { get; set; } = "";
private static ImmutableDictionary<string, string> EnglishLocalization { get; set; } = ImmutableDictionary<string, string>.Empty;

public static bool IsEmpty
{
get
{
lock (locker)
{
return EnglishLocalization.IsEmpty;
}
}
}

public static string FileName
{
get
{
lock (locker)
{
return EnglishLocalizationFileName;
}
}
}

public static bool ContainsKey(string key)
{
lock (locker)
{
return EnglishLocalization.ContainsKey(key);
}
}

public static bool Load(string projectDir)
{
if (string.IsNullOrWhiteSpace(projectDir))
{
return false;
}
string solutionDir = Directory.GetParent(projectDir)?.Parent?.FullName;
if (!Directory.Exists(solutionDir))
{
return false;
}

string enJson;
lock (locker)
{
EnglishLocalizationFileName = Path.Combine(solutionDir, relativePathFromSolutionDirToEnglishLanguageFile);
if (!File.Exists(EnglishLocalizationFileName))
{
return false;
}

enJson = File.ReadAllText(EnglishLocalizationFileName);
}
// Parse localization JSON to dictionary for lookup.
Dictionary<string, string> keyValue = new();
foreach (Match match in localizationParseRegex.Matches(enJson))
{
keyValue.Add(match.Groups[1].Value, match.Groups[2].Value);
}
lock (locker)
{
EnglishLocalization = keyValue.ToImmutableDictionary();
}
return true;
}
}
}
80 changes: 80 additions & 0 deletions Nitrox.Analyzers/Diagnostics/StringUsageAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Nitrox.Analyzers.Diagnostics;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class StringUsageAnalyzer : DiagnosticAnalyzer
{
public const string PREFER_INTERPOLATED_STRING_DIAGNOSTIC_ID = $"{nameof(StringUsageAnalyzer)}001";

private static readonly DiagnosticDescriptor preferInterpolatedStringRule = new(PREFER_INTERPOLATED_STRING_DIAGNOSTIC_ID,
"Prefer interpolated string over string concat",
"String concat can be turned into interpolated string",
"Usage",
DiagnosticSeverity.Warning,
true,
"Prefer interpolated string over concatenating strings");

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(preferInterpolatedStringRule);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterSyntaxNodeAction(AnalyzeAddNode, SyntaxKind.AddExpression);
}

private void AnalyzeAddNode(SyntaxNodeAnalysisContext context)
{
bool IsPartOfStringConcat(SyntaxNode node)
{
switch (node)
{
case LiteralExpressionSyntax literal:
return literal.IsKind(SyntaxKind.StringLiteralExpression);
case InterpolatedStringExpressionSyntax:
return true;
case MemberAccessExpressionSyntax member:
string memberType = context.SemanticModel.GetTypeInfo(member).ConvertedType?.Name;
return memberType == "String";
case BinaryExpressionSyntax binary:
// If one side is string-ish then the other side will get implicitly casted to string.
return binary.IsKind(SyntaxKind.AddExpression) && (IsPartOfStringConcat(binary.Right) || IsPartOfStringConcat(binary.Left));
default:
return false;
}
}

static bool IsLeftMostNodeInConcat(SyntaxNode node)
{
switch (node)
{
case BinaryExpressionSyntax:
case InterpolatedStringContentSyntax:
return true;
case ParenthesizedExpressionSyntax:
return IsLeftMostNodeInConcat(node.Parent);
}
return false;
}

BinaryExpressionSyntax expression = (BinaryExpressionSyntax)context.Node;
// Deduplicate warnings. Only left most '+' of the expression should be handled here.
if (IsLeftMostNodeInConcat(expression.Parent))
{
return;
}
// Test if this should be interpolated.
if (!IsPartOfStringConcat(expression.Left) && !IsPartOfStringConcat(expression.Right))
{
return;
}

context.ReportDiagnostic(Diagnostic.Create(preferInterpolatedStringRule, expression.GetLocation(), expression));
}
}
Loading

0 comments on commit 899dde6

Please sign in to comment.