Skip to content

Commit

Permalink
Merge pull request #63 from bdach/check-localisation-keys-for-uniqueness
Browse files Browse the repository at this point in the history
Add analyser which checks uniqueness of translation keys in a single file
  • Loading branch information
smoogipoo authored May 17, 2024
2 parents 117c350 + 700668f commit 07256c6
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Threading.Tasks;
using LocalisationAnalyser.Analysers;
using Xunit;

namespace LocalisationAnalyser.Tests.Analysers
{
public class LocalisationKeyUsedMultipleTimesInClassAnalyserTests : AbstractAnalyserTests<LocalisationKeyUsedMultipleTimesInClassAnalyser>
{
[Theory]
[InlineData("DuplicatedLocalisationKeys")]
public Task RunTest(string name) => Check(name);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using osu.Framework.Localisation;

namespace TestProject.Localisation
{
public static class CommonStrings
{
private const string prefix = @"TestProject.Localisation.Common";

/// <summary>
/// "first string"
/// </summary>
public static LocalisableString FirstString => new TranslatableString(getKey([|@"first_string"|]), @"first string");

/// <summary>
/// "second string"
/// </summary>
public static LocalisableString SecondString => new TranslatableString(getKey([|@"first_string"|]), @"second string");

/// <summary>
/// "third string"
/// </summary>
public static LocalisableString ThirdString => new TranslatableString(getKey(@"third_string"), @"third string");

/// <summary>
/// "first string with arguments (like {0})"
/// </summary>
public static LocalisableString FirstStringWithArguments(string test) => new TranslatableString(getKey([|@"first_string"|]), @"first string with arguments (like {0})");

/// <summary>
/// "second string with arguments (like {0})"
/// </summary>
public static LocalisableString SecondStringWithArguments(string test) => new TranslatableString(getKey(@"second_string_with_args"), @"second string with arguments (like {0})");

private static string getKey(string key) => $@"{prefix}:{key}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using LocalisationAnalyser.Localisation;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace LocalisationAnalyser.Analysers
{
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class LocalisationKeyUsedMultipleTimesInClassAnalyser : DiagnosticAnalyzer

Check warning on line 12 in LocalisationAnalyser/Analysers/LocalisationKeyUsedMultipleTimesInClassAnalyser.cs

View workflow job for this annotation

GitHub Actions / Deploy

Missing XML comment for publicly visible type or member 'LocalisationKeyUsedMultipleTimesInClassAnalyser'
{
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>

Check warning on line 14 in LocalisationAnalyser/Analysers/LocalisationKeyUsedMultipleTimesInClassAnalyser.cs

View workflow job for this annotation

GitHub Actions / Deploy

Missing XML comment for publicly visible type or member 'LocalisationKeyUsedMultipleTimesInClassAnalyser.SupportedDiagnostics'
ImmutableArray.Create(DiagnosticRules.LOCALISATION_KEY_USED_MULTIPLE_TIMES_IN_CLASS);

public override void Initialize(AnalysisContext context)

Check warning on line 17 in LocalisationAnalyser/Analysers/LocalisationKeyUsedMultipleTimesInClassAnalyser.cs

View workflow job for this annotation

GitHub Actions / Deploy

Missing XML comment for publicly visible type or member 'LocalisationKeyUsedMultipleTimesInClassAnalyser.Initialize(AnalysisContext)'
{
// See https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Actions%20Semantics.md for more information
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterSyntaxTreeAction(analyseSyntaxTree);
}

private void analyseSyntaxTree(SyntaxTreeAnalysisContext context)
{
// Optimisation to not inspect too many files.
if (!context.Tree.FilePath.EndsWith("Strings.cs"))
return;

if (!LocalisationFile.TryRead(context.Tree, out var file, out _))
return;

var duplicateKeys = findDuplicateKeys(file).ToImmutableHashSet();

var root = context.Tree.GetRoot();

foreach (var property in root.DescendantNodes().OfType<PropertyDeclarationSyntax>())
markPropertyIfDuplicate(context, property, file, duplicateKeys);

foreach (var method in root.DescendantNodes().OfType<MethodDeclarationSyntax>())
markMethodIfDuplicate(context, method, file, duplicateKeys);
}

private IEnumerable<string> findDuplicateKeys(LocalisationFile localisationFile)
{
var hashSet = new HashSet<string>();

foreach (var member in localisationFile.Members)
{
if (!hashSet.Add(member.Key))
yield return member.Key;
}
}

private void markMethodIfDuplicate(SyntaxTreeAnalysisContext context, MethodDeclarationSyntax method,
LocalisationFile localisationFile, ImmutableHashSet<string> duplicateKeys)
{
string? name = method.Identifier.Text;
if (name == null)
return;

var member = localisationFile.Members.SingleOrDefault(m =>
m.Name == name && m.Parameters.Length == method.ParameterList.Parameters.Count);

if (member == null)
return;

if (!duplicateKeys.Contains(member.Key))
return;

var creationExpression = (ObjectCreationExpressionSyntax)method.ExpressionBody.Expression;
var keyArgument = creationExpression.ArgumentList!.Arguments[0];

if (keyArgument.Expression is not InvocationExpressionSyntax methodInvocation
|| (methodInvocation.Expression as IdentifierNameSyntax)?.Identifier.Text != "getKey"
|| methodInvocation.ArgumentList.Arguments.Count != 1)
{
return;
}

var keyString = methodInvocation.ArgumentList.Arguments[0];

context.ReportDiagnostic(Diagnostic.Create(DiagnosticRules.LOCALISATION_KEY_USED_MULTIPLE_TIMES_IN_CLASS, keyString.GetLocation(), member.Key));
}

private void markPropertyIfDuplicate(SyntaxTreeAnalysisContext context, PropertyDeclarationSyntax property,
LocalisationFile localisationFile, ImmutableHashSet<string> duplicateKeys)
{
string? name = property.Identifier.Text;
if (name == null)
return;

var member = localisationFile.Members.SingleOrDefault(m => m.Name == name && m.Parameters.Length == 0);

if (member == null)
return;

if (!duplicateKeys.Contains(member.Key))
return;

var creationExpression = (ObjectCreationExpressionSyntax)property.ExpressionBody.Expression;
var keyArgument = creationExpression.ArgumentList!.Arguments[0];

if (keyArgument.Expression is not InvocationExpressionSyntax methodInvocation
|| (methodInvocation.Expression as IdentifierNameSyntax)?.Identifier.Text != "getKey"
|| methodInvocation.ArgumentList.Arguments.Count != 1)
{
return;
}

var keyString = methodInvocation.ArgumentList.Arguments[0];

context.ReportDiagnostic(Diagnostic.Create(DiagnosticRules.LOCALISATION_KEY_USED_MULTIPLE_TIMES_IN_CLASS, keyString.GetLocation(), member.Key));
}
}
}
9 changes: 9 additions & 0 deletions LocalisationAnalyser/DiagnosticRules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ public static class DiagnosticRules
true,
"Prevent confusion by matching the XMLDoc and the translation text.");

public static readonly DiagnosticDescriptor LOCALISATION_KEY_USED_MULTIPLE_TIMES_IN_CLASS = new DiagnosticDescriptor(
"OLOC004",
"Localisation key used multiple times in class",
"The localisation key '{0}' has been used multiple times in this class",
"Globalization",
DiagnosticSeverity.Warning,
true,
"Use unique localisation keys for every member in a single class containing localisations.");

public static readonly DiagnosticDescriptor RESOLVED_ATTRIBUTE_NULLABILITY_IS_REDUNDANT = new DiagnosticDescriptor(
"OSUF001",
"Nullability should be provided by the type",
Expand Down

0 comments on commit 07256c6

Please sign in to comment.